M54: harden operations and demo resilience
This commit is contained in:
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -74,6 +74,7 @@ test("the collapsed chip has its own close control, independent of reopening it"
|
||||
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 }) => {
|
||||
@@ -106,6 +107,41 @@ test("restarting the demo from the guide resets data and returns to login", asyn
|
||||
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");
|
||||
@@ -147,6 +183,52 @@ test("Escape collapses the standard-tier panel, then closes it", async ({ page }
|
||||
|
||||
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 }) => {
|
||||
@@ -161,3 +243,18 @@ test("going to a step scrolls, focuses and highlights the on-page target", async
|
||||
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/);
|
||||
});
|
||||
|
||||
@@ -49,7 +49,12 @@ test("data quality list can filter to demo scenarios only", async ({ page }) =>
|
||||
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
const allRows = await page.locator(".data-table tbody tr").count();
|
||||
await page.getByRole("checkbox", { name: "Enkel demoscenario's" }).check();
|
||||
// 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);
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
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", {
|
||||
@@ -12,6 +18,8 @@ async function resetDemoData(request: APIRequestContext) {
|
||||
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);
|
||||
@@ -50,14 +58,66 @@ test("five-minute demo script end to end", async ({ page, request }) => {
|
||||
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 quality issue and queued automation event", async () => {
|
||||
await expect(page.getByText(/DQ-RET-|Geen aangemaakt/)).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 () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -66,3 +67,17 @@ 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");
|
||||
});
|
||||
|
||||
@@ -106,6 +106,13 @@ test("full guided demo walkthrough, start to finish, restoring the environment a
|
||||
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 () => {
|
||||
|
||||
@@ -400,9 +400,35 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
|
||||
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();
|
||||
|
||||
@@ -14,6 +14,20 @@ async function login(page: Page) {
|
||||
|
||||
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);
|
||||
|
||||
@@ -26,11 +26,69 @@ test("Engineering Story exposes architecture, reliability and honest integration
|
||||
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.getByText("PostgreSQL + audittrail")).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");
|
||||
|
||||
Reference in New Issue
Block a user