diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 22490b5..8d58e27 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -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). diff --git a/frontend/e2e/demo-accessibility.spec.ts b/frontend/e2e/demo-accessibility.spec.ts new file mode 100644 index 0000000..78237f8 --- /dev/null +++ b/frontend/e2e/demo-accessibility.spec.ts @@ -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([]); +}); diff --git a/frontend/e2e/guided-demo-full.spec.ts b/frontend/e2e/guided-demo-full.spec.ts new file mode 100644 index 0000000..72e296d --- /dev/null +++ b/frontend/e2e/guided-demo-full.spec.ts @@ -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); + }); +}); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 0a833ef..4f640b5 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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 = { @@ -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 &&