Publish curated MobilityOps source snapshot
This commit is contained in:
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` });
|
||||
});
|
||||
@@ -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` });
|
||||
});
|
||||
@@ -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([]);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user