Publish curated MobilityOps source snapshot

This commit is contained in:
Jens committed 2026-09-29 22:03:33 +02:00
commit 7adc072f5e
317 files changed
+48851

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

@@ -0,0 +1,94 @@
import { expect, test } from "@playwright/test";
// One-off tooling to capture demo-productization evidence screenshots for
// artifacts/demo-release/final-summary.md. Not part of the regular test suite
// (prefixed with `_` and excluded from the configured test run via testIgnore).
const OUT = "../artifacts/demo-release/screenshots";
test("capture demo-release evidence screenshots", async ({ page, request }) => {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post("/api/v1/demo/reset");
await page.goto("/login");
await expect(page.getByText(/Northstar Mobility/)).toBeVisible();
await page.screenshot({ path: `${OUT}/01-demo-entry-desktop.png`, fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({ path: `${OUT}/02-demo-entry-mobile.png` });
await page.setViewportSize({ width: 1280, height: 900 });
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByText("Probeer een demonstratiescenario")).toBeVisible();
await page.screenshot({ path: `${OUT}/03-dashboard-with-scenarios.png`, fullPage: true });
await page.getByRole("button", { name: /Demo-gids/ }).click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
await page.screenshot({ path: `${OUT}/04-demo-guide.png` });
await page.getByRole("button", { name: "Sluiten" }).click();
await page.goto("/bookings/BK-DEMO-RETURN");
await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
await page.getByRole("button", { name: "Review return" }).click();
await expect(page.getByText(/Expected fleet state/)).toBeVisible();
await page.screenshot({ path: `${OUT}/05-return-preview.png`, fullPage: true });
await page.getByRole("button", { name: "Confirm return" }).click();
await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible();
await page.screenshot({ path: `${OUT}/06-return-result.png`, fullPage: true });
await page.goto("/data-quality");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
await page.locator(".data-table tbody tr").first().locator("a").click();
await expect(page.getByText("What's wrong")).toBeVisible();
await page.screenshot({ path: `${OUT}/07-data-quality-resolution.png`, fullPage: true });
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.goto("/data-quality/DQ-DEMO-DUPLICATE");
await expect(page.getByRole("heading", { name: "Compare and merge" })).toBeVisible();
await page.screenshot({ path: `${OUT}/08-duplicate-customer-merge.png`, fullPage: true });
await page.goto("/knowledge");
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.screenshot({ path: `${OUT}/09-knowledge-assistant.png`, fullPage: true });
await page.goto("/automation");
await expect(page.locator(".integration-cards")).toBeVisible();
await page.screenshot({ path: `${OUT}/10-integration-status.png`, fullPage: true });
const failedRetry = page.locator(".data-table tbody tr", { hasText: "failed" }).first();
if (await failedRetry.count()) {
await page.screenshot({ path: `${OUT}/11-automation-retry-before.png`, fullPage: true });
await failedRetry.getByRole("button", { name: "Retry" }).click();
await page.waitForTimeout(500);
await page.screenshot({ path: `${OUT}/11-automation-retry-after.png`, fullPage: true });
}
await page.goto("/audit");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
await page.screenshot({ path: `${OUT}/12-audit-trail.png`, fullPage: true });
await page.locator(".data-table tbody tr").first().getByRole("button", { name: "View related events" }).click();
await page.screenshot({ path: `${OUT}/13-audit-related-events.png`, fullPage: true });
await page.goto("/about");
await expect(page.getByRole("heading", { name: "Wat MobilityOps wel en niet is" })).toBeVisible();
await page.screenshot({ path: `${OUT}/14-about-demo.png`, fullPage: true });
await page.goto("/dashboard");
await page.getByRole("button", { name: /Synthetische demo/ }).click();
await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeVisible();
await page.screenshot({ path: `${OUT}/15-demo-badge-popover.png` });
// Final reset, captured, to leave the environment fully restored.
await page.keyboard.press("Escape");
const resetButton = page.getByRole("button", { name: "Reset demo data" });
if (await resetButton.count()) {
await resetButton.click();
await expect(page.getByRole("button", { name: "Yes, reset" })).toBeVisible();
await page.screenshot({ path: `${OUT}/16-reset-confirm.png` });
await page.getByRole("button", { name: "Yes, reset" }).click();
await expect(page).toHaveURL(/\/login$/);
}
});
@@ -0,0 +1,40 @@
import { expect, test } from "@playwright/test";
const OUT = "../artifacts/evidence/screenshots";
test("capture current recruiter-facing evidence", async ({ page, request }) => {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post("/api/v1/demo/reset");
await page.goto("/login");
await expect(page.getByRole("heading", { name: "Elke overdracht. Eén helder overzicht." })).toBeVisible();
await page.waitForTimeout(500);
await page.screenshot({ path: `${OUT}/1-login.png`, fullPage: true });
await page.getByRole("button", { name: "Bekijk de highlights in 90 seconden" }).click();
await expect(page).toHaveURL(/\/highlights$/);
await page.waitForTimeout(500);
await page.screenshot({ path: `${OUT}/2-highlights.png`, fullPage: true });
await page.goto("/about");
await expect(page.getByRole("heading", { name: "Controle, betrouwbaarheid en uitlegbaarheid" }).first()).toBeVisible();
await page.waitForTimeout(500);
await page.screenshot({ path: `${OUT}/3-engineering-story.png`, fullPage: true });
await page.goto("/dashboard");
await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible();
await page.waitForTimeout(500);
await page.screenshot({ path: `${OUT}/4-dashboard.png`, fullPage: true });
await page.goto("/knowledge");
await page.getByRole("button", { name: "Wat moet ik doen wanneer een voertuig terugkomt met schade?" }).click();
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 });
await page.waitForTimeout(500);
await page.screenshot({ path: `${OUT}/5-knowledge-evidence.png`, fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/highlights");
await expect(page.locator(".highlight-card").first()).toBeVisible();
await page.waitForTimeout(500);
await page.screenshot({ path: `${OUT}/6-highlights-mobile.png` });
});
+53
View File
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test";
// One-off tooling to capture evidence screenshots for artifacts/evidence/final-summary.md.
// Not part of the regular test suite (prefixed with `_` and excluded from CI runs).
const OUT = "../artifacts/evidence/screenshots";
test("capture the seven main pages", async ({ page, request }) => {
await request.post("http://localhost:8128/api/v1/demo/login", {
data: { role: "operations_manager" },
});
await request.post("http://localhost:8128/api/v1/demo/reset");
await page.goto("/login");
await page.screenshot({ path: `${OUT}/1-login.png` });
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible();
await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true });
await page.goto("/vehicles");
await expect(page.locator(".data-table")).toBeVisible();
await page.screenshot({ path: `${OUT}/3-vehicles.png`, fullPage: true });
await page.goto("/bookings");
await expect(page.locator(".data-table")).toBeVisible();
await page.screenshot({ path: `${OUT}/4-bookings.png`, fullPage: true });
await page.goto("/data-quality");
await expect(page.locator(".data-table")).toBeVisible();
await page.screenshot({ path: `${OUT}/5-data-quality.png`, fullPage: true });
await page.goto("/knowledge");
await page
.getByPlaceholder(/What must I do when a vehicle returns with damage/)
.fill("What must I do when a vehicle returns with damage?");
await page.getByRole("button", { name: "Ask" }).click();
await expect(page.getByText("Grounded in cited procedures")).toBeVisible();
await page.screenshot({ path: `${OUT}/6-knowledge.png`, fullPage: true });
await page.goto("/automation");
await expect(page.locator(".data-table")).toBeVisible();
await page.screenshot({ path: `${OUT}/7-automation.png`, fullPage: true });
await page.goto("/audit");
await expect(page.locator(".data-table")).toBeVisible();
await page.screenshot({ path: `${OUT}/8-audit.png`, fullPage: true });
await page.setViewportSize({ width: 360, height: 800 });
await page.goto("/dashboard");
await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible();
await page.screenshot({ path: `${OUT}/9-mobile-dashboard.png` });
});
+52
View File
@@ -0,0 +1,52 @@
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { expect, test } from "@playwright/test";
test.use({ bypassCSP: true, reducedMotion: "reduce" });
const require = createRequire(import.meta.url);
const axeSource = readFileSync(require.resolve("axe-core/axe.min.js"), "utf8");
type AxeViolation = {
id: string;
impact: string | null;
help: string;
nodes: Array<{ target: string[] }>;
};
async function seriousViolations(page: import("@playwright/test").Page): Promise<AxeViolation[]> {
await page.addStyleTag({
content: "*,*::before,*::after{animation:none!important;transition:none!important}",
});
await page.addScriptTag({ content: axeSource });
return page.evaluate(async () => {
const axe = (window as Window & {
axe: {
run: (
context: Document,
options: object,
) => Promise<{ violations: AxeViolation[] }>;
};
}).axe;
const result = await axe.run(document, {
runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21aa"] },
});
return result.violations.filter(
(violation) => violation.impact === "critical" || violation.impact === "serious",
);
});
}
test("principal routes have no serious automated accessibility violations", async ({ page }) => {
await page.goto("/login");
expect(await seriousViolations(page)).toEqual([]);
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
for (const route of ["/dashboard", "/data-quality", "/knowledge", "/automation", "/audit"] as const) {
await page.goto(route);
const violations = await seriousViolations(page);
expect(violations, `${route}: ${JSON.stringify(violations)}`).toEqual([]);
}
});
+112
View File
@@ -0,0 +1,112 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
async function resetDemoData(request: APIRequestContext) {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post("/api/v1/demo/reset");
}
// Verifies the "stretched link" pattern used across the Attention Queue, Today's movements,
// Vehicles, Bookings and Data Quality tables: the whole row is one activation target, not
// just its title/reference text, while any secondary in-row link stays independently usable.
test.beforeEach(async ({ page, request }) => {
await resetDemoData(request);
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
await page.goto("/login");
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
});
test("attention queue row opens its record when clicking empty row space, not just the title", async ({ page }) => {
const row = page.locator(".attention-list li.row-clickable").first();
await expect(row).toBeVisible();
const box = await row.boundingBox();
expect(box).not.toBeNull();
// Click near the far right edge of the row -- empty space, not the title text or badge.
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
});
test("attention queue row is keyboard reachable and opens on Enter", async ({ page }) => {
const row = page.locator(".attention-list li.row-clickable").first();
const link = row.locator(".row-link");
await link.focus();
await expect(link).toBeFocused();
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
});
test("today's movements row opens the correct booking on click", async ({ page }) => {
const row = page.locator(".movement-timeline li.row-clickable").first();
await expect(row).toBeVisible();
const box = await row.boundingBox();
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await expect(page).toHaveURL(/\/bookings\/BK-/);
});
test("vehicles table row opens the vehicle detail from empty row space", async ({ page }) => {
await page.goto("/vehicles");
const row = page.locator(".data-table tbody tr.row-clickable").first();
await expect(row).toBeVisible();
const ref = (await row.locator("th").first().innerText()).split("\n")[0].trim();
const box = await row.boundingBox();
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await expect(page).toHaveURL(new RegExp(`/vehicles/${ref}$`));
});
test("bookings table row opens the booking, and the secondary vehicle link stays independently clickable", async ({ page }) => {
await page.goto("/bookings");
const row = page.locator(".data-table tbody tr.row-clickable").first();
await expect(row).toBeVisible();
const vehicleLink = row.locator(".cell-link");
const vehicleRef = (await vehicleLink.innerText()).trim();
// Clicking the secondary vehicle-ref link navigates to the vehicle, not the booking --
// it must not be swallowed by the row-spanning overlay link sitting behind it.
await vehicleLink.click();
await expect(page).toHaveURL(new RegExp(`/vehicles/${vehicleRef}$`));
await page.goto("/bookings");
const rowAgain = page.locator(".data-table tbody tr.row-clickable").first();
const bookingRef = (await rowAgain.locator("th").first().innerText()).split("\n")[0].trim();
const box = await rowAgain.boundingBox();
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await expect(page).toHaveURL(new RegExp(`/bookings/${bookingRef}$`));
});
test("data quality table row opens the issue detail from empty row space", async ({ page }) => {
await page.goto("/data-quality");
const row = page.locator(".data-table tbody tr.row-clickable").first();
await expect(row).toBeVisible();
const ref = (await row.locator("th").first().innerText()).split("\n")[0].trim();
const box = await row.boundingBox();
await page.mouse.click(box!.x + box!.width - 10, box!.y + box!.height / 2);
await expect(page).toHaveURL(new RegExp(`/data-quality/${ref}$`));
});
test("attention queue row opens the correct record on a mobile viewport tap", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/dashboard");
const row = page.locator(".attention-list li.row-clickable").first();
await expect(row).toBeVisible();
const link = row.locator(".row-link");
const [rowBox, linkBox] = await Promise.all([row.boundingBox(), link.boundingBox()]);
expect(rowBox).not.toBeNull();
expect(linkBox).not.toBeNull();
// The stretched link itself must cover the whole mobile row. Locator.click handles
// scrolling and fixed mobile chrome before clicking the link's empty right-hand area.
expect(Math.abs(linkBox!.width - rowBox!.width)).toBeLessThanOrEqual(1);
expect(Math.abs(linkBox!.height - rowBox!.height)).toBeLessThanOrEqual(1);
// A real touch context needs `hasTouch`, which this shared spec file doesn't opt into;
// a mouse click at the same mobile viewport size still exercises the same CSS layout
// and click-target logic, since the app has no touch-specific event handling.
await link.click({ position: { x: linkBox!.width - 10, y: linkBox!.height / 2 } });
await expect(page).toHaveURL(/\/(data-quality|vehicles)\//);
});
test("data quality table row has a pointer cursor and a visible focus ring covering the whole row", async ({ page }) => {
await page.goto("/data-quality");
const row = page.locator(".data-table tbody tr.row-clickable").first();
await expect(row).toHaveCSS("cursor", "pointer");
await row.locator(".row-link").focus();
await expect(row.locator(".row-link")).toBeFocused();
});
+250
View File
@@ -0,0 +1,250 @@
import { expect, test, type Page } from "@playwright/test";
import type { AutomationRun } from "../src/api/types";
const apiErrorBody = JSON.stringify({
error: { code: "TEST_UNAVAILABLE", message: "Injected test failure", correlation_id: "test-correlation" },
});
async function loginAsManager(page: Page) {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}
async function failFirstManifestRequest(page: Page) {
let requestCount = 0;
await page.route("**/api/v1/demo/manifest", async (route) => {
requestCount += 1;
if (requestCount === 1) {
await route.fulfill({ status: 503, contentType: "application/json", body: apiErrorBody });
return;
}
await route.continue();
});
}
test("session validation does not wait for a stalled system-status request", async ({ page }) => {
let releaseStatus = () => undefined;
const statusGate = new Promise<void>((resolve) => {
releaseStatus = resolve;
});
let sessionRequested = false;
await page.route("**/api/v1/system/status", async (route) => {
await statusGate;
await route.fulfill({ status: 503, contentType: "application/json", body: apiErrorBody });
});
await page.route("**/api/v1/auth/session", async (route) => {
sessionRequested = true;
await route.fulfill({ status: 401, contentType: "application/json", body: apiErrorBody });
});
try {
await page.goto("/login");
await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeEnabled({
timeout: 2_000,
});
expect(sessionRequested).toBe(true);
} finally {
releaseStatus();
}
});
test("guided demo stays gated while its manifest loads, then exposes an explicit retry", async ({ page }) => {
let releaseManifest = () => undefined;
const manifestGate = new Promise<void>((resolve) => {
releaseManifest = resolve;
});
let markManifestRequested = () => undefined;
const manifestRequested = new Promise<void>((resolve) => {
markManifestRequested = resolve;
});
let manifestAttempts = 0;
await page.route("**/api/v1/demo/manifest", async (route) => {
manifestAttempts += 1;
if (manifestAttempts === 1) {
markManifestRequested();
await manifestGate;
await route.fulfill({ status: 503, contentType: "application/json", body: apiErrorBody });
return;
}
await route.continue();
});
try {
await page.goto("/login");
await manifestRequested;
const guidedDemo = page.getByRole("button", { name: "Start begeleide demo" });
await expect(guidedDemo).toBeDisabled();
await expect(page.getByText("Begeleide demo voorbereiden…")).toBeVisible();
releaseManifest();
await expect(page.getByText("De begeleide demo-informatie kon niet geladen worden.")).toBeVisible();
await expect(guidedDemo).toBeDisabled();
await page.getByRole("button", { name: "Demo-informatie opnieuw laden" }).click();
await expect(guidedDemo).toBeEnabled();
expect(manifestAttempts).toBeGreaterThanOrEqual(2);
} finally {
releaseManifest();
}
});
test("guided demo does not continue until the manifest reports it ready", async ({ page }) => {
let returnNotReady = true;
await page.route("**/api/v1/demo/manifest", async (route) => {
if (!returnNotReady) {
await route.continue();
return;
}
const response = await route.fetch();
const manifest = await response.json() as Record<string, unknown>;
await route.fulfill({ response, json: { ...manifest, guide_available: false } });
});
await page.goto("/login");
const guidedDemo = page.getByRole("button", { name: "Start begeleide demo" });
await expect(guidedDemo).toBeDisabled();
await expect(page.getByText("De begeleide demo is momenteel niet klaar om te starten.")).toBeVisible();
await guidedDemo.evaluate((button) => {
button.removeAttribute("disabled");
(button as HTMLButtonElement).click();
});
await expect(page).toHaveURL(/\/login$/);
returnNotReady = false;
await page.getByRole("button", { name: "Demo-informatie opnieuw laden" }).click();
await expect(guidedDemo).toBeEnabled();
});
test("blocked sessionStorage cannot break login or logout", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperties(Storage.prototype, {
setItem: {
configurable: true,
value: () => {
throw new DOMException("Storage is blocked", "SecurityError");
},
},
removeItem: {
configurable: true,
value: () => {
throw new DOMException("Storage is blocked", "SecurityError");
},
},
});
});
await loginAsManager(page);
await expect(page.getByText("Amelie De Ridder").first()).toBeVisible();
await page.locator(".operator-menu > summary").click();
await page.getByRole("button", { name: "Wissel van rol" }).click();
await expect(page).toHaveURL(/\/login$/);
});
test("scenario manifest failure is visible and retryable", async ({ page }) => {
await failFirstManifestRequest(page);
await loginAsManager(page);
await page.locator('a[href="/scenarios"]').first().click();
await expect(page).toHaveURL(/\/scenarios$/);
await expect(page.getByText("De demonstratiescenario's konden niet geladen worden.")).toBeVisible();
await page.getByRole("button", { name: "Opnieuw proberen" }).click();
await expect(page.locator(".scenario-card")).toHaveCount(5);
});
test("engineering manifest failure is visible and retryable", async ({ page }) => {
await failFirstManifestRequest(page);
await loginAsManager(page);
await page.locator('a[href="/about"]').first().click();
await expect(page).toHaveURL(/\/about$/);
await expect(page.getByText("De demo-informatie kon niet geladen worden.")).toBeVisible();
await page.getByRole("button", { name: "Opnieuw proberen" }).click();
await expect(page.getByRole("heading", { name: "Controle, betrouwbaarheid en uitlegbaarheid" })).toBeVisible();
});
test("the latest automation filter wins when an older response completes last", async ({ page }) => {
test.setTimeout(45_000);
let releaseOldResponse = () => undefined;
const oldResponseGate = new Promise<void>((resolve) => {
releaseOldResponse = resolve;
});
let markOldRequestStarted = () => undefined;
const oldRequestStarted = new Promise<void>((resolve) => {
markOldRequestStarted = resolve;
});
let markOldResponseAttempted = () => undefined;
const oldResponseAttempted = new Promise<void>((resolve) => {
markOldResponseAttempted = resolve;
});
const run = (status: "failed" | "pending", aggregateRef: string): AutomationRun => ({
event_id: status === "failed"
? "11111111-1111-4111-8111-111111111111"
: "22222222-2222-4222-8222-222222222222",
event_type: "vehicle.returned.v1",
aggregate_ref: aggregateRef,
status,
attempts: status === "failed" ? 2 : 0,
last_error: null,
last_error_code: null,
is_demo_scenario: false,
occurred_at: "2026-08-23T10:00:00Z",
});
await page.route(/\/api\/v1\/workflows(?:\?.*)?$/, async (route) => {
const requestedStatus = new URL(route.request().url()).searchParams.get("status");
if (requestedStatus === "failed") {
markOldRequestStarted();
await oldResponseGate;
try {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([run("failed", "BK-RACE-OLD")]),
});
} catch {
// Chromium cancels the superseded fetch. The delayed route can therefore be
// closed before Playwright gets to fulfil it; either outcome proves the same
// contract as long as it cannot overwrite the newer response.
} finally {
markOldResponseAttempted();
}
return;
}
if (requestedStatus === "pending") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([run("pending", "BK-RACE-NEW")]),
});
return;
}
await route.continue();
});
try {
await loginAsManager(page);
await page.goto("/automation");
await expect(page.getByRole("heading", { name: "Automatiseringsopdrachten" })).toBeVisible();
const statusFilter = page.getByRole("combobox", { name: "Status", exact: true });
await statusFilter.selectOption("failed");
await oldRequestStarted;
await statusFilter.selectOption("pending");
await expect(page.getByText("BK-RACE-NEW", { exact: true })).toBeVisible();
releaseOldResponse();
await oldResponseAttempted;
await expect(page.getByText("BK-RACE-NEW", { exact: true })).toBeVisible();
await expect(page.getByText("BK-RACE-OLD", { exact: true })).toHaveCount(0);
} finally {
releaseOldResponse();
}
});
+144
View File
@@ -0,0 +1,144 @@
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: "Retour nakijken" });
await expect(reviewButton).toBeVisible();
await reviewButton.click({ timeout: 5000 });
await expect(page.getByText(/Verwachte wagenparkstatus/)).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 Operationsmanager" }).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 Operationsmanager" }).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 Fleet Ops 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([]);
});
test("mobile quality workbench keeps filters compact and bulk actions reachable", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/data-quality?status=open");
const filterButton = page.getByRole("button", { name: /Filters/ });
await expect(filterButton).toBeVisible();
await expect(page.locator("#quality-filters")).toBeHidden();
await filterButton.click();
await expect(page.locator("#quality-filters")).toBeVisible();
await page.getByRole("button", { name: "Zichtbare problemen selecteren" }).click();
await expect(page.getByRole("region", { name: "Geselecteerde werkvoorraad bijwerken" })).toBeVisible();
const checked = await page.locator(".quality-table tbody input[type=checkbox]:checked").count();
expect(checked).toBeGreaterThan(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
await page.evaluate(() => document.documentElement.clientWidth + 1),
);
});
test("status-recommendation panel is fully keyboard operable, respects reduced motion, and never signals status by colour alone", async ({
page,
request,
}) => {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post("/api/v1/demo/reset");
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/data-quality/DQ-DEMO-STATUS");
const reviewButton = page.getByRole("button", { name: "Aanbeveling bekijken" });
await reviewButton.focus();
await expect(reviewButton).toBeFocused();
await page.keyboard.press("Enter");
const confirmButton = page.getByRole("button", { name: /^Status wijzigen naar/ });
await expect(confirmButton).toBeVisible();
// Status is never conveyed by colour alone: the badge always carries its own text.
const badge = page.locator(".status-decision .badge").first();
await expect(badge).not.toHaveText("");
// The confirm action itself is a real, focusable, keyboard-activatable button (the
// previous "Aanbeveling bekijken" button is unmounted once the decision panel
// replaces it, so focus is verified directly rather than via a Tab chain from it).
await confirmButton.focus();
await expect(confirmButton).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
});
+61
View File
@@ -0,0 +1,61 @@
import { expect, test } from "@playwright/test";
test.describe.configure({ mode: "serial" });
test("demo entry screen names the fictional org and never shows a password", async ({ page }) => {
await page.goto("/login");
await expect(page.getByText(/Northstar Mobility/)).toBeVisible();
await expect(page.getByText(/Synthetische demo · geen echte klant-/)).toBeVisible();
await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible();
await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible();
await expect(page.getByRole("button", { name: "Verken als Verhuurmedewerker" })).toBeVisible();
await expect(page.locator('input[type="password"]')).toHaveCount(0);
});
test("start guided demo logs in as Operations Manager and opens the guide at step 1", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
// The ?guide=start marker is a one-shot signal the dashboard strips immediately after
// consuming it, so assert on its effect (the guide panel opens at step 1) rather than
// the transient URL, which can already be gone by the time this assertion runs.
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByText("Amelie De Ridder")).toBeVisible();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
});
test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
await expect(trigger).toBeVisible();
await trigger.click();
await expect(page.getByRole("dialog", { name: "Over deze demo-omgeving" })).toBeVisible();
await expect(page.getByText(/Laatste reset:/)).toBeVisible();
await page.getByRole("link", { name: /Over deze demo/ }).click();
await expect(page).toHaveURL(/\/about$/);
await expect(page.getByRole("heading", { name: "Wat Fleet Ops wel en niet is" })).toBeVisible();
await expect(page.getByText("Northstar Mobility").first()).toBeVisible();
await expect(page.getByText("Demomodus").first()).toBeVisible();
await expect(page.getByText("Niet gekoppeld").first()).toBeVisible();
});
test("badge popover closes on Escape and outside click", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
await trigger.click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("dialog")).toBeHidden();
await trigger.click();
await expect(page.getByRole("dialog")).toBeVisible();
await page.mouse.click(10, 10);
await expect(page.getByRole("dialog")).toBeHidden();
});
+260
View File
@@ -0,0 +1,260 @@
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("scenario overview lists all 5 scenarios, ready right after a reset", async ({ page, request }) => {
await resetDemoData(request);
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/scenarios");
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
const cards = page.locator(".scenario-card");
await expect(cards).toHaveCount(5);
for (const label of ["Klaar voor demo"]) {
await expect(page.getByText(label).first()).toBeVisible();
}
await expect(page.getByText("Niet beschikbaar")).toHaveCount(0);
});
test("starting a scenario navigates to its fixed record", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/scenarios");
const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" });
await duplicateCard.getByRole("link", { name: "Scenario starten" }).click();
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
});
// The default Playwright viewport (1280x720) falls in the "standard desktop/tablet" tier
// (see useViewportTier.ts): the guide is a floating, non-modal panel that auto-collapses
// to a persistent progress chip the moment the visitor acts on "Ga naar deze stap".
test("demo guide: navigating steps, jumping to a step collapses to a chip, and the chip reopens it", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
await expect(page.getByRole("heading", { name: "1. Begrijp de operationele status" })).toBeVisible();
await page.getByRole("button", { name: "Volgende" }).click();
await expect(page.getByRole("heading", { name: "2. Open een boeking die aandacht nodig heeft" })).toBeVisible();
await page.getByRole("button", { name: /6\. Stel een vraag/ }).click();
await expect(page.getByRole("heading", { name: "6. Stel een vraag aan de procedureassistent" })).toBeVisible();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
await expect(page).toHaveURL(/\/knowledge$/);
// Panel auto-collapses to a persistent chip -- it must never sit over the knowledge
// page's primary "Ask" action after navigation.
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
const chip = page.getByRole("button", { name: /Demo-gids · stap 6 van 8/ });
await expect(chip).toBeVisible();
await chip.click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
await expect(page.getByRole("heading", { name: "6. Stel een vraag aan de procedureassistent" })).toBeVisible();
});
test("the collapsed chip has its own close control, independent of reopening it", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
const chip = page.getByRole("button", { name: /Demo-gids · stap/ });
await expect(chip).toBeVisible();
await page.getByRole("button", { name: "Sluiten" }).click();
await expect(chip).toBeHidden();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
await expect(page.locator(".demo-guide-trigger")).toBeFocused();
});
test("demo guide progress persists across navigation and the trigger shows it", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await page.getByRole("button", { name: "Volgende" }).click();
await page.getByRole("button", { name: "Volgende" }).click();
await page.getByRole("button", { name: "Demo-gids inklappen" }).click();
await expect(page.getByRole("button", { name: /^Demo-gids/ }).first()).toContainText("2/8");
await page.goto("/vehicles");
await page.getByRole("button", { name: /^Demo-gids/ }).first().click();
await expect(page.getByRole("heading", { name: "3. Verwerk een retour" })).toBeVisible();
});
test("demo guide is not shown to a rental employee", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Verhuurmedewerker" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0);
});
test("restarting the demo from the guide resets data and returns to login", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
await page.getByRole("button", { name: "Demo opnieuw voorbereiden" }).click();
await expect(page).toHaveURL(/\/login$/, { timeout: 10000 });
});
test("sidebar reset preserves guide progress on failure and clears it only after success", async ({ page }) => {
let resetShouldFail = true;
await page.route("**/api/v1/demo/reset", async (route) => {
if (resetShouldFail) {
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({
error: { code: "TEST_UNAVAILABLE", message: "Injected reset failure", correlation_id: "reset-test" },
}),
});
return;
}
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ status: "reset" }) });
});
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await page.getByRole("button", { name: "Volgende" }).click();
await page.locator(".demo-guide-trigger").click();
await expect(page.locator(".demo-guide-trigger")).toContainText("1/8");
await page.getByRole("button", { name: "Demogegevens herstellen" }).click();
await page.getByRole("button", { name: "Ja, herstellen" }).click();
await expect(page.getByText("Demogegevens konden niet hersteld worden.")).toBeVisible();
await expect(page.locator(".demo-guide-trigger")).toContainText("1/8");
resetShouldFail = false;
await page.getByRole("button", { name: "Demogegevens herstellen" }).click();
await page.getByRole("button", { name: "Ja, herstellen" }).click();
await expect(page).toHaveURL(/\/login$/);
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page.locator(".demo-guide-trigger")).toContainText("0/8");
});
test("wide desktop viewport docks the guide as a rail that never collapses to a chip", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1000 });
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
const panel = page.locator(".demo-guide-panel.is-wide");
await expect(panel).toBeVisible();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
await expect(panel).toBeVisible();
await expect(page.locator(".demo-guide-chip")).toHaveCount(0);
});
test("mobile viewport shows a bottom sheet with collapsed/half/full states and no horizontal overflow", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
const panel = page.locator(".demo-guide-panel.is-mobile");
await expect(panel).toBeVisible();
await expect(panel).toHaveClass(/sheet-half/);
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
await page.locator(".demo-guide-sheet-handle").click();
await expect(panel).toHaveClass(/sheet-full/);
await page.locator(".demo-guide-sheet-handle").click();
await expect(panel).toHaveClass(/sheet-collapsed/);
});
test("Escape collapses the standard-tier panel, then closes it", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: /Demo-gids · stap/ })).toBeVisible();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
await page.keyboard.press("Escape");
await expect(page.getByRole("button", { name: /Demo-gids · stap/ })).toBeHidden();
await expect(page.locator(".demo-guide-trigger")).toBeFocused();
});
test("closing the guide cancels a delayed target lookup and restores trigger focus", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1000 });
let releaseKnowledgeChunk = () => undefined;
const knowledgeChunkGate = new Promise<void>((resolve) => {
releaseKnowledgeChunk = resolve;
});
let markKnowledgeChunkRequested = () => undefined;
const knowledgeChunkRequested = new Promise<void>((resolve) => {
markKnowledgeChunkRequested = resolve;
});
await page.route(
/\/(?:assets\/Knowledge-[^/?]+\.js|src\/pages\/Knowledge\.tsx)(?:\?.*)?$/,
async (route) => {
markKnowledgeChunkRequested();
await knowledgeChunkGate;
await route.continue();
},
);
try {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await page.getByRole("button", { name: /6\. Stel een vraag/ }).click();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
await knowledgeChunkRequested;
await page
.locator(".demo-guide-panel.is-wide")
.getByRole("button", { name: "Sluiten" })
.click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
await expect(page.locator(".demo-guide-trigger")).toBeFocused();
releaseKnowledgeChunk();
const target = page.locator("#ask-heading");
await expect(target).toBeVisible();
await page.waitForTimeout(150);
await expect(target).not.toBeFocused();
await expect(target).not.toHaveClass(/demo-guide-highlight/);
} finally {
releaseKnowledgeChunk();
}
});
test("going to a step scrolls, focuses and highlights the on-page target", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await page.getByRole("button", { name: /6\. Stel een vraag/ }).click();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
await expect(page).toHaveURL(/\/knowledge$/);
const target = page.locator("#ask-heading");
await expect(target).toBeFocused();
await expect(target).toHaveClass(/demo-guide-highlight/);
});
test("going to a guide target on the already-active route still focuses and highlights it", async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1000 });
await page.goto("/login");
await page.getByRole("button", { name: "Start begeleide demo" }).click();
await page.getByRole("button", { name: /8\. Bekijk wat echt is/ }).click();
await page.goto("/about");
await page.getByRole("button", { name: /^Demo-gids/ }).first().click();
await page.getByRole("button", { name: "Ga naar deze stap" }).click();
const target = page.locator(".engineering-hero");
await expect(target).toBeFocused();
await expect(target).toHaveClass(/demo-guide-highlight/);
});
+84
View File
@@ -0,0 +1,84 @@
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("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => {
await resetDemoData(request);
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/bookings/BK-DEMO-RETURN");
await expect(page.getByText("Demonstratiescenario: afwijkende kilometerstand")).toBeVisible();
const odometerInput = page.getByLabel("Eindkilometerstand (km)");
await expect(odometerInput).not.toHaveValue("");
const prefilled = Number(await odometerInput.inputValue());
expect(prefilled).toBeGreaterThan(0);
await page.getByRole("button", { name: "Retour nakijken" }).click();
await expect(page.getByText(/laatst bevestigde stand/)).toBeVisible();
await page.getByRole("button", { name: "Retour bevestigen" }).click();
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible();
await expect(page.getByRole("link", { name: "Volledige verwerking volgen" })).toBeVisible();
});
test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
await expect(page.getByText("Wat is er mis")).toBeVisible();
await expect(page.getByText("Waarom dit belangrijk is")).toBeVisible();
await expect(page.getByText(/waarschijnlijk dezelfde persoon/)).toBeVisible();
});
test("data quality list can filter to demo scenarios only", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/data-quality");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const allRows = await page.locator(".data-table tbody tr").count();
// The URL-driven filter update can replace the controlled checkbox immediately after
// its click. Assert against the newly rendered control instead of asking `check()` to
// verify the detached pre-navigation node.
await page.getByRole("checkbox", { name: "Enkel demoscenario's" }).click();
await expect(page).toHaveURL(/demo=true/);
await expect(page.getByRole("checkbox", { name: "Enkel demoscenario's" })).toBeChecked();
const filteredRows = await page.locator(".data-table tbody tr").count();
expect(filteredRows).toBeGreaterThan(0);
expect(filteredRows).toBeLessThanOrEqual(allRows);
const refs = await page.locator(".data-table tbody tr th a").allTextContents();
for (const ref of refs) {
expect(ref.startsWith("DQ-DEMO-")).toBeTruthy();
}
});
test("knowledge page suggested question returns a grounded, honestly-labelled answer", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/knowledge");
// The status badge must name the actual active provider once its check settles.
await expect(page.locator(".knowledge-status strong")).toHaveText(/RAGcore|de demokennisbank/);
await page.getByRole("button", { name: "Wie beoordeelt een ongewone kilometerstand?" }).click();
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 });
const sources = page.locator(".exchange").first().locator(".source-card");
expect(await sources.count()).toBeLessThanOrEqual(3);
const titles = await sources.locator(".source-title").allTextContents();
expect(new Set(titles.map((title) => title.trim().toLocaleLowerCase())).size).toBe(titles.length);
await page.getByRole("button", { name: "Ja" }).first().click();
await expect(page.getByText("Feedback opgeslagen")).toBeVisible();
});
+159
View File
@@ -0,0 +1,159 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
import type {
AuditEvent,
AutomationRun,
DataQualityIssueDetail,
RegisterReturnResult,
} from "../src/api/types";
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("five-minute demo script end to end", async ({ page, request }) => {
const proof: { returned?: RegisterReturnResult } = {};
// Reset via a throwaway API session so the UI test starts from the deterministic seed
// regardless of what earlier test runs mutated (S1 return, S2 merge, etc.).
await resetDemoData(request);
await test.step("1. login as Operations Manager", async () => {
await page.goto("/login");
await expect(page.getByText(/Synthetische demo · geen echte klant-/)).toBeVisible();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
});
await test.step("2. verify dashboard metrics are loaded", async () => {
await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible();
const metricValues = page.locator(".metric-cell dd");
await expect(metricValues.first()).toBeVisible();
const values = await metricValues.allTextContents();
expect(values.length).toBeGreaterThan(0);
expect(values.some((v) => Number(v) > 0)).toBeTruthy();
});
await test.step("3. open active demo booking", async () => {
await page.goto("/bookings/BK-DEMO-RETURN");
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
await expect(page.getByText("actief", { exact: true })).toBeVisible();
});
await test.step("4. register an odometer-regression return (S1)", async () => {
const vehicleOdometerText = await page
.locator(".detail-grid div", { hasText: "Startkilometerstand" })
.locator("dd")
.textContent();
const startOdometer = parseInt((vehicleOdometerText ?? "0").replace(/\D/g, ""), 10);
const lowReading = Math.max(0, startOdometer - 500);
await page.getByLabel("Eindkilometerstand (km)").fill(String(lowReading));
await page.getByLabel("Brandstofniveau (%)").fill("55");
await page.getByRole("button", { name: "Retour nakijken" }).click();
await expect(page.getByRole("heading", { name: "Retourimpact nakijken" })).toBeVisible();
const returnResponsePromise = page.waitForResponse(
(response) =>
response.request().method() === "POST" &&
response.url().endsWith("/api/v1/bookings/BK-DEMO-RETURN/return"),
);
await page.getByRole("button", { name: "Retour bevestigen" }).click();
const returnResponse = await returnResponsePromise;
expect(returnResponse.status()).toBe(201);
proof.returned = (await returnResponse.json()) as RegisterReturnResult;
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
});
await test.step("5. verify the persisted quality issue, outbox event and audit trace", async () => {
const returned = proof.returned;
if (!returned) throw new Error("The return API response was not captured");
expect(returned.odometer_regression).toBe(true);
expect(returned.quality_issue_ref).toMatch(/^DQ-RET-/);
const qualityIssueRef = returned.quality_issue_ref;
if (!qualityIssueRef) throw new Error("The regression return did not create a quality issue");
await expect(page.getByRole("link", { name: qualityIssueRef })).toBeVisible();
await expect(page.getByText(/Klaargezet voor verwerking \(/)).toBeVisible();
// Use the page-bound request context: the throwaway reset session above is
// deliberately invalidated by reset, while this context shares the manager login
// cookie established in step 1.
const issueResponse = await page.request.get(`/api/v1/data-quality/issues/${qualityIssueRef}`);
expect(issueResponse.ok()).toBeTruthy();
const issue = (await issueResponse.json()) as DataQualityIssueDetail;
expect(issue.public_ref).toBe(qualityIssueRef);
expect(issue.rule_type).toBe("odometer_regression");
expect(issue.status).toBe("open");
expect(issue.entity_ref).toBe(returned.vehicle_ref);
const workflowsResponse = await page.request.get("/api/v1/workflows");
expect(workflowsResponse.ok()).toBeTruthy();
const workflows = (await workflowsResponse.json()) as AutomationRun[];
const workflow = workflows.find((run) => run.event_id === returned.workflow_event_id);
expect(workflow).toBeDefined();
expect(workflow).toMatchObject({
event_type: "vehicle.returned.v1",
aggregate_ref: returned.booking_ref,
});
const auditResponse = await page.request.get(
`/api/v1/audit?correlation_id=${encodeURIComponent(returned.correlation_id)}`,
);
expect(auditResponse.ok()).toBeTruthy();
const trace = (await auditResponse.json()) as AuditEvent[];
expect(trace.map((event) => event.action)).toEqual(
expect.arrayContaining([
"data_quality_issue_created",
"return_registered",
"vehicle_status_changed",
]),
);
expect(trace.every((event) => event.correlation_id === returned.correlation_id)).toBe(true);
});
await test.step("6. resolve the duplicate customer scenario (S2)", async () => {
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
await expect(page.getByRole("heading", { name: "Vergelijken en samenvoegen" })).toBeVisible();
await page.getByRole("button", { name: /Samenvoegen met CUS-0012/ }).click();
await page.getByRole("button", { name: "Ja, samenvoegen" }).click();
await expect(page.locator(".badge.status-resolved")).toBeVisible();
});
await test.step("7. ask the damage question and inspect citations (S6)", async () => {
await page.goto("/knowledge");
await page
.getByPlaceholder(/Wat moet ik doen wanneer een voertuig beschadigd terugkomt/)
.fill("Wat moet ik doen wanneer een voertuig terugkomt met schade?");
await page.getByRole("button", { name: "Vraag stellen" }).click();
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 });
const damageSource = page.locator(".source-card", { hasText: /zichtbare of gemelde schade/i }).first();
await expect(damageSource).toBeVisible();
await expect(damageSource).toContainText(/zichtbare of gemelde schade/i);
});
await test.step("8. inspect audit entries", async () => {
await page.goto("/audit");
await page.getByLabel("Actie").fill("return_registered");
await expect(page.locator(".audit-group-list li").first()).toBeVisible();
await expect(page.getByText("Voertuigretour geregistreerd").first()).toBeVisible();
});
await test.step("9. verify responsive navigation at mobile width", async () => {
await page.setViewportSize({ width: 360, height: 800 });
await page.goto("/dashboard");
await expect(page.getByRole("button", { name: /Synthetische demo/ })).toBeVisible();
await expect(page.getByRole("link", { name: "Overzicht" }).first()).toBeVisible();
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth + 1);
});
});
+205
View File
@@ -0,0 +1,205 @@
import { expect, test } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { ApiError } from "../src/api/apiError";
import { describeApiError, KNOWN_CODES } from "../src/api/errorMessages";
// Pure Node-context checks for the central API-error-localization function (section 7 /
// 11 of the Fleet Ops final localization brief). No browser needed: describeApiError()
// only depends on a `t` function and a caught error, so it's tested here against the
// real locale JSON with a minimal i18next-shaped `t` stub -- proving the known-code and
// known-HTTP-status paths never leak raw backend English as the primary message, and
// that the raw text is always still available via `.technical` for "Technical details".
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LOCALES_DIR = path.resolve(__dirname, "../src/i18n/locales");
const LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const;
function loadNamespace(language: string, namespace: string): Record<string, unknown> {
const filePath = path.join(LOCALES_DIR, language, `${namespace}.json`);
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
// Mirrors the (namespace, options.defaultValue) contract react-i18next's `t` exposes,
// resolving "namespace:dotted.path" against the real locale files for the given language.
function makeT(language: string): (key: string, options?: Record<string, unknown>) => string {
return (key: string, options?: Record<string, unknown>) => {
const [ns, ...rest] = key.includes(":") ? key.split(":") : ["errors", key];
const dottedPath = key.includes(":") ? rest.join(":") : rest.join("");
const data = loadNamespace(language, ns);
const value = dottedPath.split(".").reduce<unknown>((acc, part) => {
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
return undefined;
}, data);
if (typeof value === "string") return value;
if (options && "defaultValue" in options) return String(options.defaultValue);
return key;
};
}
const KNOWN_HTTP_STATUSES = ["401", "403", "404", "409", "422", "500"];
test("every known AppError code has a non-empty title+explanation in all 3 locales", () => {
for (const language of LANGUAGES) {
const codes = loadNamespace(language, "errors").codes as Record<string, { title?: string; explanation?: string }>;
for (const code of KNOWN_CODES) {
expect(codes[code], `${language}/errors.json is missing codes.${code}`).toBeTruthy();
expect(codes[code]?.title?.trim().length ?? 0, `${language}/errors.json:codes.${code}.title is empty`).toBeGreaterThan(0);
expect(
codes[code]?.explanation?.trim().length ?? 0,
`${language}/errors.json:codes.${code}.explanation is empty`,
).toBeGreaterThan(0);
}
}
});
test("every known HTTP status fallback has a non-empty title+explanation in all 3 locales", () => {
for (const language of LANGUAGES) {
const http = loadNamespace(language, "errors").http as Record<string, { title?: string; explanation?: string }>;
for (const status of KNOWN_HTTP_STATUSES) {
expect(http[status], `${language}/errors.json is missing http.${status}`).toBeTruthy();
expect(http[status]?.title?.trim().length ?? 0, `${language}/errors.json:http.${status}.title is empty`).toBeGreaterThan(0);
expect(
http[status]?.explanation?.trim().length ?? 0,
`${language}/errors.json:http.${status}.explanation is empty`,
).toBeGreaterThan(0);
}
}
});
test("a known AppError code resolves to its localized codes.* entry, never the raw backend message", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const raw = "IntegrityError: duplicate key value violates unique constraint";
const err = new ApiError(409, "VEHICLE_NOT_FOUND", raw, "corr-1");
const info = describeApiError(t, err);
const expected = loadNamespace(language, "errors").codes as Record<string, { title: string; explanation: string; nextStep?: string }>;
expect(info.title).toBe(expected.VEHICLE_NOT_FOUND.title);
expect(info.explanation).toBe(expected.VEHICLE_NOT_FOUND.explanation);
expect(info.title).not.toBe(raw);
expect(info.explanation).not.toBe(raw);
// The raw backend text must still be reachable, just demoted to `.technical`.
expect(info.technical).toBe(raw);
}
});
test("a code with nextStep populates it; a code without nextStep leaves it undefined", () => {
const t = makeT("nl-BE");
const withNextStep = describeApiError(t, new ApiError(422, "EMPTY_VALUE", "raw", "c1"));
expect(withNextStep.nextStep).toBeTruthy();
const withoutNextStep = describeApiError(t, new ApiError(404, "CUSTOMER_NOT_FOUND", "raw", "c2"));
expect(withoutNextStep.nextStep).toBeUndefined();
});
test("an unrecognized AppError code falls back to the matching known HTTP status, not raw text", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const raw = "Some brand-new backend code nobody localized yet";
const err = new ApiError(404, "SOME_FUTURE_CODE_NOT_YET_LOCALIZED", raw, "corr-2");
const info = describeApiError(t, err);
const expected404 = (loadNamespace(language, "errors").http as Record<string, { title: string; explanation: string }>)["404"];
expect(info.title).toBe(expected404.title);
expect(info.explanation).toBe(expected404.explanation);
expect(info.title).not.toBe(raw);
expect(info.technical).toBe(raw);
}
});
test("an unrecognized code and an unrecognized HTTP status fall back to the fully generic message", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const raw = "418 I'm a teapot (never mapped)";
const err = new ApiError(418, "418", raw, "corr-3");
const info = describeApiError(t, err);
const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string };
expect(info.title).toBe(generic.title);
expect(info.explanation).toBe(generic.explanation);
expect(info.technical).toBe(raw);
}
});
test("a stringified HTTP status used as the AppError code (plain HTTPException path) resolves via the http map", () => {
// Mirrors app/main.py's plain-HTTPException handler, which sets code = str(status_code)
// (e.g. "401") rather than a semantic AppError code -- see backend/app/main.py.
const t = makeT("fr-BE");
const err = new ApiError(401, "401", "Not authenticated", "corr-4");
const info = describeApiError(t, err);
const expected401 = (loadNamespace("fr-BE", "errors").http as Record<string, { title: string }>)["401"];
expect(info.title).toBe(expected401.title);
expect(info.title).not.toBe("Not authenticated");
});
test("a non-ApiError (e.g. network failure before any response) uses the fallback key, never a raw JS error message as the primary text", () => {
const t = makeT("nl-BE");
const networkFailure = new TypeError("Failed to fetch");
// Real call sites (e.g. Automation.tsx) invoke t() with their own default namespace
// already scoped via useTranslation("integrations"); this stub's default namespace is
// "errors", so the fallback key is qualified explicitly here to match.
const info = describeApiError(t, networkFailure, "integrations:ledger.retryFailed");
const expectedFallback = loadNamespace("nl-BE", "integrations").ledger as Record<string, string>;
expect(info.explanation).toBe(expectedFallback.retryFailed);
expect(info.explanation).not.toBe("Failed to fetch");
expect(info.technical).toBe("Failed to fetch");
});
// --- Backend/frontend AppError code drift guard ---
// KNOWN_CODES is a hand-maintained mirror of every `raise AppError("CODE", ...)` in the
// backend (see app/core/errors.py::AppError and every raise site). If the backend adds a
// new code and nobody updates KNOWN_CODES, it silently falls back to the generic-but-
// still-localized HTTP/generic message rather than raw English -- not a broken build, but
// a missed opportunity for a more specific message. This test surfaces that drift instead
// of letting it go unnoticed indefinitely.
const BACKEND_APP_DIR = path.resolve(__dirname, "../../backend/app");
function collectPyFiles(dir: string): string[] {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return collectPyFiles(full);
return entry.name.endsWith(".py") ? [full] : [];
});
}
function collectBackendAppErrorCodes(): Set<string> {
const codes = new Set<string>();
for (const file of collectPyFiles(BACKEND_APP_DIR)) {
const source = fs.readFileSync(file, "utf-8");
const pattern = /AppError\(\s*"([A-Z_]+)"/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
codes.add(match[1]);
}
}
return codes;
}
test("frontend KNOWN_CODES exactly matches every AppError code actually raised by the backend", () => {
const backendCodes = collectBackendAppErrorCodes();
const frontendCodes = KNOWN_CODES;
const missingFromFrontend = [...backendCodes].filter((c) => !frontendCodes.has(c)).sort();
const staleInFrontend = [...frontendCodes].filter((c) => !backendCodes.has(c)).sort();
expect(
missingFromFrontend,
`Backend raises AppError code(s) with no localized entry in errorMessages.ts KNOWN_CODES ` +
`(they'll fall back to a generic/HTTP-status message): ${missingFromFrontend.join(", ")}`,
).toEqual([]);
expect(
staleInFrontend,
`errorMessages.ts KNOWN_CODES lists code(s) the backend never raises -- likely renamed or ` +
`removed on the backend side: ${staleInFrontend.join(", ")}`,
).toEqual([]);
});
test("a non-ApiError with no fallbackKey uses the fully generic explanation", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const info = describeApiError(t, new TypeError("Failed to fetch"));
const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string };
expect(info.title).toBe(generic.title);
expect(info.explanation).toBe(generic.explanation);
}
});
+399
View File
@@ -0,0 +1,399 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
// Targeted end-to-end coverage for the Fleet Ops correction brief (docs/fleet-ops-
// correction/): branding, the redesigned status-recommendation flow (preview/apply/
// manual-review/stale-token), MO-016 order independence, trilingual knowledge
// grounding, and localized audit/automation content. See also i18n-coverage.spec.ts
// (key parity, brand invariant, translation-quality) and responsive-i18n.spec.ts
// (breakpoint matrix) for the complementary static-content checks.
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();
// /api/v1/demo/reset deletes the session cookie (it recreates the users table), so any
// further authenticated call through this same request context needs a fresh login.
const relogin = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
expect(relogin.ok()).toBeTruthy();
}
const EXPLORE_OPS_MANAGER: Record<string, string> = {
"nl-BE": "Verken als Operationsmanager",
"en-GB": "Explore as Operations Manager",
"fr-BE": "Explorer en tant que Responsable des opérations",
};
const REVIEW_RECOMMENDATION: Record<string, string> = {
"nl-BE": "Aanbeveling bekijken",
"en-GB": "Review recommendation",
"fr-BE": "Voir la recommandation",
};
const CHANGE_STATUS_PREFIX: Record<string, RegExp> = {
"nl-BE": /^Status wijzigen naar/,
"en-GB": /^Change status to/,
"fr-BE": /^Changer le statut vers/,
};
const MANUAL_REVIEW_HEADING: Record<string, string> = {
"nl-BE": "Handmatige beoordeling vereist",
"en-GB": "Manual review required",
"fr-BE": "Évaluation manuelle requise",
};
async function loginAsOpsManager(page: Page, lang: string) {
await page.addInitScript((l) => localStorage.setItem("fleetops.language", l), lang);
await page.goto("/login");
await page.getByRole("button", { name: EXPLORE_OPS_MANAGER[lang] }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}
test.describe.configure({ mode: "serial" });
test.describe("branding", () => {
for (const lang of ["nl-BE", "en-GB", "fr-BE"]) {
test(`Fleet Ops is the visible brand and no MobilityOps/PoC leaks through (${lang})`, async ({
page,
request,
}) => {
await resetDemoData(request);
await loginAsOpsManager(page, lang);
await expect(page.locator(".brand-mark").first()).toBeVisible();
await expect(page.getByText("Fleet Ops", { exact: true }).first()).toBeVisible();
await expect(page.locator(".app-footer")).toContainText("Fleet Ops");
await expect(page.locator("html")).toHaveAttribute("lang", lang);
for (const path of ["/dashboard", "/vehicles", "/data-quality", "/audit", "/automation", "/knowledge"]) {
await page.goto(path);
const text = await page.locator("body").innerText();
expect(text, `${path} (${lang})`).not.toContain("MobilityOps");
expect(text, `${path} (${lang})`).not.toMatch(/\bPoC\b/);
}
});
}
});
test("the Fleet Ops favicon is linked and resolves (not the browser's blank-tab default)", async ({ page, request }) => {
await resetDemoData(request);
await page.goto("/login");
const href = await page.locator('link[rel="icon"]').getAttribute("href");
expect(href).toBe("/favicon.svg");
const response = await page.request.get(href as string);
expect(response.ok()).toBeTruthy();
expect(response.headers()["content-type"]).toContain("svg");
});
test("language switcher control changes the UI and persists across a reload", async ({ page, request }) => {
await resetDemoData(request);
// Deliberately not using loginAsOpsManager here: its addInitScript would re-force
// nl-BE on every reload, defeating exactly the persistence behaviour under test.
await page.goto("/login");
await page.getByRole("button", { name: EXPLORE_OPS_MANAGER["nl-BE"] }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole("heading", { name: "Aandachtspunten" })).toBeVisible();
await page.getByRole("combobox", { name: "Taal" }).selectOption("fr-BE");
await expect(page.getByRole("heading", { name: "File d'attention" })).toBeVisible();
await expect(page.locator("html")).toHaveAttribute("lang", "fr-BE");
await page.reload();
await expect(page.getByRole("heading", { name: "File d'attention" })).toBeVisible();
await expect(page.locator("html")).toHaveAttribute("lang", "fr-BE");
});
test.describe("status-recommendation flow", () => {
test("preview does not mutate anything, apply names the exact target status", async ({ page, request }) => {
await resetDemoData(request);
await loginAsOpsManager(page, "nl-BE");
await page.goto("/data-quality/DQ-DEMO-STATUS");
await expect(page.getByText("DQ-DEMO-STATUS", { exact: true })).toBeVisible();
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
await expect(page.getByText("Aanbevolen status")).toBeVisible();
await expect(page.getByRole("heading", { name: "Waarom" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Gevolg" })).toBeVisible();
// Previewing must not have resolved the issue -- still open, using the page's own
// authenticated session (page.request shares cookies with the browser context).
const issue = await page.request.get("/api/v1/data-quality/issues/DQ-DEMO-STATUS");
expect((await issue.json()).status).toBe("open");
const confirmButton = page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] });
await expect(confirmButton).toHaveText(/Geblokkeerd/);
await confirmButton.click();
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
const resolved = await page.request.get("/api/v1/data-quality/issues/DQ-DEMO-STATUS");
expect((await resolved.json()).status).toBe("resolved");
});
test("manual review state offers no generic apply button for a genuine fact contradiction", async ({
page,
request,
}) => {
await resetDemoData(request);
const issues = await (
await request.get("/api/v1/data-quality/issues", {
params: { rule_type: "vehicle_status_conflict", status: "open" },
})
).json();
const conflicted = issues.find((i: { entity_ref: string }) => i.entity_ref === "MO-024");
expect(conflicted, "expected MO-024's vehicle_status_conflict issue to exist after reset").toBeTruthy();
await loginAsOpsManager(page, "en-GB");
await page.goto(`/data-quality/${conflicted.public_ref}`);
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] }).click();
await expect(page.getByRole("heading", { name: MANUAL_REVIEW_HEADING["en-GB"] })).toBeVisible();
await expect(page.getByRole("button", { name: /^Change status to/ })).toHaveCount(0);
});
test("a stale recommendation is rejected and the user must review again before applying", async ({
page,
request,
}) => {
await resetDemoData(request);
await loginAsOpsManager(page, "en-GB");
await page.goto("/data-quality/DQ-DEMO-STATUS");
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] }).click();
await expect(page.getByRole("button", { name: /^Change status to/ })).toBeVisible();
// Simulate the underlying facts changing after the preview was shown (the same
// session resolves the booking overlap in the meantime) -- the previously-fetched
// recommendation token must no longer be accepted.
await page.evaluate(async () => {
await fetch("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ booking_ref: "BK-DEMO-OVERLAP-B" }),
});
});
await page.getByRole("button", { name: /^Change status to/ }).click();
await expect(page.getByText(/situation has changed/i)).toBeVisible();
await expect(page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] })).toBeVisible();
});
});
test.describe("MO-016 status conflict is order-independent", () => {
// Order independence does NOT mean "the same final vehicle status regardless of
// order" -- resolving the booking overlap first genuinely removes the conflict, so
// there is correctly nothing left to apply afterwards. What must hold in either
// order: the recommendation always reflects the real, current facts (never a stale
// "was some other issue open" proxy), and nothing unsafe is ever applied (never
// "rented").
test("resolving the booking overlap first correctly leaves nothing to apply", async ({ page, request }) => {
await resetDemoData(request);
await loginAsOpsManager(page, "nl-BE");
await page.goto("/data-quality/DQ-DEMO-OVERLAP");
await page.getByRole("radio", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).check();
await page.getByRole("button", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).click();
await expect(page.getByText("Opgelost").first()).toBeVisible();
await page.goto("/data-quality/DQ-DEMO-STATUS");
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
await expect(page.getByRole("heading", { name: "Geen wijziging nodig" })).toBeVisible();
await expect(page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] })).toHaveCount(0);
const vehicle = await request.get("/api/v1/vehicles/MO-016");
expect((await vehicle.json()).operational_status).toBe("available");
});
test("resolving the status conflict first safely blocks the vehicle, unaffected by the later overlap fix", async ({
page,
request,
}) => {
await resetDemoData(request);
await loginAsOpsManager(page, "nl-BE");
await page.goto("/data-quality/DQ-DEMO-STATUS");
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
await page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] }).click();
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
const vehicleMid = await request.get("/api/v1/vehicles/MO-016");
const statusAfterApply = (await vehicleMid.json()).operational_status;
expect(statusAfterApply).not.toBe("rented");
await page.goto("/data-quality/DQ-DEMO-OVERLAP");
await page.getByRole("radio", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).check();
await page.getByRole("button", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).click();
await expect(page.getByText("Opgelost").first()).toBeVisible();
// Resolving the now-redundant overlap afterwards must not itself change the
// vehicle's status as a side effect.
const vehicleFinal = await request.get("/api/v1/vehicles/MO-016");
const statusFinal = (await vehicleFinal.json()).operational_status;
expect(statusFinal).toBe(statusAfterApply);
expect(statusFinal).not.toBe("rented");
await resetDemoData(request);
});
});
test.describe("knowledge base is grounded in the operator's own language", () => {
const cases: { lang: string; question: string; sourceHint: RegExp; sourceTitle: RegExp }[] = [
{
lang: "nl-BE",
question: "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
sourceHint: /zichtbare of gemelde schade/i,
sourceTitle: /Procedure schadeafhandeling|Schade bij voertuigretour/i,
},
{
lang: "en-GB",
question: "What should I do when a vehicle returns with damage?",
sourceHint: /visible or reported damage/i,
sourceTitle: /Damage handling procedure|Vehicle return damage/i,
},
{
lang: "fr-BE",
question: "Que dois-je faire lorsqu'un véhicule revient endommagé ?",
sourceHint: /dommages visibles ou signalés/i,
sourceTitle: /Procédure de gestion des dommages|Dommages au retour du véhicule/i,
},
];
for (const { lang, question, sourceHint, sourceTitle } of cases) {
test(`grounded ${lang} answer cites a ${lang} source about damage`, async ({ page, request }) => {
await resetDemoData(request);
await loginAsOpsManager(page, lang);
await page.goto("/knowledge");
await page.locator("#knowledge-question").fill(question);
await page.getByRole("button", { name: /^(Vraag stellen|Ask|Demander)$/ }).click();
const localizedSource = page.locator(".source-card", { hasText: sourceHint }).first();
await expect(localizedSource).toBeVisible({ timeout: 15_000 });
await expect(localizedSource).toContainText(sourceTitle);
});
}
});
test("audit trail shows localized action and field labels with raw codes only in technical details", async ({
page,
request,
}) => {
await resetDemoData(request);
await loginAsOpsManager(page, "nl-BE");
await page.goto("/data-quality/DQ-DEMO-STATUS");
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
await page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] }).click();
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
await page.goto("/audit");
// Applying resolves both the vehicle status and the issue in one correlated action --
// expand the group's "technical events" toggle so the other audit row's diff renders
// too, regardless of which one the grouping picked as primary.
const toggle = page.getByRole("button", { name: /technische gebeurtenis/ }).first();
await expect(toggle).toBeVisible();
await toggle.click();
await expect(page.getByRole("button", { name: "Technische gebeurtenissen verbergen" })).toBeVisible();
await expect(page.getByText("Aanbevolen status toegepast").first()).toBeVisible();
const diffs = page.locator(".change-diff");
await expect(diffs.first()).toBeVisible();
const combinedDiffText = (await diffs.allInnerTexts()).join(" ");
expect(combinedDiffText).toContain("Operationele status");
expect(combinedDiffText).not.toContain("operational_status");
});
test("automation shows a localized error explanation with the raw error only under technical details", async ({
page,
request,
}) => {
await resetDemoData(request);
await loginAsOpsManager(page, "nl-BE");
await page.goto("/automation");
const localizedSummary = /Gesimuleerde time-out richting de workflowdienst/;
await expect(page.getByText(localizedSummary).first()).toBeVisible();
await expect(page.getByText("Synthetic connection timeout to n8n")).not.toBeVisible();
// Scope to the failed job's own row -- the workflow-evidence table above it also has
// "Technische details" toggles (one per workflow), so an unscoped .first() can open
// the wrong one.
const failedJobRow = page.locator("tr", { has: page.getByText(localizedSummary) });
await failedJobRow.getByText("Technische details").click();
await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible();
});
test.describe("route matrix (section 11F)", () => {
// Opens every main route in all 3 languages: no console errors, correct html[lang],
// and a real, non-empty page heading (proving the route actually rendered content
// instead of silently falling back to a raw i18next key or a blank screen). Key
// parity across locale files is already proven structurally by i18n-coverage.spec.ts
// (every key that exists in nl-BE also exists, non-empty, in en-GB/fr-BE), so this
// matrix focuses on what only a live render can catch.
const routes = [
"/dashboard",
"/vehicles",
"/vehicles/MO-001",
"/bookings",
"/bookings/BK-DEMO-RETURN",
"/data-quality",
"/data-quality/DQ-DEMO-STATUS",
"/automation",
"/knowledge",
"/audit",
"/scenarios",
"/about",
];
for (const lang of ["nl-BE", "en-GB", "fr-BE"]) {
test(`every main route renders correctly with no console errors (${lang})`, async ({ page, request }) => {
await resetDemoData(request);
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() !== "error") return;
if (msg.text().includes("401") && msg.text().includes("Unauthorized")) return;
errors.push(msg.text());
});
page.on("pageerror", (err) => errors.push(err.message));
await loginAsOpsManager(page, lang);
for (const route of routes) {
await page.goto(route);
await expect(page.locator("html")).toHaveAttribute("lang", lang);
const heading = page.getByRole("heading", { level: 1 });
await expect(heading, `${route} (${lang})`).toBeVisible();
const headingText = (await heading.first().textContent())?.trim() ?? "";
expect(headingText, `${route} (${lang}) heading text`).not.toBe("");
// A raw, unresolved i18next key looks like "namespace:some.key.path" -- real
// page headings never contain a colon followed by a dotted identifier.
expect(headingText, `${route} (${lang}) heading looks like a raw i18n key`).not.toMatch(
/^[a-zA-Z]+:[\w.]+$/,
);
}
expect(errors, `Console errors across the route matrix (${lang}):\n${errors.join("\n")}`).toEqual([]);
});
}
});
test.describe("data-quality evidence summary is localized, not raw English (section 6)", () => {
// The primary evidence line at the top of every issue's detail page must render the
// structured `evidence.signals` in the operator's language; the legacy English
// `evidence.summary` string is a technical fallback only, visible solely inside
// "Technical details". Live-caught: this line was unconditionally showing raw
// English ("vehicle marked available while reserved bookings conflict") in every
// language until fixed.
const cases: { lang: string; expectedText: RegExp }[] = [
{ lang: "nl-BE", expectedText: /overlappende reserveringen/i },
{ lang: "en-GB", expectedText: /overlapping bookings/i },
{ lang: "fr-BE", expectedText: /chevauchent|chevauchement/i },
];
for (const { lang, expectedText } of cases) {
test(`vehicle_status_conflict evidence is localized (${lang})`, async ({ page, request }) => {
await resetDemoData(request);
await loginAsOpsManager(page, lang);
await page.goto("/data-quality/DQ-DEMO-STATUS");
const summarySection = page.locator(".record-surface-evidence");
await expect(summarySection).toBeVisible();
await expect(summarySection).toContainText(expectedText);
await expect(summarySection).not.toContainText("vehicle marked available while reserved bookings conflict");
});
}
});
+107
View File
@@ -0,0 +1,107 @@
import { expect, type Page, test } from "@playwright/test";
// Browser-context evidence for the time-dependent Europe/Brussels dashboard greeting
// (section 9 / 11 / 16 of the Fleet Ops final localization brief): the real rendered app,
// in all 3 languages, at every required boundary instant, using Playwright's clock API to
// control the browser's Date without waiting on real wall-clock time. Also proves the
// greeting updates live (no reload) when the period rolls over while the app stays open.
// 2026-01-15 is CET (UTC+1): Brussels hour = UTC hour + 1.
function cet(hour: number, minute = 0, second = 0): Date {
return new Date(Date.UTC(2026, 0, 15, hour - 1, minute, second));
}
const BOUNDARY_CASES: Array<{ time: Date; label: string; period: string }> = [
{ time: cet(4, 59), label: "04:59", period: "night" },
{ time: cet(5, 0), label: "05:00", period: "morning" },
{ time: cet(11, 59), label: "11:59", period: "morning" },
{ time: cet(12, 0), label: "12:00", period: "afternoon" },
{ time: cet(17, 59), label: "17:59", period: "afternoon" },
{ time: cet(18, 0), label: "18:00", period: "evening" },
{ time: cet(22, 59), label: "22:59", period: "evening" },
{ time: cet(23, 0), label: "23:00", period: "night" },
];
const EXPECTED_TITLE: Record<string, Record<string, string>> = {
"nl-BE": {
morning: "Goedemorgen. Hier is de status van je wagenpark voor vandaag.",
afternoon: "Goedemiddag. Hier is het actuele overzicht van je wagenpark.",
evening: "Goedenavond. Hier is het overzicht van je wagenpark voor vanavond.",
night: "Welkom terug. Hier is het laatste overzicht van je wagenpark.",
},
"en-GB": {
morning: "Good morning. Here's today's fleet status.",
afternoon: "Good afternoon. Here's the current overview of your fleet.",
evening: "Good evening. Here's this evening's fleet overview.",
night: "Welcome back. Here's the latest overview of your fleet.",
},
"fr-BE": {
morning: "Bonjour. Voici l'état de votre flotte pour aujourd'hui.",
afternoon: "Bonjour. Voici l'aperçu actuel de votre flotte.",
evening: "Bonsoir. Voici l'aperçu de votre flotte pour ce soir.",
night: "Bon retour. Voici le dernier aperçu de votre flotte.",
},
};
async function loginAsOperationsManager(page: Page) {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}
async function switchLanguage(page: Page, language: "nl-BE" | "en-GB" | "fr-BE") {
// At the default desktop viewport, only the topbar's compact switcher is visible --
// the sidebar's full switcher is `display: none` until the <960px breakpoint.
await page.locator(".language-switcher-compact select").selectOption(language);
}
const dashboardHeading = (page: Page) => page.locator(".page-header h1");
for (const language of ["nl-BE", "en-GB", "fr-BE"] as const) {
test(`dashboard greeting matches every required boundary time in ${language}`, async ({ page }) => {
await page.clock.install({ time: BOUNDARY_CASES[0].time });
await loginAsOperationsManager(page);
if (language !== "nl-BE") {
await switchLanguage(page, language);
}
for (const { time, label, period } of BOUNDARY_CASES) {
await page.clock.setFixedTime(time);
await page.reload();
await expect(dashboardHeading(page), `${language} @ ${label} Brussels time (expected period: ${period})`).toHaveText(
EXPECTED_TITLE[language][period],
);
}
});
}
test("dashboard greeting updates live across a period rollover without a page reload", async ({ page }) => {
// Start at 11:59:31 Brussels -- 29s before the 12:00 boundary.
await page.clock.install({ time: cet(11, 59, 31) });
await loginAsOperationsManager(page);
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning);
// Advance 30s of fake time (crossing 12:00) so the hook's 30s poll interval fires and
// recomputes the period -- no page.reload() call anywhere in this test.
await page.clock.fastForward(30_000);
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].afternoon);
});
test("dashboard greeting updates immediately on language switch without changing the time period", async ({ page }) => {
await page.clock.install({ time: cet(9, 0) });
await loginAsOperationsManager(page);
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning);
await switchLanguage(page, "en-GB");
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["en-GB"].morning);
await switchLanguage(page, "fr-BE");
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["fr-BE"].morning);
});
test("dashboard never shows 'Goedenacht' as a greeting at any hour", async ({ page }) => {
await page.clock.install({ time: cet(2, 0) });
await loginAsOperationsManager(page);
await expect(dashboardHeading(page)).not.toContainText("Goedenacht");
await expect(dashboardHeading(page)).toContainText("Welkom terug");
});
+83
View File
@@ -0,0 +1,83 @@
import { expect, test } from "@playwright/test";
import { getBrusselsHour, getGreetingPeriod } from "../src/i18n/greeting";
import { brusselsLocalToIso, toBrusselsDate, tryBrusselsLocalToIso } from "../src/i18n/brusselsDateTime";
// Pure Node-context boundary tests for the central, clock-injectable greeting function
// (section 9 of the Fleet Ops final localization brief). Every case below constructs an
// explicit UTC instant that corresponds to a specific Europe/Brussels wall-clock time --
// this is what "clock injection" buys: no real time needs to pass, and DST is exercised
// by picking instants either side of the CET/CEST transition.
// 2026-01-15 is CET (UTC+1): 04:59 Brussels = 03:59 UTC.
function cet(hour: number, minute = 0): Date {
return new Date(Date.UTC(2026, 0, 15, hour - 1, minute));
}
// 2026-07-15 is CEST (UTC+2): 04:59 Brussels = 02:59 UTC.
function cest(hour: number, minute = 0): Date {
return new Date(Date.UTC(2026, 6, 15, hour - 2, minute));
}
test("period boundaries are correct in winter time (CET, UTC+1)", () => {
expect(getGreetingPeriod(cet(4, 59))).toBe("night");
expect(getGreetingPeriod(cet(5, 0))).toBe("morning");
expect(getGreetingPeriod(cet(11, 59))).toBe("morning");
expect(getGreetingPeriod(cet(12, 0))).toBe("afternoon");
expect(getGreetingPeriod(cet(17, 59))).toBe("afternoon");
expect(getGreetingPeriod(cet(18, 0))).toBe("evening");
expect(getGreetingPeriod(cet(22, 59))).toBe("evening");
expect(getGreetingPeriod(cet(23, 0))).toBe("night");
});
test("period boundaries are correct in summer time (CEST, UTC+2)", () => {
expect(getGreetingPeriod(cest(4, 59))).toBe("night");
expect(getGreetingPeriod(cest(5, 0))).toBe("morning");
expect(getGreetingPeriod(cest(11, 59))).toBe("morning");
expect(getGreetingPeriod(cest(12, 0))).toBe("afternoon");
expect(getGreetingPeriod(cest(17, 59))).toBe("afternoon");
expect(getGreetingPeriod(cest(18, 0))).toBe("evening");
expect(getGreetingPeriod(cest(22, 59))).toBe("evening");
expect(getGreetingPeriod(cest(23, 0))).toBe("night");
});
test("DST transition (2026-03-29, clocks spring forward 02:00 -> 03:00 CEST): the Brussels hour never regresses or skips a period incorrectly", () => {
// 00:30 UTC = 01:30 CET, still "night" (before the 05:00 boundary regardless).
const beforeTransition = new Date(Date.UTC(2026, 2, 29, 0, 30));
expect(getBrusselsHour(beforeTransition)).toBe(1);
expect(getGreetingPeriod(beforeTransition)).toBe("night");
// 09:00 UTC on transition day = 11:00 CEST (already sprung forward) -- still morning.
const afterTransition = new Date(Date.UTC(2026, 2, 29, 9, 0));
expect(getBrusselsHour(afterTransition)).toBe(11);
expect(getGreetingPeriod(afterTransition)).toBe("morning");
// Autumn transition, 2026-10-25: fall-back happens at 01:00 UTC (03:00 CEST -> 02:00
// CET), so 00:30 UTC is still CEST -> 02:30 Brussels.
const beforeFallBack = new Date(Date.UTC(2026, 9, 25, 0, 30));
expect(getBrusselsHour(beforeFallBack)).toBe(2);
expect(getGreetingPeriod(beforeFallBack)).toBe("night");
// 09:00 UTC on fall-back day = 10:00 CET (already fallen back) -- still morning.
const afterFallBack = new Date(Date.UTC(2026, 9, 25, 9, 0));
expect(getBrusselsHour(afterFallBack)).toBe(10);
expect(getGreetingPeriod(afterFallBack)).toBe("morning");
});
test("default argument uses the real current time when no clock is injected", () => {
const period = getGreetingPeriod();
expect(["morning", "afternoon", "evening", "night"]).toContain(period);
});
test("Brussels wall-clock conversion is DST-aware and rejects impossible local times", () => {
expect(brusselsLocalToIso("2026-01-15T12:00")).toBe("2026-01-15T11:00:00.000Z");
expect(brusselsLocalToIso("2026-07-15T12:00")).toBe("2026-07-15T10:00:00.000Z");
expect(tryBrusselsLocalToIso("2026-03-29T02:30")).toBeNull();
expect(tryBrusselsLocalToIso("2026-02-30T12:00")).toBeNull();
// The repeated autumn hour resolves deterministically to the post-transition CET instant.
expect(brusselsLocalToIso("2026-10-25T02:30")).toBe("2026-10-25T01:30:00.000Z");
});
test("Brussels calendar dates do not depend on the browser or runner timezone", () => {
expect(toBrusselsDate(new Date("2026-01-01T23:30:00.000Z"))).toBe("2026-01-02");
expect(toBrusselsDate(new Date("2026-07-01T22:30:00.000Z"))).toBe("2026-07-02");
});
+121
View File
@@ -0,0 +1,121 @@
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);
// Wide desktop viewport: the guide docks as a rail and never auto-collapses to a chip
// (see useViewportTier.ts), so this walkthrough can keep interacting with the panel
// directly across every step -- the standard-tier auto-collapse behaviour itself is
// covered separately in demo-guide.spec.ts.
await page.setViewportSize({ width: 1600, height: 1000 });
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: "Wagenparkstatus" })).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: "Retour nakijken" }).click();
await expect(page.getByText(/laatst bevestigde stand/)).toBeVisible();
await page.getByRole("button", { name: "Retour bevestigen" }).click();
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).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();
await page.getByLabel("Regeltype").selectOption("odometer_regression");
const odometerIssue = page.getByRole("row", {
name: /Afwijkende kilometerstand.*MO-024/,
});
await odometerIssue.locator("a").click();
await expect(page.getByText("Wat is er mis")).toBeVisible();
// SLA ordering deliberately outranks recency, so select the issue by its rule.
await page.getByRole("radio", { name: /Laatst bevestigde stand behouden/ }).check();
await page.getByRole("button", { name: "Probleem oplossen" }).click();
await expect(page.getByText(/Probleem .* opgelost/)).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: /^Samenvoegen met/ }).click();
await page.getByRole("button", { name: "Ja, samenvoegen" }).click();
await expect(page.getByText(/Probleem .* opgelost/)).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: "Wat moet ik doen wanneer een voertuig terugkomt met schade?" }).click();
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 });
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(".audit-group-list li").first()).toBeVisible();
await page.getByRole("button", { name: /Demo-gids/ }).click();
await page
.getByRole("dialog", { name: "Gegidste demo" })
.getByRole("button", { name: "Volgende", exact: true })
.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 Fleet Ops wel en niet is" })).toBeVisible();
await expect(page.getByText("Demomodus", { exact: false }).first()).toBeVisible();
await expect(page.getByText("Niet gekoppeld").first()).toBeVisible();
await page.getByRole("button", { name: "Demo afronden" }).click();
await expect(page.getByRole("dialog", { name: "Gegidste demo" })).toBeHidden();
const guideTrigger = page.locator(".demo-guide-trigger");
await expect(guideTrigger).toContainText("8/8");
await expect(guideTrigger).toBeFocused();
await page.reload();
await expect(page.getByRole("button", { name: /^Demo-gids/ }).first()).toContainText("8/8");
});
await test.step("restore the environment", async () => {
await resetDemoData(request);
});
});
+324
View File
@@ -0,0 +1,324 @@
import { expect, test } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
// Pure Node-context checks (no browser needed): every locale must define exactly the
// same set of translation keys. A missing key would otherwise silently fall back to
// showing the raw key string in production -- this test makes that impossible to ship.
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LOCALES_DIR = path.resolve(__dirname, "../src/i18n/locales");
const LANGUAGES = ["nl-BE", "en-GB", "fr-BE"];
function collectKeyPaths(value: unknown, prefix = ""): string[] {
if (value === null || typeof value !== "object") {
return [prefix];
}
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
collectKeyPaths(nested, prefix ? `${prefix}.${key}` : key),
);
}
function loadNamespace(language: string, namespace: string): Record<string, unknown> {
const filePath = path.join(LOCALES_DIR, language, `${namespace}.json`);
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
const namespaces = fs
.readdirSync(path.join(LOCALES_DIR, "nl-BE"))
.filter((f) => f.endsWith(".json"))
.map((f) => f.replace(/\.json$/, ""));
test("every locale defines the same translation keys as nl-BE, for every namespace", () => {
expect(namespaces.length).toBeGreaterThan(0);
for (const namespace of namespaces) {
const referenceKeys = collectKeyPaths(loadNamespace("nl-BE", namespace)).sort();
for (const language of LANGUAGES) {
if (language === "nl-BE") continue;
const keys = collectKeyPaths(loadNamespace(language, namespace)).sort();
const missing = referenceKeys.filter((k) => !keys.includes(k));
const extra = keys.filter((k) => !referenceKeys.includes(k));
expect(
missing,
`${language}/${namespace}.json is missing keys present in nl-BE: ${missing.join(", ")}`,
).toEqual([]);
expect(
extra,
`${language}/${namespace}.json has extra keys not present in nl-BE: ${extra.join(", ")}`,
).toEqual([]);
}
}
});
test("no locale file contains an empty string value", () => {
for (const language of LANGUAGES) {
for (const namespace of namespaces) {
const data = loadNamespace(language, namespace);
const keys = collectKeyPaths(data);
for (const keyPath of keys) {
const value = keyPath.split(".").reduce<unknown>((acc, part) => {
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
return undefined;
}, data);
if (typeof value === "string") {
expect(value.trim().length, `${language}/${namespace}.json:${keyPath} is empty`).toBeGreaterThan(0);
}
}
}
}
});
// --- Brand-invariant: "Fleet Ops" is a fixed constant, never a translation value ---
// (see frontend/src/product.ts and docs/fleet-ops-correction/current-gap-audit.md §1).
// A regression here means someone re-introduced a per-locale brand key/value instead of
// interpolating {{productName}} from the shared constant.
test("no locale file defines an 'appName' key or the literal brand string", () => {
for (const language of LANGUAGES) {
for (const namespace of namespaces) {
const data = loadNamespace(language, namespace);
const raw = JSON.stringify(data);
expect(
raw.includes("Fleet Ops"),
`${language}/${namespace}.json contains the literal brand string "Fleet Ops" -- ` +
`use {{productName}} interpolation instead so the brand can never drift per locale`,
).toBe(false);
const keys = collectKeyPaths(data);
expect(
keys.some((k) => k === "appName" || k.endsWith(".appName")),
`${language}/${namespace}.json defines an "appName" key -- the brand name must come ` +
`from the PRODUCT_NAME constant, never a translatable key`,
).toBe(false);
}
}
});
test("no locale file contains the internal project name 'MobilityOps' or the word 'PoC'", () => {
for (const language of LANGUAGES) {
for (const namespace of namespaces) {
const raw = JSON.stringify(loadNamespace(language, namespace));
expect(
raw.includes("MobilityOps"),
`${language}/${namespace}.json contains "MobilityOps" -- the visible product name is ` +
`always "Fleet Ops" (via {{productName}}); "MobilityOps" is a technical/repo-only identifier`,
).toBe(false);
expect(
/\bPoC\b/.test(raw),
`${language}/${namespace}.json contains "PoC" -- Fleet Ops is never described as a PoC ` +
`in user-facing copy`,
).toBe(false);
}
}
});
// --- Translation-quality: prove values were actually translated, not copy-pasted ---
// Sleutelpariteit alone doesn't prove translation happened (a locale file could contain
// the literal English string under the right key and still pass). For every "real prose"
// string (>=8 chars, not on the allowlist below), assert nl-BE and fr-BE differ from
// en-GB, and that fr-BE differs from nl-BE -- catching both "still English" and
// "Dutch text copy-pasted into French" in one pass.
// Exact (namespace, key-path) pairs that are legitimately identical across two or more
// locales: real proper nouns/brand names, deliberately-untranslated role titles, and
// genuine cross-language cognates (identical spelling in Dutch/French/English). This is
// a precise allowlist by key path, not a broad word-level allowlist, so it can't quietly
// hide an unrelated real mistranslation under the same key in a different namespace.
const IDENTICAL_VALUE_ALLOWLIST = new Set([
"audit.diff.was", // "{{field}}: was {{value}}" -- "was" is spelled identically in Dutch
"common.language.nl-BE", // language-picker options show each language's own endonym
"common.language.fr-BE",
"common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text
"common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3
"dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative
"demo.scenarios.durationValue", // "± {{minutes}} min" -- unit abbreviation, same in all 3
"demo.about.limitationsTitle", // "Limitations" -- identical spelling in French
"demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun
"fleet.list.columns.attention", // "Attention" -- identical spelling in French
"fleet.detail.tabs.inspections", // "Inspections" -- identical spelling in French
"integrations.cards.orchestrationKicker", // "Orchestration" -- identical in French
"knowledge.questionLabel", // "Question" -- identical spelling in French
"knowledge.retrievalFlow.question",
"knowledge.questionLabelExchange",
"returns.result.inspection", // "Inspection" -- identical spelling in French
"integrations.workflows.columns.name", // "Workflow" -- used as-is in Dutch and French
]);
function isTranslatableProse(value: unknown): value is string {
if (typeof value !== "string") return false;
if (value.trim().length < 8) return false;
// Strip interpolation placeholders and non-letter characters; if nothing substantial
// remains (pure numbers/punctuation/units), it's not "prose" that needs translating.
const stripped = value
.replace(/\{\{[^}]+\}\}/g, " ")
.replace(/[^a-zA-Zà-öø-ÿÀ-ÖØ-ß]/g, "");
return stripped.trim().length >= 3;
}
test("nl-BE and fr-BE translations are not suspiciously identical to en-GB or each other", () => {
for (const namespace of namespaces) {
const en = loadNamespace("en-GB", namespace);
const nl = loadNamespace("nl-BE", namespace);
const fr = loadNamespace("fr-BE", namespace);
const keys = collectKeyPaths(en);
for (const keyPath of keys) {
if (IDENTICAL_VALUE_ALLOWLIST.has(`${namespace}.${keyPath}`)) continue;
const at = (data: Record<string, unknown>) =>
keyPath.split(".").reduce<unknown>((acc, part) => {
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
return undefined;
}, data);
const enValue = at(en);
if (!isTranslatableProse(enValue)) continue;
const nlValue = at(nl);
const frValue = at(fr);
expect(
nlValue,
`${namespace}.json:${keyPath} — nl-BE is identical to en-GB ("${enValue}"); ` +
`looks untranslated (add to IDENTICAL_VALUE_ALLOWLIST if this is intentional)`,
).not.toBe(enValue);
expect(
frValue,
`${namespace}.json:${keyPath} — fr-BE is identical to en-GB ("${enValue}"); ` +
`looks untranslated (add to IDENTICAL_VALUE_ALLOWLIST if this is intentional)`,
).not.toBe(enValue);
expect(
frValue,
`${namespace}.json:${keyPath} — fr-BE is identical to nl-BE ("${nlValue}"); ` +
`looks like Dutch text was copy-pasted into the French locale`,
).not.toBe(nlValue);
}
}
});
// --- Embedded English/Dutch fragments inside otherwise-translated prose ---
// The whole-string identity check above only catches a value that is IDENTICAL to
// en-GB end-to-end. It cannot catch a real bug class found during the Fleet Ops final
// localization pass: a sentence gets 95% translated but a role/status noun phrase is
// left embedded mid-sentence, e.g. nl-BE "... moet door de Operations Manager worden
// goedgekeurd." This scan flags known English fragments appearing literally inside any
// nl-BE or fr-BE string value, and known Dutch fragments leaking into fr-BE (copy-paste
// mistakes). Deliberately limited to unambiguous multi-word phrases (not single common
// words like "Open" or "Field", which collide with genuine Dutch/French vocabulary).
const FORBIDDEN_ENGLISH_FRAGMENTS = [
"Operations Manager",
"Operations Managers",
"Rental Employee",
"Rental Employees",
"Audit trail",
"Start scenario",
];
const FORBIDDEN_DUTCH_FRAGMENTS_IN_FR = [
"Operationsmanager",
"Verhuurmedewerker",
"Auditgeschiedenis",
"Scenario starten",
];
function collectStringLeaves(value: unknown, prefix = ""): Array<{ path: string; value: string }> {
if (typeof value === "string") return [{ path: prefix, value }];
if (value === null || typeof value !== "object") return [];
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
collectStringLeaves(nested, prefix ? `${prefix}.${key}` : key),
);
}
test("no known English role/status fragments leak into nl-BE or fr-BE prose", () => {
const findings: string[] = [];
for (const namespace of namespaces) {
const nl = loadNamespace("nl-BE", namespace);
const fr = loadNamespace("fr-BE", namespace);
for (const { path: keyPath, value } of collectStringLeaves(nl)) {
for (const fragment of FORBIDDEN_ENGLISH_FRAGMENTS) {
if (value.includes(fragment)) {
findings.push(`nl-BE/${namespace}.json:${keyPath} contains English fragment "${fragment}": "${value}"`);
}
}
}
for (const { path: keyPath, value } of collectStringLeaves(fr)) {
for (const fragment of [...FORBIDDEN_ENGLISH_FRAGMENTS, ...FORBIDDEN_DUTCH_FRAGMENTS_IN_FR]) {
if (value.includes(fragment)) {
findings.push(`fr-BE/${namespace}.json:${keyPath} contains foreign-language fragment "${fragment}": "${value}"`);
}
}
}
}
expect(findings, findings.join("\n")).toEqual([]);
});
// --- Hardcoded JSX text (section 11D) ---
// A targeted, deliberately narrow static scan: JSX text nodes (`>literal text<`, not a
// `{...}` expression) containing two or more real words are almost always user-facing
// prose that should go through t(...). This is not a full parser, so a short, explicit
// allowlist covers technical tokens/proper nouns that are correctly never translated
// (MobilityOps.md is checked for absence elsewhere; this list is for things that ARE
// expected to appear literally in JSX).
const SRC_DIR = path.resolve(__dirname, "../src");
const SCAN_DIRS = ["pages", "components"];
const ALLOWED_LITERAL_TEXT = new Set([
"Fleet Ops", // the non-localizable brand name (frontend/src/product.ts)
"Northstar Mobility", // fictional demo org, a proper noun
"ITWorx MCP Hub", // proper noun
]);
function collectTsxFiles(dir: string): string[] {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return collectTsxFiles(full);
return entry.name.endsWith(".tsx") ? [full] : [];
});
}
function findHardcodedJsxText(filePath: string): string[] {
const source = fs.readFileSync(filePath, "utf-8");
const findings: string[] = [];
// Matches `<Tag ...>text</Tag>` where the closing tag name backreferences the
// opening one -- this specifically excludes TypeScript generics like
// `useState<string | null>(null)`, which have no matching `</string | null>` closer,
// unlike a naive `>...<` scan would. Deliberately spans newlines (Prettier commonly
// puts JSX text on its own line) and does not attempt to parse JSX properly -- it is
// a fast, approximate net for the common mistake, not a compiler.
const jsxTextPattern = /<([A-Za-z][\w.]*)(?:\s[^<>]*)?>([^<>{}]{3,200})<\/\1>/gs;
let match: RegExpExecArray | null;
while ((match = jsxTextPattern.exec(source)) !== null) {
const text = match[2].trim();
if (!text) continue;
if (ALLOWED_LITERAL_TEXT.has(text)) continue;
// Needs at least two alphabetic words to count as "prose" -- filters out numbers,
// single technical words, units (km, %), punctuation-only fragments, and JSX
// whitespace artifacts.
const words = text.match(/[A-Za-z]+/g) ?? [];
if (words.length < 2) continue;
// Skip anything that is itself an i18next interpolation artifact leaking through
// (shouldn't happen, but never flag `{{...}}`-shaped remnants) or looks like a URL
// or path.
if (/^https?:\/\//.test(text) || text.includes("/") || text.includes("{{")) continue;
findings.push(`${path.relative(SRC_DIR, filePath)}: "${text}"`);
}
return findings;
}
test("no hardcoded user-facing JSX text outside the approved technical-token allowlist", () => {
const allFindings: string[] = [];
for (const dir of SCAN_DIRS) {
const files = collectTsxFiles(path.join(SRC_DIR, dir));
for (const file of files) {
allFindings.push(...findHardcodedJsxText(file));
}
}
expect(
allFindings,
`Found ${allFindings.length} likely hardcoded JSX string(s) bypassing t(...). ` +
`Either route it through the translation system, or add the exact literal to ` +
`ALLOWED_LITERAL_TEXT in this test if it's a genuine proper noun/technical token:\n` +
allFindings.join("\n"),
).toEqual([]);
});
+468
View File
@@ -0,0 +1,468 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
async function resetDemoData(request: APIRequestContext) {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post("/api/v1/demo/reset");
}
async function switchRole(page: Page) {
await page.locator(".operator-menu > summary").click();
await page.getByRole("button", { name: "Switch role" }).click();
}
test.describe.configure({ mode: "serial" });
// This file's assertions were authored against the English UI copy; nl-BE is now the
// app's default for a fresh session, so force English explicitly rather than rewriting
// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs).
test.beforeEach(async ({ page, request }) => {
// Keep reset's Set-Cookie deletion in the standalone request fixture. Sharing the
// browser cookie jar here can race that deletion against the UI login below.
await resetDemoData(request);
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
await page.goto("/login");
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
});
test("all manager navigation items navigate correctly", async ({ page }) => {
const items: [string, RegExp][] = [
["Overview", /\/dashboard$/],
["Fleet", /\/vehicles$/],
["Bookings", /\/bookings$/],
["Data quality", /\/data-quality$/],
["Knowledge", /\/knowledge$/],
["Integrations", /\/automation$/],
["Audit trail", /\/audit$/],
["Users", /\/users$/],
["Privacy", /\/privacy$/],
];
const primaryNavigation = page.getByRole("navigation", { name: "Primary navigation" });
for (const [label, urlPattern] of items) {
await primaryNavigation.getByRole("link", { name: label, exact: true }).click();
await expect(page).toHaveURL(urlPattern);
}
});
test("vehicles page: status filter and attention-only checkbox both work", async ({ page }) => {
await page.goto("/vehicles");
await expect(page.locator(".data-table")).toBeVisible();
await page.getByLabel("Status").selectOption("maintenance");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const statuses = await page.locator(".data-table tbody tr td:nth-child(4)").allTextContents();
expect(statuses.every((s) => s.includes("maintenance"))).toBeTruthy();
await page.getByLabel("Status").selectOption("");
await expect(page).not.toHaveURL(/status=maintenance/);
await expect(page.getByLabel("Attention only")).not.toBeChecked();
// Updating the URL replaces this controlled input. Click it, then assert against the
// newly-rendered control rather than asking Playwright to verify the detached node.
await page.getByLabel("Attention only").click();
await expect(page).toHaveURL(/attention_only=true/);
await expect(page.getByLabel("Attention only")).toBeChecked();
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const rows = page.locator(".data-table tbody tr");
await expect(rows.locator(".attention-flag")).toHaveCount(await rows.count());
const attentionReasons = await rows.locator(".attention-flag").allTextContents();
expect(attentionReasons.every((reason) => reason.trim().length > 0)).toBeTruthy();
});
test("vehicles page: free-text search actually filters the rendered rows", async ({ page }) => {
await page.goto("/vehicles");
await expect(page.locator(".data-table")).toBeVisible();
const totalRows = await page.locator(".data-table tbody tr").count();
expect(totalRows).toBeGreaterThan(1);
const searchBox = page.getByRole("form", { name: "Vehicle fleet" }).getByLabel("Search");
await searchBox.fill("MO-001");
await expect(async () => {
const rows = await page.locator(".data-table tbody tr").count();
expect(rows).toBe(1);
}).toPass({ timeout: 5000 });
const refs = await page.locator(".data-table tbody tr th a").allTextContents();
expect(refs).toEqual(["MO-001"]);
await searchBox.fill("");
await expect(async () => {
const rows = await page.locator(".data-table tbody tr").count();
expect(rows).toBe(totalRows);
}).toPass({ timeout: 5000 });
});
test("vehicle detail: all tabs render distinct content", async ({ page }) => {
await page.goto("/vehicles/MO-016");
await expect(page.getByRole("heading", { name: /MO-016/ })).toBeVisible();
for (const tab of ["Overview", "Bookings", "Inspections", "Maintenance", "Quality"]) {
await page.getByRole("tab", { name: tab }).click();
await expect(page.getByRole("tab", { name: tab })).toHaveAttribute("aria-selected", "true");
}
});
test("bookings page: status filter works", async ({ page }) => {
await page.goto("/bookings");
await expect(page.locator(".data-table")).toBeVisible();
await page.getByLabel("Status").selectOption("returned");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const statuses = await page.locator(".data-table tbody tr td:nth-child(5)").allTextContents();
expect(statuses.every((s) => s.includes("returned"))).toBeTruthy();
});
test("bookings page: pagination renders at most 25 rows and page 2 differs from page 1", async ({
page,
}) => {
await page.goto("/bookings");
await expect(page.locator(".data-table")).toBeVisible();
const page1Count = await page.locator(".data-table tbody tr").count();
expect(page1Count).toBeLessThanOrEqual(25);
const page1Refs = await page.locator(".data-table tbody tr th a").allTextContents();
const nextButton = page.getByRole("button", { name: "Next" });
await expect(nextButton).toBeEnabled();
await nextButton.click();
await expect(async () => {
const page2Refs = await page.locator(".data-table tbody tr th a").allTextContents();
expect(page2Refs.length).toBeGreaterThan(0);
expect(page2Refs).not.toEqual(page1Refs);
}).toPass({ timeout: 5000 });
const page2Count = await page.locator(".data-table tbody tr").count();
expect(page2Count).toBeLessThanOrEqual(25);
const prevButton = page.getByRole("button", { name: "Previous" });
await expect(prevButton).toBeEnabled();
});
test("return preview correctly reports blocked (not maintenance) for damage reported", async ({
page,
request,
}) => {
await resetDemoData(request);
await page.goto("/bookings/BK-DEMO-RETURN");
await page.getByLabel("End odometer (km)").fill("55000");
await page.getByLabel("Fuel level (%)").fill("40");
await page.getByRole("checkbox", { name: "Damage reported" }).check();
await page.getByRole("button", { name: "Review return" }).click();
// The preview is the server's authoritative evaluation: damage always routes to
// "blocked", never "maintenance" -- this used to be guessed client-side and wrong.
// The localized reason is the primary text; the raw code sits behind "Technical
// details" so it isn't visible until expanded.
await expect(page.getByText("Damage was reported on this return.")).toBeVisible();
const statusRegion = page.locator(".impact-preview");
await expect(statusRegion.getByText("blocked", { exact: true })).toBeVisible();
await page.getByText("Technical details").click();
await expect(page.getByText("Damage was reported on return.")).toBeVisible();
});
test("data quality page: status and rule-type filters work", async ({ page }) => {
await page.goto("/data-quality");
await expect(page.locator(".data-table")).toBeVisible();
await page.getByRole("combobox", { name: "Rule type", exact: true }).selectOption(
"possible_duplicate_customer",
);
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
const rules = await page.locator(".data-table tbody tr td:nth-child(2)").allTextContents();
expect(rules.every((r) => r.includes("Possible duplicate customer"))).toBeTruthy();
await page.getByRole("combobox", { name: "Rule type", exact: true }).selectOption("");
await page.getByRole("combobox", { name: "Status", exact: true }).selectOption("resolved");
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
});
test("data quality issue detail: defer and reject buttons work", async ({ page, request }) => {
await resetDemoData(request);
await page.goto("/data-quality?status=open&rule_type=missing_required_field");
await expect(page.getByRole("combobox", { name: "Rule type", exact: true })).toHaveValue(
"missing_required_field",
);
const firstLink = page.locator(".data-table tbody tr").first().locator("a");
await expect(firstLink).toBeVisible();
const ref = (await firstLink.textContent())?.trim() ?? "";
expect(ref).not.toBe("");
await firstLink.click();
await expect(page).toHaveURL(new RegExp(`/data-quality/${ref}$`));
await expect(page.locator(".page-header .page-eyebrow")).toHaveText(ref);
await page.getByRole("button", { name: "Defer" }).click();
await expect(page.locator(".badge.status-deferred")).toBeVisible();
});
test("data quality: providing missing fields resolves a vehicle issue", async ({ page, request }) => {
await resetDemoData(request);
await page.goto("/data-quality/DQ-DEMO-ATTENTION");
await expect(page.locator(".page-header .page-eyebrow")).toHaveText("DQ-DEMO-ATTENTION");
await page.getByLabel("Registration number").fill("TST-777");
await page.getByLabel("Make").fill("TestMake");
await page.getByLabel("Model").fill("TestModel");
await page.getByLabel("Location").fill("Depot");
await page.getByRole("button", { name: "Save and re-check" }).click();
await expect(page.getByText("Resolved").first()).toBeVisible();
});
test("data quality: resolving a booking overlap blocks one booking", async ({ page, request }) => {
await resetDemoData(request);
await page.goto("/data-quality/DQ-DEMO-OVERLAP");
await expect(page.locator(".page-header .page-eyebrow")).toHaveText("DQ-DEMO-OVERLAP");
await page.getByRole("radio", { name: /Block BK-DEMO-OVERLAP-A/ }).check();
await page.getByRole("button", { name: /^Block BK-DEMO-OVERLAP-A$/ }).click();
await expect(page.getByText("Resolved").first()).toBeVisible();
await expect
.poll(async () => {
const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A");
return (await booking.json()).status;
})
.toBe("blocked");
});
test("data quality: applying the recommended status resolves a vehicle conflict", async ({
page,
request,
}) => {
await resetDemoData(request);
await page.goto("/data-quality/DQ-DEMO-STATUS");
await expect(page.getByText("DQ-DEMO-STATUS", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Review recommendation" }).click();
await page.getByRole("button", { name: /^Change status to/ }).click();
await expect(page.getByText("Applied", { exact: false })).toBeVisible();
});
test("data quality: retaining canonical resolves an odometer regression issue", async ({
page,
request,
}) => {
await resetDemoData(request);
const issues = await (
await page.request.get("/api/v1/data-quality/issues", {
params: { rule_type: "odometer_regression", status: "open" },
})
).json();
const target = issues[0];
await page.goto(`/data-quality/${target.public_ref}`);
await expect(page.getByText(target.public_ref, { exact: true })).toBeVisible();
await page.getByRole("radio", { name: /Retain canonical/ }).check();
await page.getByRole("button", { name: "Resolve issue" }).click();
await expect(page.getByText("Resolved").first()).toBeVisible();
});
test("data quality: manual scan runs and shows a result summary", async ({ page, request }) => {
await resetDemoData(request);
await page.goto("/data-quality");
await page.getByRole("button", { name: "Run quality scan" }).click();
await page.getByRole("button", { name: "Yes, run scan" }).click();
await expect(page.getByText(/Scan complete/)).toBeVisible();
});
test("automation page: status filter and retry button work", async ({ page, request }) => {
await resetDemoData(request);
await page.goto("/automation");
const jobsTable = page.getByRole("table", { name: "Automation jobs" });
await expect(jobsTable).toBeVisible();
await page.getByLabel("Status").selectOption("failed");
await expect(jobsTable.locator("tbody tr").first()).toBeVisible();
const failedRowCountBefore = await jobsTable.locator("tbody tr").count();
const retryButton = page.getByRole("button", { name: "Retry" }).first();
await expect(retryButton).toBeVisible();
await retryButton.click();
// Retrying a failed delivery moves it out of the "failed" status, so — while still
// filtered to "failed" — the row correctly disappears from this view rather than
// showing "pending" in place. Confirm the filtered list shrank by one.
await expect(async () => {
const count = await jobsTable.locator("tbody tr").count();
expect(count).toBe(failedRowCountBefore - 1);
}).toPass({ timeout: 5000 });
});
test("audit page: action filter works", async ({ page }) => {
await page.goto("/audit");
await expect(page.locator(".audit-group-list")).toBeVisible();
await page.getByLabel("Action").fill("demo_login");
await expect(page.locator(".audit-group").first()).toBeVisible();
const headings = await page.locator(".audit-group-heading strong").allTextContents();
expect(headings.every((a) => a === "Logged in")).toBeTruthy();
});
test("audit page: shows human-readable before/after and a safe entity link", async ({
page,
request,
}) => {
await resetDemoData(request);
// demo/reset deletes the acting session's own cookie, so submit the return through
// page.request instead -- it shares the browser context's still-valid OM session from
// beforeEach rather than the now-logged-out standalone `request` fixture.
const submitted = await page.request.post("/api/v1/bookings/BK-DEMO-RETURN/return", {
data: {
end_odometer_km: 60000,
fuel_level_percent: 55,
cleanliness_ok: true,
damage_reported: false,
technical_warning: false,
},
headers: { "Idempotency-Key": "e2e-audit-before-after-check" },
});
expect(submitted.ok()).toBeTruthy();
await page.goto("/audit");
await page.getByLabel("Action").fill("return_registered");
const firstGroup = page.locator(".audit-group").first();
await expect(firstGroup).toBeVisible();
const changeDiff = firstGroup.locator(".change-diff");
await expect(changeDiff).toContainText(/status/i);
await expect(changeDiff).toContainText("returned");
await expect(firstGroup.locator(".audit-group-meta a")).toHaveAttribute("href", /\/bookings\/BK-/);
});
test("knowledge page: form submits and clears input", async ({ page }) => {
await page.goto("/knowledge");
const input = page.getByPlaceholder(/What must I do when a vehicle returns with damage/);
await input.fill("What must I do when a vehicle returns with damage?");
await page.getByRole("button", { name: "Ask" }).click();
await expect(page.getByText("Grounded in cited procedures")).toBeVisible({ timeout: 15_000 });
await expect(input).toHaveValue("");
});
test("switch role button logs out and returns to login", async ({ page }) => {
await switchRole(page);
await expect(page).toHaveURL(/\/login$/);
});
test("session survives a page refresh and restores the correct role", async ({ page }) => {
await page.goto("/vehicles");
await page.reload();
await expect(page).toHaveURL(/\/vehicles$/);
await expect(page.getByText("Operations manager")).toBeVisible();
await expect(page.locator(".data-table")).toBeVisible();
});
test("logout invalidates the server session so a refresh returns to login", async ({ page }) => {
await page.goto("/dashboard");
await switchRole(page);
await expect(page).toHaveURL(/\/login$/);
// Directly re-requesting a protected route after logout must not restore access from a
// stale client cache; the server-side cookie is gone.
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login$/);
});
test("direct navigation to a protected route without a session redirects to login", async ({
page,
context,
}) => {
await context.clearCookies();
await page.goto("/vehicles");
await expect(page).toHaveURL(/\/login$/);
});
test("rental employee role has a restricted nav and cannot reach manager-only pages", async ({
page,
}) => {
await switchRole(page);
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
// Manager-only nav items are not shown at all, not merely disabled.
await expect(page.getByRole("link", { name: "Data quality" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Integrations" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Audit trail" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Privacy" })).toHaveCount(0);
// Direct URL navigation is still blocked server-side and shows the same restricted
// message as a defense-in-depth measure, not just a hidden button.
await page.goto("/automation");
await expect(
page.getByText("Automation is visible to Operations Managers only.").first(),
).toBeVisible();
await page.goto("/data-quality");
await expect(
page.getByText("Data-quality evidence and resolutions are visible to Operations Managers only.").first(),
).toBeVisible();
await page.goto("/audit");
await expect(page.getByText("Audit history is visible to Operations Managers only.").first()).toBeVisible();
await page.goto("/privacy");
await expect(page.getByText("These governance functions are available to Operations Managers only.")).toBeVisible();
await page.goto("/users");
await expect(page.getByText("User administration is available to Operations Managers only.").first()).toBeVisible();
await expect(page.getByRole("button", { name: "Add user" })).toHaveCount(0);
await page.goto("/vehicles/MO-024");
await expect(page.getByRole("tab", { name: "Quality" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Reset demo data" })).toHaveCount(0);
});
test("rental employee return result exposes operational follow-up but no manager-only evidence links", async ({
page,
}) => {
await switchRole(page);
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/bookings/BK-DEMO-RETURN");
await page.getByLabel("End odometer (km)").fill("54000");
await page.getByLabel("Fuel level (%)").fill("75");
await page.getByRole("button", { name: "Review return" }).click();
await page.getByRole("button", { name: "Confirm return" }).click();
await expect(page.getByRole("heading", { name: "Return registered" })).toBeVisible();
await expect(page.getByRole("link", { name: /View vehicle MO-024/ })).toBeVisible();
await expect(page.getByRole("link", { name: "View automation status" })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Trace the complete operation" })).toHaveCount(0);
});
test("operations manager can reset demo data and is returned to login", async ({ page }) => {
await expect(page.getByRole("button", { name: "Reset demo data" })).toBeVisible();
await page.getByRole("button", { name: "Reset demo data" }).click();
await page.getByRole("button", { name: "Yes, reset" }).click();
await expect(page).toHaveURL(/\/login$/);
// The reset must not have affected the ability to log back in against fresh data.
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
});
test("automation page shows the aggregate n8n integration status, not just the latest event", async ({
page,
request,
}) => {
await resetDemoData(request);
await page.goto("/automation");
await expect(page.locator(".integration-cards")).toContainText("succeeded");
await expect(page.locator(".integration-cards")).toContainText("failed");
});
test("rental employee direct API access to manager-only endpoints is rejected", async ({
page,
}) => {
await switchRole(page);
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
// page.request shares the browser context's cookies, and (via the web container's
// nginx /api/ proxy) works identically against localhost and the deployed server --
// the backend API itself is never exposed directly on either.
for (const path of ["/api/v1/data-quality/issues", "/api/v1/audit", "/api/v1/workflows"]) {
const response = await page.request.get(path);
expect(response.status(), path).toBe(403);
}
});
+116
View File
@@ -0,0 +1,116 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
async function reset(request: APIRequestContext) {
expect((await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } })).ok()).toBeTruthy();
expect((await request.post("/api/v1/demo/reset")).ok()).toBeTruthy();
expect((await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } })).ok()).toBeTruthy();
}
async function login(page: Page) {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}
test.describe.configure({ mode: "serial" });
test("booking creation explains when the end does not follow the start", async ({ page }) => {
await login(page);
await page.goto("/bookings/new");
const start = page.getByLabel("Start");
const end = page.getByLabel("Einde");
const startValue = await start.inputValue();
await end.fill(startValue);
await expect(page.getByRole("alert")).toHaveText("Het einde moet na de start van de boeking vallen.");
await expect(end).toHaveAttribute("aria-invalid", "true");
await expect(page.getByRole("button", { name: "Boeking aanmaken" })).toBeDisabled();
});
test("operator can create and cancel a booking through the UI", async ({ page, request }) => {
await reset(request);
await login(page);
await page.goto("/bookings/new");
await page.getByLabel("Klant zoeken").fill("CUS-0001");
const customer = page.locator(".booking-create-form select").nth(0);
await expect.poll(() => customer.locator("option").count()).toBeGreaterThan(1);
await customer.selectOption({ index: 1 });
const vehicle = page.locator(".booking-create-form select").nth(1);
await expect.poll(() => vehicle.locator("option").count()).toBeGreaterThan(1);
await vehicle.selectOption({ index: 1 });
await page.getByRole("button", { name: "Boeking aanmaken" }).click();
await expect(page).toHaveURL(/\/bookings\/BK-/);
await expect(page.getByText("gereserveerd", { exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Vereisten controleren" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Voertuig uitchecken" })).toHaveCount(0);
await page.getByLabel("Controlebewijs").fill("Rijbewijs en huurvoorwaarden gecontroleerd");
await page.getByRole("button", { name: "Vereisten bevestigen" }).click();
await expect(page.getByRole("heading", { name: "Voertuig uitchecken" })).toBeVisible();
await page.getByLabel("Reden", { exact: true }).fill("Klant annuleert de geplande rit");
await page.getByRole("button", { name: "Annulering bevestigen" }).click();
await expect(page.getByText("geannuleerd", { exact: true })).toBeVisible();
});
test("manager can create, edit and deactivate an operational user", async ({ page, request }) => {
await reset(request);
await login(page);
await page.goto("/users");
await page.getByLabel("Naam").fill("E2E Planner");
await page.getByLabel("E-mail").fill("e2e.planner@example.test");
await page.getByLabel("Tijdelijk wachtwoord").fill("secure-e2e-password");
await page.getByRole("button", { name: "Gebruiker toevoegen" }).click();
let row = page.getByRole("row", { name: /E2E Planner/ });
await expect(row).toBeVisible();
await row.getByRole("button", { name: "Bewerken" }).click();
const editor = page.locator(".user-edit");
await editor.getByLabel("Naam").fill("E2E Senior Planner");
await editor.getByLabel("Rol").selectOption("operations_manager");
await editor.getByLabel("Nieuw wachtwoord").fill("updated-e2e-password");
await editor.getByRole("button", { name: "Wijzigingen opslaan" }).click();
row = page.getByRole("row", { name: /E2E Senior Planner/ });
await expect(row.getByText("Operationeel beheerder")).toBeVisible();
await row.getByRole("button", { name: "Deactiveren" }).click();
await expect(row.getByText("inactief", { exact: true })).toBeVisible();
});
test("manager can assign data-quality work from the queue", async ({ page, request }) => {
await reset(request);
await login(page);
await page.goto("/data-quality?status=open");
const firstRow = page.locator(".data-table tbody tr").first();
const issueRef = (await firstRow.locator(".row-link").textContent())?.trim() ?? "";
expect(issueRef).not.toBe("");
await firstRow.getByRole("checkbox").check();
const assignee = page.getByLabel("Toewijzen aan");
await assignee.selectOption({ index: 1 });
const selectedLabel = await assignee.locator("option:checked").textContent();
await page.getByRole("button", { name: "Werkvoorraad bijwerken" }).click();
const updatedRow = page.locator(".data-table tbody tr").filter({
has: page.getByRole("link", { name: issueRef, exact: true }),
});
await expect(updatedRow).toContainText(selectedLabel ?? "");
});
test("manager can record maintenance and release a safe vehicle", async ({ page, request }) => {
await reset(request);
const vehicles = await (await request.get("/api/v1/vehicles?status=available")).json();
let selected = vehicles[0];
for (const candidate of vehicles) {
const detail = await (await request.get(`/api/v1/vehicles/${candidate.public_ref}`)).json();
if (!detail.attention && !detail.bookings.some((booking: { status: string }) => booking.status === "active")) {
selected = candidate;
break;
}
}
await login(page);
await page.goto(`/vehicles/${selected.public_ref}`);
await page.getByRole("tab", { name: "Onderhoud" }).click();
await page.getByRole("button", { name: "Onderhoud registreren" }).click();
await page.getByLabel("Uitgevoerd werk").fill("Periodiek onderhoud en veiligheidscontrole voltooid");
await page.getByRole("button", { name: "Onderhoud opslaan" }).click();
await expect(page.getByText("onderhoud", { exact: true }).first()).toBeVisible();
await page.getByLabel("Reden voor vrijgave").fill("Veiligheidscontrole afgerond zonder open punten");
await page.getByRole("button", { name: "Voertuig vrijgeven" }).click();
await expect(page.getByText("beschikbaar", { exact: true }).first()).toBeVisible();
});
+30
View File
@@ -0,0 +1,30 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
async function resetAndLogin(request: APIRequestContext, page: Page) {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post("/api/v1/demo/reset");
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}
test("privacy centre reports policy and produces an audited CSV export", async ({ page, request }) => {
await resetAndLogin(request, page);
await page.goto("/privacy");
await expect(page.getByRole("heading", { name: "Privacybeheer" })).toBeVisible();
await expect(page.getByText("30 dagen", { exact: true })).toBeVisible();
const downloadPromise = page.waitForEvent("download");
await page.getByRole("link", { name: "Audit CSV downloaden" }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe("mobilityops-audit.csv");
});
test("privacy centre refuses anonymisation of a customer with an active booking", async ({ page, request }) => {
await resetAndLogin(request, page);
await page.goto("/privacy");
await page.getByLabel("Klantreferentie").first().fill("CUS-0042");
await page.getByLabel("Gemotiveerde reden").fill("Gevalideerd verzoek van de betrokkene");
await page.getByLabel(/Typ CUS-0042 ter bevestiging/).fill("CUS-0042");
await page.getByRole("button", { name: "Definitief anonimiseren" }).click();
await expect(page.getByText(/kon niet worden uitgevoerd/)).toBeVisible();
});
+142
View File
@@ -0,0 +1,142 @@
import { expect, test, type Page } from "@playwright/test";
test.describe.configure({ mode: "serial" });
async function loginAsOperationsManager(page: Page) {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}
test("90-second recruiter entry exposes three verifiable engineering highlights", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Bekijk de highlights in 90 seconden" }).click();
await expect(page).toHaveURL(/\/highlights$/);
await expect(page.getByRole("heading", { name: "De sterkste keuzes in 90 seconden" })).toBeVisible();
await expect(page.locator(".highlight-card")).toHaveCount(3);
await expect(page.getByText("Transactie · idempotency · outbox · degradatie")).toBeVisible();
await expect(page.getByText("RAGcore · citations · veilige degradatie")).toBeVisible();
});
test("Engineering Story exposes architecture, reliability and honest integration evidence", async ({ page }) => {
await loginAsOperationsManager(page);
await page.goto("/about");
await expect(page.getByRole("heading", { name: "Controle, betrouwbaarheid en uitlegbaarheid" }).first()).toBeVisible();
await expect(page.getByText("Commit eerst, orkestreer daarna")).toBeVisible();
await expect(page.getByText("AI moet bewijs tonen")).toBeVisible();
await expect(page.locator(".architecture-step", { hasText: "PostgreSQL + audittrail" }).getByRole("button")).toBeVisible();
const outboxStep = page.locator(".architecture-step", { hasText: "Betrouwbare outbox" }).getByRole("button");
await outboxStep.click();
await expect(outboxStep).toHaveAttribute("aria-pressed", "true");
await expect(page.locator("#architecture-active-detail")).toContainText("Post-commit aflevering");
const architectureTypeMinimums = [
[".architecture-zones > span", 12],
[".architecture-step button > small", 13],
[".architecture-zone-tag", 12],
[".architecture-commit-note", 12],
[".architecture-interaction-hint", 13],
[".architecture-active-copy p", 13],
[".architecture-active-evidence dt", 12],
[".architecture-active-evidence dd", 13],
] as const;
for (const [selector, minimumPixels] of architectureTypeMinimums) {
const fontSize = await page.locator(selector).first().evaluate((element) =>
Number.parseFloat(window.getComputedStyle(element).fontSize));
expect(fontSize, `${selector} font-size`).toBeGreaterThanOrEqual(minimumPixels);
}
await expect(page.getByText(/nog niet live gekoppeld/)).toHaveCount(0);
await expect(page.getByText(/unknown procedures/)).toHaveCount(0);
});
test("dashboard load failure offers an in-place retry and recovers", async ({ page }) => {
await loginAsOperationsManager(page);
let dashboardAttempts = 0;
let allowDashboardRecovery = false;
await page.route("**/api/v1/dashboard", async (route) => {
dashboardAttempts += 1;
if (!allowDashboardRecovery) {
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" });
return;
}
await route.continue();
});
await page.reload();
const retryButton = page.getByRole("button", { name: "Opnieuw proberen" });
await expect(retryButton).toBeVisible();
allowDashboardRecovery = true;
await retryButton.click();
await expect(page.getByRole("heading", { name: "Wagenparkstatus" })).toBeVisible();
expect(dashboardAttempts).toBeGreaterThanOrEqual(2);
});
test("dashboard header stacks a full-width touch target on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await loginAsOperationsManager(page);
const header = page.locator(".page-header");
const action = page.locator(".page-actions .button");
const [headerBox, actionBox] = await Promise.all([header.boundingBox(), action.boundingBox()]);
expect(headerBox).not.toBeNull();
expect(actionBox).not.toBeNull();
expect(actionBox!.width).toBeGreaterThanOrEqual(headerBox!.width - 1);
expect(Math.round(actionBox!.height)).toBeGreaterThanOrEqual(44);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
await page.evaluate(() => document.documentElement.clientWidth + 1),
);
});
test("mobile recruiter surfaces remain readable without horizontal overflow", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/login");
await page.getByRole("button", { name: "Bekijk de highlights in 90 seconden" }).click();
await expect(page).toHaveURL(/\/highlights$/);
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
expect(overflow).toBeLessThanOrEqual(1);
await expect(page.locator(".highlight-card").first()).toBeVisible();
});
test("remaining attention items use a compact, legible queue action", async ({ page, request }) => {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post("/api/v1/demo/reset");
await loginAsOperationsManager(page);
const queueAction = page.locator(".queue-more a");
await expect(queueAction).toContainText("Nog 2 aandachtspunten");
await expect(queueAction).toContainText("Open de volledige werklijst");
const actionBox = await queueAction.boundingBox();
const arrowBox = await queueAction.locator(".queue-more-arrow svg").boundingBox();
expect(actionBox?.height).toBeLessThanOrEqual(70);
expect(arrowBox?.width).toBeLessThanOrEqual(16);
});
test("Knowledge Hub renders independently verified RAGcore index evidence", async ({ page }) => {
await loginAsOperationsManager(page);
await page.route("**/api/v1/knowledge/status**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
provider: "ragcore",
available: true,
detail: "11/11 managed sources verified",
tenant: "northstar-mobility-demo",
workspace: "mobilityops",
collection: "internal-procedures",
document_count: 11,
source_document_count: 11,
reported_synced_document_count: 33,
reported_failed_document_count: 0,
last_sync_at: "2026-08-10T16:00:00Z",
statistics_state: "verified",
}),
});
});
await page.goto("/knowledge");
await expect(page.getByText("11 procedures geïndexeerd")).toBeVisible();
await expect(page.getByText("exact gevonden en actief gepubliceerd")).toBeVisible();
});
+57
View File
@@ -0,0 +1,57 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
async function resetDemoData(request: APIRequestContext) {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post("/api/v1/demo/reset");
}
// Cross-checks the responsive shell across the exact breakpoint matrix from the polish
// brief (1440x1000, 1280x800, 1024x768, 768x1024, 430x932, 390x844, 360x800) in all three
// supported languages -- both that nothing overflows horizontally and that the localized
// heading text still renders (rather than being silently truncated away).
const BREAKPOINTS: { width: number; height: number }[] = [
{ width: 1440, height: 1000 },
{ width: 1280, height: 800 },
{ width: 1024, height: 768 },
{ width: 768, height: 1024 },
{ width: 430, height: 932 },
{ width: 390, height: 844 },
{ width: 360, height: 800 },
];
const LANGUAGES: { code: string; attentionTitle: string }[] = [
{ code: "nl-BE", attentionTitle: "Aandachtspunten" },
{ code: "en-GB", attentionTitle: "Attention queue" },
{ code: "fr-BE", attentionTitle: "File d'attention" },
];
test.beforeEach(async ({ request }) => {
await resetDemoData(request);
});
for (const language of LANGUAGES) {
test.describe(`${language.code}`, () => {
for (const bp of BREAKPOINTS) {
test(`no horizontal overflow at ${bp.width}x${bp.height}`, async ({ page }) => {
await page.addInitScript(
(lang) => localStorage.setItem("fleetops.language", lang),
language.code,
);
await page.setViewportSize({ width: bp.width, height: bp.height });
await page.goto("/login");
await page.getByRole("button", { name: /begeleide demo|guided demo|démo guidée/i }).click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByRole("heading", { name: language.attentionTitle })).toBeVisible();
const dimensions = await page.evaluate(() => ({
scroll: document.documentElement.scrollWidth,
client: document.documentElement.clientWidth,
}));
expect(dimensions.scroll, `${language.code} @ ${bp.width}x${bp.height}`).toBeLessThanOrEqual(
dimensions.client + 1,
);
});
}
});
}
+45
View File
@@ -0,0 +1,45 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
async function reset(request: APIRequestContext) {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post("/api/v1/demo/reset");
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
}
async function login(page: Page) {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}
test.beforeEach(async ({ request }) => reset(request));
test("operational lists use bounded server pages and retain filter state in the URL", async ({ page, request }) => {
const vehicles = await request.get("/api/v1/vehicles?page=1&page_size=25");
expect(vehicles.ok()).toBeTruthy();
const vehiclePage = await vehicles.json();
expect(vehiclePage.items).toHaveLength(25);
expect(vehiclePage.total).toBeGreaterThanOrEqual(25);
const audit = await request.get("/api/v1/audit?page=1&page_size=25");
expect(audit.ok()).toBeTruthy();
const auditPage = await audit.json();
expect(auditPage.items.length).toBeLessThanOrEqual(25);
await login(page);
await page.goto("/vehicles?status=maintenance&page=1");
await expect(page.getByRole("heading", { name: "Wagenpark" })).toBeVisible();
await expect(page).toHaveURL(/status=maintenance/);
});
test("record status remains visible and responsive lists do not overflow on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await login(page);
await page.goto("/bookings/BK-DEMO-RETURN");
await expect(page.getByRole("heading", { name: "BK-DEMO-RETURN" })).toBeVisible();
await expect(page.locator(".page-actions .badge")).toBeVisible();
await page.goto("/vehicles");
const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth }));
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.client + 1);
});
+107
View File
@@ -0,0 +1,107 @@
import { expect, test, type APIRequestContext } from "@playwright/test";
async function resetDemoData(request: APIRequestContext) {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post("/api/v1/demo/reset");
}
// This file's assertions were authored against the English UI copy; nl-BE is now the
// app's default for a fresh session, so force English explicitly rather than rewriting
// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs).
test.beforeEach(async ({ page, request }) => {
await resetDemoData(request);
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
await page.goto("/login");
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
});
test("control-centre shell exposes landmarks, persisted readiness and active navigation", async ({ page }) => {
await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible();
await expect(page.getByRole("main")).toBeVisible();
await expect(page.getByRole("heading", { name: "Fleet readiness" })).toBeVisible();
await expect(page.locator(".metric-cell dd").first()).not.toHaveText("");
await expect(page.getByRole("link", { name: "Overview" }).first()).toHaveAttribute("aria-current", "page");
});
test("global search supports its keyboard shortcut and finds a vehicle by reference", async ({ page }) => {
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
await expect(async () => {
await page.keyboard.press("Control+k");
await expect(search).toBeFocused();
}).toPass({ timeout: 5_000 });
await search.fill("MO-024");
const result = page.getByRole("option", { name: /MO-024/ });
await expect(result).toBeVisible();
await result.click();
await expect(page).toHaveURL(/\/vehicles\/MO-024$/);
await expect(page.getByRole("heading", { name: "MO-024" })).toBeVisible();
});
test("global search supports arrow-key navigation and Enter to select", async ({ page }) => {
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
await search.fill("fleet");
await expect(page.getByRole("option", { name: /Fleet/ })).toBeVisible();
await search.press("ArrowDown");
await search.press("Enter");
await expect(page).toHaveURL(/\/vehicles$/);
});
test("global search shows a no-results state and closes on Escape", async ({ page }) => {
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
await search.fill("zzz-nothing-matches-zzz");
await expect(page.getByText(/No matches for/)).toBeVisible();
await search.press("Escape");
await expect(page.getByRole("listbox")).not.toBeVisible();
});
test("global search finds a booking and a data-quality issue by reference", async ({ page }) => {
const search = page.getByRole("combobox", { name: "Search Fleet Ops" });
await search.fill("BK-DEMO-RETURN");
const bookingResult = page.getByRole("option", { name: /BK-DEMO-RETURN/ });
await expect(bookingResult).toBeVisible();
await bookingResult.click();
await expect(page).toHaveURL(/\/bookings\/BK-DEMO-RETURN$/);
await search.fill("DQ-DEMO-OVERLAP");
const issueResult = page.getByRole("option", { name: /DQ-DEMO-OVERLAP/ });
await expect(issueResult).toBeVisible();
await issueResult.click();
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-OVERLAP$/);
});
test("return review separates capture from irreversible commit", async ({ page }) => {
await page.goto("/bookings/BK-DEMO-RETURN");
await page.getByLabel("End odometer (km)").fill("60000");
await page.getByLabel("Fuel level (%)").fill("65");
let commitRequests = 0;
let previewRequests = 0;
page.on("request", (request) => {
if (request.method() !== "POST") return;
if (request.url().endsWith("/return-preview")) previewRequests += 1;
else if (request.url().endsWith("/return")) commitRequests += 1;
});
await page.getByRole("button", { name: "Review return" }).click();
await expect(page.getByText(/Expected fleet state/)).toBeVisible();
expect(commitRequests).toBe(0);
// The review step is server-evaluated (not client-guessed), so exactly one non-mutating
// preview call is expected before any commit.
expect(previewRequests).toBe(1);
await expect(page.getByText("Queue automation after the local commit")).toBeVisible();
await page.getByRole("button", { name: "Edit details" }).click();
await expect(page.getByLabel("End odometer (km)")).toHaveValue("60000");
});
for (const width of [390, 768, 1280, 1440]) {
test(`responsive shell has no horizontal overflow at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: width < 700 ? 844 : 900 });
await page.goto("/dashboard");
await expect(page.getByRole("heading", { name: "Attention queue" })).toBeVisible();
const dimensions = await page.evaluate(() => ({ scroll: document.documentElement.scrollWidth, client: document.documentElement.clientWidth }));
expect(dimensions.scroll).toBeLessThanOrEqual(dimensions.client + 1);
if (width < 960) await expect(page.getByRole("navigation", { name: "Mobile navigation" })).toBeVisible();
});
}
+15
View File
@@ -0,0 +1,15 @@
import { expect, test } from "@playwright/test";
test("public entry and engineering highlights retain their visual hierarchy", async ({ page }) => {
await page.goto("/login");
await expect(page.locator(".login-access")).toHaveScreenshot("login-access.png", {
animations: "disabled",
maxDiffPixelRatio: 0.02,
});
await page.goto("/highlights");
await expect(page.locator("main")).toHaveScreenshot("highlights-main.png", {
animations: "disabled",
maxDiffPixelRatio: 0.02,
});
});