test(demo): add full guided-demo walkthrough and targeted demo tests

Adds one comprehensive Playwright test that walks a fresh Operations
Manager session through all 8 Demo Guide steps performing the real
action at each one, then restores the environment. Writing it surfaced
a real desktop layout bug: the Demo Guide's fixed side panel overlapped
main content with no reflow, making the return form's "Review return"
button unclickable while the guide was open at ordinary viewport widths.
Fixed by reserving layout space via a guide-open class. Also adds mobile
bottom-sheet, keyboard-reachability, and console-error checks.
This commit is contained in:
NuklearRabbit
2026-08-03 15:12:29 +02:00
parent 65835ea40a
commit 07d5605812
5 changed files with 234 additions and 1 deletions
+36
View File
@@ -742,3 +742,39 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem
- Exact next action: full guided-demo Playwright test + remaining targeted demo tests per
section 19 (mobile guide, keyboard nav, all scenario flows, About page, accessibility/
reduced-motion/console/network checks) — task #35.
### Batch 6 — full guided-demo test + targeted demo tests (complete)
- **Found and fixed a real, fairly serious desktop layout bug** while writing the full
guided-demo test: the Demo Guide's fixed right-side panel (400px wide) overlapped the
main content area at normal desktop widths with no reflow, so its own step-list buttons
intercepted pointer events meant for the page underneath (concretely: the return form's
"Review return" button was unclickable while the guide was open, at exactly the
viewport size Playwright's default test browser uses — this would have hit real
visitors on ordinary laptop screens too). Fixed by adding a `guide-open` class to
`.app-workspace` that reserves `padding-right: min(400px, 92vw)` while the guide is open
(≥701px only; the ≤700px bottom-sheet layout is unaffected), so content reflows aside
instead of sitting underneath the panel.
- Added `frontend/e2e/guided-demo-full.spec.ts`: one comprehensive test walking a fresh
Operations Manager session through all 8 Demo Guide steps in order, performing the
**real** action at each step (not just verifying copy) — processes the actual
odometer-anomaly return, resolves the resulting data-quality issue, merges the
duplicate customer, asks a suggested knowledge question, checks automation + audit,
reviews the About page — using the guide's own progression controls
("Volgende"/"Ga naar deze stap"/"Ga verder met de demo") throughout, then resets the
demo data again at the end to restore the environment per the brief's requirement.
- Added `frontend/e2e/demo-accessibility.spec.ts` (4 tests): the guide renders as a
correctly-anchored bottom sheet on a 390px mobile viewport with no horizontal overflow;
the guide never covers the return form's action buttons on desktop (regression test for
the bug above); the demo badge and guide trigger are keyboard-focusable and operable
(Enter to open, explicit close controls); key demo pages (dashboard, scenarios, about,
guide open) load with no unexpected console errors (the one expected benign 401 from
the app's own session-probe on first load is explicitly allow-listed, not silenced
blindly).
- Evidence: full Playwright suite **56 passed** (51 existing + 1 guided-demo-full + 4
demo-accessibility), confirmed stable across two consecutive full runs. Backend
untouched this batch (last gate: 127 passed/ruff/mypy clean, Batch 5).
- Exact next action: clean-checkout demo drill, final documentation set (demo-concept/
demo-scenarios/demo-data/demo-guide/demo-runbook, README, .env.example), final Unraid
deploy + live evidence with screenshots, `artifacts/demo-release/final-summary.md` —
task #36 (final).
+87
View File
@@ -0,0 +1,87 @@
import { expect, test } from "@playwright/test";
test.describe.configure({ mode: "serial" });
test("demo guide is usable as a mobile bottom sheet", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await expect(page).toHaveURL(/\/dashboard/);
const panel = page.getByRole("dialog", { name: "Gegidste demo" });
await expect(panel).toBeVisible();
const box = await panel.boundingBox();
expect(box).not.toBeNull();
// A bottom sheet: anchored to the bottom of the viewport, not a full-height side panel.
expect(box!.height).toBeLessThan(800);
expect(box!.x).toBeLessThanOrEqual(1);
await page.getByRole("button", { name: "Volgende" }).click();
await expect(page.getByRole("heading", { name: "2. Open een boeking" })).toBeVisible();
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
});
test("demo guide does not cover the return form's action buttons on desktop", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await expect(page).toHaveURL(/\/dashboard/);
await page.goto("/bookings/BK-DEMO-RETURN");
const reviewButton = page.getByRole("button", { name: "Review return" });
await expect(reviewButton).toBeVisible();
await reviewButton.click({ timeout: 5000 });
await expect(page.getByText(/Expected fleet state/)).toBeVisible();
});
test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
const guideTrigger = page.getByRole("button", { name: /Demo-gids/ });
await guideTrigger.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
await page.keyboard.press("Escape");
// The guide panel itself doesn't bind Escape (it's a persistent panel, not a transient
// popover), so close it explicitly the way a keyboard user would: activate its own
// close control.
await page.getByRole("button", { name: "Sluiten" }).click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
const badgeTrigger = page.getByRole("button", { name: /Synthetische demo/ });
await badgeTrigger.focus();
await page.keyboard.press("Enter");
await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeHidden();
});
test("key demo pages load without console errors", async ({ page }) => {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() !== "error") return;
// The app deliberately probes GET /demo/session on every load to confirm whether a
// session cookie is still valid (see AuthContext.tsx); a logged-out visitor's very
// first load always logs one benign 401 for this, which the app already handles via
// .catch() -- it is not an application error.
if (msg.text().includes("401") && msg.text().includes("Unauthorized")) return;
errors.push(msg.text());
});
page.on("pageerror", (err) => errors.push(err.message));
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/scenarios");
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
await page.goto("/about");
await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible();
await page.getByRole("button", { name: /Demo-gids/ }).click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
expect(errors, `Unexpected console errors: ${errors.join("\n")}`).toEqual([]);
});
+103
View File
@@ -0,0 +1,103 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
async function resetDemoData(request: APIRequestContext) {
const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
expect(login.ok()).toBeTruthy();
const reset = await request.post("/api/v1/demo/reset");
expect(reset.ok()).toBeTruthy();
}
test.describe.configure({ mode: "serial" });
test("full guided demo walkthrough, start to finish, restoring the environment after", async ({
page,
request,
}) => {
await resetDemoData(request);
await test.step("start the guided demo from the login screen", async () => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
});
await test.step("step 1: understand the operational state", async () => {
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Fleet readiness" })).toBeVisible();
await page.getByRole("button", { name: "Volgende" }).click();
});
await test.step("step 2: open the booking needing attention", async () => {
await expect(page.getByRole("heading", { name: "2. Open een boeking" })).toBeVisible();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
await expect(page).toHaveURL(/\/bookings\/BK-DEMO-RETURN$/);
await page.getByRole("button", { name: "Volgende" }).click();
});
await test.step("step 3: process the return with the pre-filled odometer anomaly", async () => {
await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible();
await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
await page.getByRole("button", { name: "Review return" }).click();
await expect(page.getByText(/below.*canonical reading/)).toBeVisible();
await page.getByRole("button", { name: "Confirm return" }).click();
await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible();
await page.getByRole("button", { name: "Ga verder met de demo" }).click();
});
await test.step("step 4: handle the newly created data-quality issue", async () => {
await expect(page).toHaveURL(/\/data-quality$/);
await expect(page.getByRole("heading", { name: "4. Bekijk en behandel" })).toBeVisible();
const firstIssueLink = page.locator(".data-table tbody tr").first().locator("a");
await firstIssueLink.click();
await expect(page.getByText("What's wrong")).toBeVisible();
// The newest issue is the odometer regression this return just created.
await page.getByRole("radio", { name: /Retain canonical/ }).check();
await page.getByRole("button", { name: "Resolve issue" }).click();
await expect(page.getByText(/Issue .* resolved/)).toBeVisible();
await page.getByRole("button", { name: "Ga verder met de demo" }).click();
});
await test.step("step 5: review and merge the possible duplicate customer", async () => {
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
await expect(page.getByRole("heading", { name: "5. Beoordeel en behandel" })).toBeVisible();
await page.getByRole("button", { name: /^Merge into/ }).click();
await page.getByRole("button", { name: "Yes, merge" }).click();
await expect(page.getByText(/Issue .* resolved/)).toBeVisible();
await page.getByRole("button", { name: "Ga verder met de demo" }).click();
});
await test.step("step 6: ask the procedure assistant a question", async () => {
await expect(page).toHaveURL(/\/knowledge$/);
await expect(page.getByRole("heading", { name: "6. Stel een vraag" })).toBeVisible();
await page.getByRole("button", { name: "What must I do when a vehicle returns with damage?" }).click();
await expect(page.getByText("Grounded in cited procedures")).toBeVisible();
await page.getByRole("button", { name: "Volgende" }).click();
});
await test.step("step 7: check automation and the audit trail", async () => {
await expect(page.getByRole("heading", { name: "7. Controleer automatisering" })).toBeVisible();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
await expect(page).toHaveURL(/\/automation$/);
// Plain-language status only -- never the raw backend state string (e.g. "degraded").
await expect(page.locator(".integration-cards")).not.toContainText("degraded");
await expect(page.locator(".integration-cards")).not.toContainText("no_evidence");
await page.goto("/audit");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
await page.getByRole("button", { name: /Demo-gids/ }).click();
await page.getByRole("button", { name: "Volgende" }).click();
});
await test.step("step 8: review what's real, simulated, or not yet connected", async () => {
await expect(page.getByRole("heading", { name: "8. Bekijk wat echt is" })).toBeVisible();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
await expect(page).toHaveURL(/\/about$/);
await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible();
await expect(page.getByText("Demomodus", { exact: false }).first()).toBeVisible();
await expect(page.getByText("Niet gekoppeld").first()).toBeVisible();
});
await test.step("restore the environment", async () => {
await resetDemoData(request);
});
});
+3 -1
View File
@@ -6,6 +6,7 @@ import type { Role, SearchResultItem } from "../api/types";
import { BrandMark, Icon, type IconName } from "./Icons";
import { DemoBadge } from "./DemoBadge";
import { DemoGuide, DemoGuideTrigger } from "./DemoGuide";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
@@ -46,6 +47,7 @@ const NAV_GROUPS: Array<{ label: string; items: NavItem[] }> = [
export function Layout() {
const { user, logout } = useAuth();
const { manifest } = useDemoManifest();
const { open: guideOpen } = useDemoGuide();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
@@ -220,7 +222,7 @@ export function Layout() {
{mobileOpen && <button className="nav-scrim" aria-label="Close navigation" onClick={() => setMobileOpen(false)} />}
<div className="app-workspace">
<div className={`app-workspace ${guideOpen ? "guide-open" : ""}`}>
<header className="topbar">
<button className="icon-button mobile-menu" type="button" onClick={() => setMobileOpen(true)} aria-label="Open navigation">
<Icon name="menu" />
+5
View File
@@ -327,6 +327,11 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.demo-guide-progress-pill { padding: 1px 6px; color: var(--teal-dark); background: white; border-radius: 999px; font-size: .6rem; }
.demo-guide-panel { position: fixed; z-index: 40; top: 0; right: 0; width: min(400px, 92vw); height: 100vh; display: flex; flex-direction: column; gap: 14px; padding: 20px; background: var(--surface); border-left: 1px solid var(--line); box-shadow: var(--shadow-float); overflow-y: auto; }
@media (min-width: 701px) {
/* The guide panel is a fixed right-side overlay at this width -- push page content
aside while it's open so it never sits underneath and blocks actionable buttons. */
.app-workspace.guide-open { padding-right: min(400px, 92vw); }
}
.demo-guide-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.demo-guide-kicker { margin: 0 0 4px; color: var(--teal-dark); font-size: .64rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
.demo-guide-header h2 { margin: 0; font-size: 1.05rem; letter-spacing: -.015em; }