import { expect, test } from "@playwright/test"; import { mockApi, seedAuthenticatedState } from "./support/network.js"; const removedMachineUnavailableGuidanceText = "Maskinvask er ikke tilgængelig for den valgte bane. Vælg manuel vask eller en anden ledig bane."; const CUSTOMER_SELF_SERVE_PERMISSIONS = [ "user", "list_own_department_selfserve_vehicle_conditions", "add_own_department_selfserve_vehicle_conditions", ]; async function suppressVueDevtoolsOverlay(page) { await page.addInitScript(() => { const appendStyle = () => { const parent = document.documentElement || document.head || document.body; if (!parent) { document.addEventListener("DOMContentLoaded", appendStyle, { once: true }); return; } const style = document.createElement("style"); style.textContent = "#__vue-devtools-container__, .vue-devtools__anchor-btn, .vue-devtools__panel-content { display: none !important; visibility: hidden !important; pointer-events: none !important; }"; parent.appendChild(style); }; appendStyle(); }); } async function primeSession(page, { token }) { await seedAuthenticatedState(page, token); } async function mockSelectedSubuserGrant( page, { customerNumber = 12345679, permissions = ["SELFSERVE_LIST", "SELFSERVE_ADD"] } = {} ) { await page.route("**/subusers/me", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ data: { id: 71, username: "driver-user", name: "Driver User", email: "driver@example.test", phone_country_code: 45, phone: 12345678, grants: [ { id: 7101, billing_customer_number: customerNumber, permissions, }, ], created_at: "2026-01-01T00:00:00.000Z", updated_at: "2026-01-01T00:00:00.000Z", suspended_at: null, }, }), }); }); } async function seedGeolocation(context) { await context.grantPermissions(["geolocation"]); await context.setGeolocation({ latitude: 55.6415, longitude: 12.0803, }); } async function fillRegistration(page, value) { const registration = page.locator('input[placeholder*="registreringsnummer"]').first(); await registration.fill(value); await expect(registration).toHaveValue(value.toUpperCase()); await registration.press("Escape"); } async function selectVehicleType(page, vehicleTypeId = 2) { await page.getByTestId(`self-serve-vehicle-type-${vehicleTypeId}`).click(); await expect(page.getByTestId("self-serve-vehicle-selector-description")).toContainText(/Truck|Van|Car/); } async function advanceGuidedWashToLastStep(page) { for (let index = 0; index < 5; index += 1) { await page.getByTestId("self-serve-guided-next").click(); } } async function waitForLaneCommandRequest(page, command) { const request = await page.waitForRequest((candidateRequest) => { if (!candidateRequest.url().includes("/modules/self-serve/lane/command")) { return false; } if (candidateRequest.method() !== "POST") { return false; } const body = candidateRequest.postDataJSON?.() || {}; return body.command === command; }); await request.response(); return request; } function buildSavedProgress(overrides = {}) { return { washInProgress: false, washLaneId: null, washStartTime: null, currentStep: 0, licensePlateInput: "AB12345", vehicleTypeSelect: 2, radioWashType: "Manual", radioLaneOption: 7, customerNumberInput: "12345679", answers: {}, completedTasks: {}, nearestDepartmentId: 6, forceNearestDepartmentEvaluationId: 0, isForcingNearestDepartment: false, savedAt: Date.now(), ...overrides, }; } async function seedSavedProgress(page, overrides = {}) { const payload = buildSavedProgress(overrides); await page.addInitScript((savedProgress) => { window.localStorage.setItem("mywash_progress_v6", JSON.stringify(savedProgress)); }, payload); try { await page.evaluate((savedProgress) => { window.localStorage.setItem("mywash_progress_v6", JSON.stringify(savedProgress)); }, payload); } catch {} } async function expectFinishingOrCompletedWash(page) { const finishing = page.getByTestId("self-serve-finishing-wash"); const completed = page.getByTestId("self-serve-completed-step"); await expect .poll( async () => { if (await finishing.isVisible().catch(() => false)) { return "finishing"; } if (await completed.isVisible().catch(() => false)) { return "completed"; } return "pending"; }, { message: "expected the wash to show the finishing state or complete", timeout: 15_000, } ) .toMatch(/^(finishing|completed)$/); if (await finishing.isVisible().catch(() => false)) { await expect(finishing).toContainText("Afslutter vask, porten åbnes automatisk"); } } function captureSelfServeGatewayRequests(page) { const captured = { previews: [], summaries: [], answers: [], allowedServices: [], commands: [], dynamicImages: [], }; page.on("request", (request) => { const url = new URL(request.url()); let body = null; if (request.method() !== "GET") { try { body = request.postDataJSON?.() || null; } catch { body = null; } } const entry = { method: request.method(), url, body, }; if (url.pathname.endsWith("/department/selfserve/vehicle/allowed")) { captured.previews.push(entry); } else if (url.pathname.endsWith("/department/selfserve/washes/summary")) { captured.summaries.push(entry); } else if (url.pathname.endsWith("/department/selfserve/vehicle/conditions")) { captured.answers.push(entry); } else if (url.pathname.endsWith("/modules/self-serve/lane/services/allowed")) { captured.allowedServices.push(entry); } else if (url.pathname.endsWith("/modules/self-serve/lane/command")) { captured.commands.push(entry); } else if (url.pathname.endsWith("/department/lanes/dynamic-image")) { captured.dynamicImages.push(entry); } }); return captured; } async function roundedBoundingBox(locator) { const box = await locator.boundingBox(); expect(box).not.toBeNull(); return { width: Math.round(box.width), height: Math.round(box.height), }; } function waitForQuestionAnswerRequest(page, question, value) { return page.waitForRequest((request) => { const url = new URL(request.url()); if (!url.pathname.endsWith("/department/selfserve/vehicle/conditions") || request.method() !== "POST") { return false; } const body = request.postDataJSON?.() || {}; return Number(body.question) === Number(question) && body.value === value; }); } test.describe("Self-serve wash", () => { test.describe.configure({ timeout: 120_000 }); test.skip( ({ browserName }) => browserName !== "chromium", "Self-serve coverage is maintained on Chromium desktop/mobile." ); test.beforeEach(async ({ page, context }) => { await suppressVueDevtoolsOverlay(page); await seedGeolocation(context); }); test("@smoke landing route sorts departments, loads vehicle selection, and restores direct wash progress", async ({ page, }) => { const api = await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, sessionData: { customer_number: 12345679, }, selfServe: true, }); api.selfServe.commandResponseDelayMs = [500, 0]; await primeSession(page, { token: "self-serve-user-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash"); await expect(page.getByRole("link", { name: "Start vask" })).toBeVisible({ timeout: 30_000 }); await expect(page.getByText("Roskilde").first()).toBeVisible(); const orderedDepartmentNames = await page .locator(".card-header-title.has-text-grey") .evaluateAll((elements) => elements.map((element) => element.textContent?.trim())); expect(orderedDepartmentNames).toEqual(["Odense", "Aarhus"]); await page.getByRole("link", { name: "Start vask" }).click(); await expect(page).toHaveURL(/\/user\/wash\/start$/); await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde"); await fillRegistration(page, "ab12345"); await selectVehicleType(page, 2); api.selfServe.inProgressByLaneId[7] = { lane_id: 7, in_progress: true, session: { id: 601, lane_id: 7, department_id: 6, reg: "ZZ00000", customer_number: 12345679, vehicle_type_id: 2, machine_relay_enabled: false, wash_started_at: "2026-04-28 11:00:00", status: "IN_PROGRESS", }, customer: { customer_number: 12345679 }, vehicle: { reg: "ZZ00000", type: 2 }, }; await seedSavedProgress(page, { washInProgress: true, washLaneId: 7, washStartTime: Date.now() - 20_000, currentStep: 4, licensePlateInput: "ZZ00000", answers: { 21: true, }, }); await page.reload({ waitUntil: "domcontentloaded" }); await expect(page.getByTestId("self-serve-inline-progress")).toContainText("ZZ00000", { timeout: 20_000 }); await advanceGuidedWashToLastStep(page); await expect(page.getByTestId("self-serve-guided-next")).toBeHidden(); await expect(page.getByTestId("self-serve-nav-complete")).toBeVisible(); const stopCommandRequestPromise = waitForLaneCommandRequest(page, "STOP"); const exitGateCommandRequestPromise = waitForLaneCommandRequest(page, "OPEN_PROPERTY_EXIT_GATE"); await page.getByTestId("self-serve-nav-complete").click(); await expectFinishingOrCompletedWash(page); const stopCommandRequest = await stopCommandRequestPromise; const exitGateCommandRequest = await exitGateCommandRequestPromise; expect(stopCommandRequest.postDataJSON?.()).toMatchObject({ lane_id: 7, command: "STOP", }); expect(exitGateCommandRequest.postDataJSON?.()).toMatchObject({ lane_id: 7, command: "OPEN_PROPERTY_EXIT_GATE", }); await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 }); await page.getByTestId("self-serve-nav-close").click(); await expect(page.getByTestId("self-serve-completed-step")).toBeHidden(); await expect(page.getByTestId("self-serve-vehicle-step")).toBeVisible(); await expect(page.getByTestId("self-serve-nav-complete")).toBeHidden(); }); test("start route lets regular users choose a department when geolocation permission is denied", async ({ page, context, }) => { await context.clearPermissions(); await page.addInitScript(() => { Object.defineProperty(navigator, "geolocation", { configurable: true, value: { getCurrentPosition: (_success, error) => error?.({ code: 1, message: "User denied Geolocation", PERMISSION_DENIED: 1 }), watchPosition: (_success, error) => { error?.({ code: 1, message: "User denied Geolocation", PERMISSION_DENIED: 1 }); return 1; }, clearWatch: () => {}, }, }); }); await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, sessionData: { customer_number: 12345679, }, selfServe: true, }); await primeSession(page, { token: "self-serve-denied-geolocation-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("self-serve-start-page")).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde"); await expect(page.getByTestId("self-serve-vehicle-step")).toBeVisible({ timeout: 15_000 }); await page.getByTestId("self-serve-department-name").click(); await expect(page.getByTestId("self-serve-department-search")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("self-serve-department-search").fill("Odense"); await page.getByText("Odense", { exact: true }).click(); await expect(page.getByTestId("self-serve-department-name")).toContainText("Odense"); }); test("start route resumes the authenticated customer's active server wash from another device", async ({ page }) => { const requests = captureSelfServeGatewayRequests(page); await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, sessionData: { customer_number: 12345679, }, selfServe: { inProgressByLaneId: { 9: { lane_id: 9, in_progress: true, session: { id: 704, lane_id: 9, department_id: 2, reg: "CD67890", customer_number: 12345679, vehicle_type_id: 2, machine_relay_enabled: true, wash_started_at: "2026-04-28 10:15:00", status: "MACHINE_STARTED", }, customer: { customer_number: 12345679 }, vehicle: { reg: "CD67890", type: 2 }, }, }, summaryBySessionId: { 704: { session: { id: 704, lane_id: 9, reg: "CD67890", status: "MACHINE_STARTED", allowed: true, vehicle_type_id: 2, }, lane: { id: 9, name: "9", department: 2 }, questions: [], conditions: [], rules: [], tasks: [], events: [{ id: 7041, type: "MACHINE_STARTED", created_at: "2026-04-28T10:15:00.000Z" }], }, }, }, }); await primeSession(page, { token: "self-serve-active-server-wash-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-department-name")).toContainText("Odense"); await expect(page.getByTestId("self-serve-nav-help")).toContainText("Har du brug for hjælp?"); expect(requests.commands).toHaveLength(0); const bottomActions = page.getByTestId("self-serve-bottom-actions"); const guidedActions = bottomActions.getByTestId("self-serve-guided-bottom-actions"); const sessionActions = bottomActions.getByTestId("self-serve-session-bottom-actions"); const gateActions = bottomActions.getByTestId("self-serve-property-gate-actions"); await expect(bottomActions).toBeVisible(); await expect .poll(async () => bottomActions.evaluate((element) => Array.from(element.children).map((child) => child.getAttribute("data-testid")) ) ) .toEqual([ "self-serve-session-bottom-actions", "self-serve-property-gate-actions", "self-serve-guided-bottom-actions", ]); await expect(guidedActions.getByTestId("self-serve-guided-prev")).toBeVisible(); await expect(guidedActions.getByTestId("self-serve-guided-next")).toBeVisible(); await expect(sessionActions.getByTestId("self-serve-nav-help")).toContainText("Har du brug for hjælp?"); await expect(sessionActions.getByTestId("self-serve-nav-complete")).toBeHidden(); await expect(guidedActions.getByTestId("self-serve-nav-complete")).toBeHidden(); await expect(gateActions).toBeVisible(); await expect(gateActions.getByTestId("self-serve-nav-open-property-access-gate")).toBeVisible(); await expect(gateActions.getByTestId("self-serve-nav-open-property-exit-gate")).toBeVisible(); await advanceGuidedWashToLastStep(page); const guidedStepLabel = page.getByTestId("self-serve-guided-step-label"); const guidedStepTitle = page.getByTestId("self-serve-guided-step-title"); const guidedStepCounter = page.getByTestId("self-serve-guided-counter"); const guidedStepContent = page.getByTestId("self-serve-guided-content"); await expect(guidedStepTitle).toHaveText("6. Spot Free-skylning"); await expect(guidedStepCounter).toHaveText("6/6"); await expect(guidedStepLabel).toContainText("6/6"); await expect(page.getByText("6 / 6")).toHaveCount(0); const [titleBox, counterBox, contentBox] = await Promise.all([ guidedStepTitle.boundingBox(), guidedStepCounter.boundingBox(), guidedStepContent.boundingBox(), ]); expect(titleBox).not.toBeNull(); expect(counterBox).not.toBeNull(); expect(contentBox).not.toBeNull(); expect(Math.abs(titleBox.y + titleBox.height / 2 - (counterBox.y + counterBox.height / 2))).toBeLessThan(4); expect(counterBox.x).toBeGreaterThan(titleBox.x + titleBox.width); expect(Math.abs(counterBox.x + counterBox.width - (contentBox.x + contentBox.width))).toBeLessThan(8); await expect(guidedActions.getByTestId("self-serve-guided-next")).toBeHidden(); await expect(guidedActions.getByTestId("self-serve-nav-complete")).toBeVisible(); if ((page.viewportSize()?.width || 0) <= 768) { await expect(bottomActions).toHaveCSS("position", "fixed"); const footerBox = await page.locator(".fixed-bottom-footer").boundingBox(); const actionsBox = await bottomActions.boundingBox(); expect(footerBox).not.toBeNull(); expect(actionsBox).not.toBeNull(); expect(actionsBox.y + actionsBox.height).toBeLessThanOrEqual(footerBox.y + 2); } const accessCommandRequestPromise = waitForLaneCommandRequest(page, "OPEN_PROPERTY_ACCESS_GATE"); await page.getByTestId("self-serve-nav-open-property-access-gate").click(); const accessCommandRequest = await accessCommandRequestPromise; expect(accessCommandRequest.postDataJSON?.()).toMatchObject({ lane_id: 9, command: "OPEN_PROPERTY_ACCESS_GATE", }); }); test("superuser can force a department and clear the override", async ({ page }) => { await mockApi(page, { authenticated: true, permissions: ["user", "superuser"], selfServe: true, }); await primeSession(page, { token: "self-serve-superuser-token", permissions: ["user", "superuser"], }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde"); await page.getByTestId("self-serve-department-name").click(); const departmentSearch = page.locator('input[placeholder*="afdelinger"]').first(); await departmentSearch.fill("Odense"); await page.getByRole("button", { name: "Odense" }).click(); await expect(page.getByTestId("self-serve-department-name")).toContainText("Odense"); await expect(page.getByTestId("self-serve-department-clear")).toBeVisible(); await page.getByTestId("self-serve-department-clear").click(); await expect(page.getByTestId("self-serve-department-name")).toContainText("Roskilde"); }); test("start route skips an empty question set and waits for lane confirmation", async ({ page }) => { const requests = captureSelfServeGatewayRequests(page); const api = await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: true, }); api.selfServe.answerResponseDelayMs = 1000; api.selfServe.previewByKey["7:AB12345"] = { ...api.selfServe.previewByKey["7:AB12345"], questions: [], tasks: [], }; api.selfServe.summaryBySessionId[501] = { ...api.selfServe.summaryBySessionId[501], questions: [], tasks: [], }; api.selfServe.summaryByKey["7:AB12345"] = { ...api.selfServe.summaryByKey["7:AB12345"], questions: [], tasks: [], }; await primeSession(page, { token: "self-serve-empty-questions-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await fillRegistration(page, "ab12345"); await selectVehicleType(page, 2); await page.getByTestId("self-serve-nav-next").click(); await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-question-11")).toHaveCount(0); await expect(page.getByTestId("self-serve-nav-confirm")).toContainText(/start/i); expect(requests.commands).toHaveLength(0); const startCommandRequestPromise = waitForLaneCommandRequest(page, "START"); await page.getByTestId("self-serve-nav-confirm").click(); const startCommandRequest = await startCommandRequestPromise; expect(startCommandRequest.postDataJSON?.()).toMatchObject({ lane_id: 7, command: "START", wash_type: "Manual", }); }); test("start route reviews prefilled answered questions before lane confirmation", async ({ page }) => { const api = await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: true, }); const answeredQuestion = { ...api.selfServe.previewByKey["7:AB12345"].questions[0], answer: true, }; api.selfServe.previewByKey["7:AB12345"] = { ...api.selfServe.previewByKey["7:AB12345"], questions: [answeredQuestion], tasks: [], }; api.selfServe.summaryBySessionId[501] = { ...api.selfServe.summaryBySessionId[501], questions: [answeredQuestion], tasks: [], }; api.selfServe.summaryByKey["7:AB12345"] = { ...api.selfServe.summaryByKey["7:AB12345"], questions: [answeredQuestion], tasks: [], }; await primeSession(page, { token: "self-serve-prefilled-questions-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start", { waitUntil: "domcontentloaded" }); await fillRegistration(page, "ab12345"); await selectVehicleType(page, 2); await page.getByTestId("self-serve-nav-next").click(); await expect(page.getByTestId("self-serve-questions-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-question-11-yes")).toHaveClass(/is-success/); await expect(page.getByTestId("self-serve-lane-step")).toBeHidden(); await expect(page.getByTestId("self-serve-steps")).toHaveCSS("padding-bottom", "0px"); await expect(page.getByTestId("self-serve-prewash-bottom-actions")).toHaveCSS("position", "static"); await page.getByTestId("self-serve-nav-confirm").click(); await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); }); test("vehicle next button is disabled while self-serve data loads", async ({ page }) => { await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: { previewResponseDelayMs: 1000, summaryResponseDelayMs: 1000, }, }); await primeSession(page, { token: "self-serve-next-loading-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-vehicle-step")).toBeVisible({ timeout: 30_000 }); await fillRegistration(page, "ab12345"); await selectVehicleType(page, 2); const nextButton = page.getByTestId("self-serve-nav-next"); await expect(nextButton).toBeEnabled({ timeout: 10_000 }); await expect(nextButton).not.toHaveClass(/is-loading/); await nextButton.click(); await expect(nextButton).toBeDisabled(); await expect(page.getByTestId("self-serve-questions-step")).toBeVisible({ timeout: 10_000 }); }); test("lane flow gates invalid lanes, direct wash path auto-starts, and expired progress is discarded", async ({ page, }) => { await seedSavedProgress(page, { washInProgress: true, washLaneId: 7, washStartTime: Date.now() - 30_000, currentStep: 4, licensePlateInput: "STALE01", vehicleTypeSelect: 2, radioWashType: "Manual", radioLaneOption: 7, customerNumberInput: "12345679", savedAt: Date.now() - 3 * 60 * 60 * 1000, }); await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: true, }); await primeSession(page, { token: "self-serve-direct-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-vehicle-step")).toBeVisible(); await expect(page.getByTestId("self-serve-nav-complete")).toBeHidden(); await expect.poll(async () => page.evaluate(() => window.localStorage.getItem("mywash_progress_v6"))).toBeNull(); await seedSavedProgress(page, { currentStep: 2, licensePlateInput: "ZZ00000", radioLaneOption: 8, radioWashType: "Machine", }); await page.reload({ waitUntil: "domcontentloaded" }); await expect(page.getByTestId("self-serve-questions-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled(); await page.getByTestId("self-serve-question-21-yes").click(); await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 }); await page.getByTestId("self-serve-nav-confirm").click(); await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-lane-option-7")).toContainText("Tilgængelig"); await expect(page.getByTestId("self-serve-lane-option-8")).toContainText("Vaskebanen er ikke tilgængelig"); await expect(page.getByRole("radio", { name: /Maskine/i })).toBeEnabled(); await expect(page.getByTestId("self-serve-machine-unavailable-guidance")).toHaveCount(0); await expect(page.locator("body")).not.toContainText(removedMachineUnavailableGuidanceText); await page.getByTestId("self-serve-lane-option-7").click(); await page.getByTestId("self-serve-wash-type-manual").click(); await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 }); await page.getByTestId("self-serve-nav-confirm").click(); await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 }); }); test("hides disabled lanes from customer self-serve selection", async ({ page }) => { await seedSavedProgress(page, { currentStep: 2, licensePlateInput: "AB12345", vehicleTypeSelect: 2, radioLaneOption: 7, radioWashType: "Manual", customerNumberInput: "12345679", }); await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: { departments: [ { id: 6, name: "Roskilde", address: "Industrivej 45, 4000 Roskilde", latitude: 55.6415, longitude: 12.0803, self_serve_enabled: true, lanes: [ { id: 7, department: 6, name: "7", status: "AVAILABLE", selfserve_enabled: false, machine_available: false, products: [2, 3], relay_machine_id: "M-7", }, { id: 8, department: 6, name: "8", status: "AVAILABLE", selfserve_enabled: true, machine_available: true, products: [2, 3], relay_machine_id: "M-8", }, ], }, ], previewByKey: { "8:AB12345": { allowed: true, machine_available: true, lane: { id: 8, name: "8" }, questions: [], conditions: [], rules: [], tasks: [], allowed_services: ["MACHINE"], }, }, summaryByKey: { "8:AB12345": { session: { id: 801, lane_id: 8, reg: "AB12345", status: "IN_PROGRESS", allowed: true, vehicle_type_id: 2 }, lane: { id: 8, name: "8" }, questions: [], conditions: [], rules: [], tasks: [], events: [{ id: 8010, type: "SESSION_SYNCED", created_at: "2026-04-29T08:00:00.000Z" }], }, }, laneAllowedServicesByLane: { 8: ["MACHINE"], }, }, }); await primeSession(page, { token: "self-serve-disabled-lane-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-lane-option-7")).toHaveCount(0); await expect(page.getByTestId("self-serve-lane-option-8")).toBeVisible(); await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 }); }); test("disables lanes that the API marks unavailable even when raw status is available", async ({ page }) => { await seedSavedProgress(page, { currentStep: 2, licensePlateInput: "AB12345", vehicleTypeSelect: 2, radioLaneOption: 7, radioWashType: "Manual", customerNumberInput: "12345679", }); await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: { departments: [ { id: 6, name: "Roskilde", address: "Industrivej 45, 4000 Roskilde", latitude: 55.6415, longitude: 12.0803, self_serve_enabled: true, lanes: [ { id: 7, department: 6, name: "7", status: "AVAILABLE", selfserve_enabled: true, selfserve_available: false, selfserve_unavailable_reason: "lane_temporarily_unavailable", machine_available: false, products: [2, 3], relay_machine_id: "M-7", }, ], }, ], }, }); await primeSession(page, { token: "self-serve-unavailable-lane-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-lane-option-7")).toContainText("Vaskebanen er ikke tilgængelig"); await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled(); }); test("moves a stale saved lane selection to the first API-available lane", async ({ page }) => { await seedSavedProgress(page, { currentStep: 2, licensePlateInput: "AB12345", vehicleTypeSelect: 2, radioLaneOption: 7, radioWashType: "Manual", customerNumberInput: "12345679", }); await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: { departments: [ { id: 6, name: "Roskilde", address: "Industrivej 45, 4000 Roskilde", latitude: 55.6415, longitude: 12.0803, self_serve_enabled: true, lanes: [ { id: 7, department: 6, name: "7", status: "AVAILABLE", selfserve_enabled: true, selfserve_available: false, selfserve_unavailable_reason: "lane_temporarily_unavailable", machine_available: false, products: [2, 3], relay_machine_id: "M-7", }, { id: 8, department: 6, name: "8", status: "AVAILABLE", selfserve_enabled: true, selfserve_available: true, selfserve_unavailable_reason: null, machine_available: false, products: [2, 3], relay_machine_id: "M-8", }, ], }, ], previewByKey: { "8:AB12345": { allowed: true, machine_available: false, lane: { id: 8, name: "8", selfserve_available: true }, questions: [], conditions: [], rules: [], tasks: [], allowed_services: [], }, }, summaryByKey: { "8:AB12345": { session: { id: 801, lane_id: 8, reg: "AB12345", status: "IN_PROGRESS", allowed: true, vehicle_type_id: 2 }, lane: { id: 8, name: "8", selfserve_available: true }, questions: [], conditions: [], rules: [], tasks: [], events: [{ id: 8010, type: "SESSION_SYNCED", created_at: "2026-04-29T08:00:00.000Z" }], }, }, }, }); await primeSession(page, { token: "self-serve-stale-unavailable-lane-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-lane-option-7")).toContainText("Vaskebanen er ikke tilgængelig"); await expect(page.getByTestId("self-serve-lane-option-8")).toContainText("Tilgængelig"); await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 }); }); test("customer session uses authenticated customer number instead of stale local values", async ({ page }) => { const orderFilters = []; page.on("request", (request) => { const url = new URL(request.url()); if (url.pathname.endsWith("/orders")) { orderFilters.push(url.searchParams.get("filters") || ""); } }); await seedSavedProgress(page, { currentStep: 2, licensePlateInput: "ZZ00000", vehicleTypeSelect: 2, radioLaneOption: 7, radioWashType: "Manual", customerNumberInput: "777", }); await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, sessionData: { customer_number: 12345679, runtime_config: { economic: { transaction_draft_customer_number: 777, }, }, }, selfServe: true, }); await primeSession(page, { token: "self-serve-customer-number-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 }); const startCommandRequestPromise = waitForLaneCommandRequest(page, "START"); await page.getByTestId("self-serve-nav-confirm").click(); const startCommandRequest = await startCommandRequestPromise; expect(startCommandRequest.postDataJSON?.()).toMatchObject({ command: "START", customer_number: 12345679, wash_type: "Manual", defer_relay_side_effects: true, }); await page.waitForTimeout(250); expect(orderFilters.some((filters) => filters.includes("customer_id:777"))).toBe(false); }); test("subuser selected customer grant can start self-serve wash", async ({ page }) => { await page.addInitScript(() => { window.localStorage.setItem("is_subuser", "true"); window.localStorage.setItem("selected_customer_number", "12345679"); }); await seedSavedProgress(page, { currentStep: 2, licensePlateInput: "AB12345", vehicleTypeSelect: 2, radioLaneOption: 7, radioWashType: "Manual", customerNumberInput: "", }); await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, sessionData: { customer_number: null, }, selfServe: true, }); await mockSelectedSubuserGrant(page); await primeSession(page, { token: "self-serve-subuser-grant-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 }); const startCommandRequestPromise = waitForLaneCommandRequest(page, "START"); await page.getByTestId("self-serve-nav-confirm").click(); const startCommandRequest = await startCommandRequestPromise; expect(startCommandRequest.postDataJSON?.()).toMatchObject({ command: "START", customer_number: 12345679, wash_type: "Manual", defer_relay_side_effects: true, }); expect(startCommandRequest.headers()["x-customer-number"]).toBe("12345679"); }); test("start command failure stays retryable without entering in-progress state", async ({ page }) => { await seedSavedProgress(page, { currentStep: 2, licensePlateInput: "ZZ00000", vehicleTypeSelect: 2, radioLaneOption: 7, radioWashType: "Manual", customerNumberInput: "777", }); const api = await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, sessionData: { customer_number: 12345679, }, selfServe: { commandResponses: [ { success: false, data: { message: "Edge gateway command timed out", }, }, { success: true }, ], }, }); api.selfServe.previewByKey["7:ZZ00000"] = { ...api.selfServe.previewByKey["7:ZZ00000"], questions: [], tasks: [], }; api.selfServe.summaryBySessionId[601] = { ...api.selfServe.summaryBySessionId[601], questions: [], tasks: [], }; api.selfServe.summaryByKey["7:ZZ00000"] = { session: api.selfServe.previewByKey["7:ZZ00000"].session, lane: api.selfServe.previewByKey["7:ZZ00000"].lane, questions: [], conditions: [], rules: [], tasks: [], events: [{ id: 4, type: "STARTED", created_at: "2026-01-01T11:01:00.000Z" }], }; await primeSession(page, { token: "self-serve-start-retry-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("self-serve-nav-confirm").click(); await expect(page.getByTestId("self-serve-action-error")).toContainText("Edge gateway command timed out"); await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden(); await page.getByTestId("self-serve-action-retry").click(); await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 }); }); test("stop command failure shows the completed state after backend cleanup", async ({ page }) => { await seedSavedProgress(page, { washInProgress: true, washLaneId: 7, washStartTime: Date.now() - 30_000, currentStep: 4, licensePlateInput: "AB12345", vehicleTypeSelect: 2, radioLaneOption: 7, radioWashType: "Manual", customerNumberInput: "12345679", }); await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: { commandResponses: [ { success: false, data: { message: "Stop command failed", }, }, ], inProgressByLaneId: { 7: { lane_id: 7, in_progress: true, session: { id: 705, lane_id: 7, department_id: 6, reg: "AB12345", customer_number: 12345679, vehicle_type_id: 2, machine_relay_enabled: true, wash_started_at: "2026-04-28 10:15:00", status: "MACHINE_STARTED", }, customer: { customer_number: 12345679 }, vehicle: { reg: "AB12345", type: 2 }, }, }, }, }); await primeSession(page, { token: "self-serve-stop-failure-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 }); await advanceGuidedWashToLastStep(page); const stopCommandRequestPromise = waitForLaneCommandRequest(page, "STOP"); await page.getByTestId("self-serve-nav-complete").click(); const stopCommandRequest = await stopCommandRequestPromise; expect(stopCommandRequest.postDataJSON?.()).toMatchObject({ lane_id: 7, command: "STOP", }); await expect(page.getByTestId("self-serve-completed-step")).toBeVisible(); await expect(page.getByTestId("self-serve-nav-complete")).toBeHidden(); }); test("already-ended stop response completes stale in-progress progress", async ({ page }) => { await seedSavedProgress(page, { washInProgress: true, washLaneId: 7, washStartTime: Date.now() - 30_000, currentStep: 4, licensePlateInput: "AB12345", vehicleTypeSelect: 2, radioLaneOption: 7, radioWashType: "Manual", customerNumberInput: "12345679", }); await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: { commandResponseDelayMs: [500, 0], commandResponses: [ { success: false, data: { message: "Failed to execute command: Cannot stop lane: Lane is not occupied.", }, }, ], }, }); await primeSession(page, { token: "self-serve-stop-already-ended-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 }); await advanceGuidedWashToLastStep(page); const stopCommandRequestPromise = waitForLaneCommandRequest(page, "STOP"); const exitGateCommandRequestPromise = waitForLaneCommandRequest(page, "OPEN_PROPERTY_EXIT_GATE"); await page.getByTestId("self-serve-nav-complete").click(); await expectFinishingOrCompletedWash(page); const stopCommandRequest = await stopCommandRequestPromise; const exitGateCommandRequest = await exitGateCommandRequestPromise; expect(stopCommandRequest.postDataJSON?.()).toMatchObject({ lane_id: 7, command: "STOP", }); expect(exitGateCommandRequest.postDataJSON?.()).toMatchObject({ lane_id: 7, command: "OPEN_PROPERTY_EXIT_GATE", }); await expect(page.getByTestId("self-serve-action-error")).toBeHidden(); await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden(); }); test("edge gateway preview and summary refreshes stay read-only until confirmation", async ({ page }) => { const requests = captureSelfServeGatewayRequests(page); await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: true, }); await primeSession(page, { token: "self-serve-edge-readonly-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await fillRegistration(page, "ab12345"); await selectVehicleType(page, 2); await page.getByTestId("self-serve-nav-next").click(); await expect(page.getByTestId("self-serve-question-11")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-toggle-debug")).toHaveCount(0); await expect(page.getByTestId("self-serve-debug-panel")).toHaveCount(0); await page.getByTestId("self-serve-question-11-yes").click(); await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-runtime-error")).toBeHidden(); await expect(page.getByTestId("self-serve-action-error")).toBeHidden(); expect(requests.answers).toHaveLength(1); expect(requests.answers[0].body).toMatchObject({ lane: 7, question: 11, value: true, vehicle_type: 2, activate_machine: false, sync_relay_state: false, }); expect(requests.allowedServices).toHaveLength(0); expect(requests.commands).toHaveLength(0); expect(requests.previews.some((entry) => entry.url.searchParams.get("vehicle_type") === "2")).toBe(true); expect(requests.summaries.some((entry) => entry.url.searchParams.get("session_id") === "501")).toBe(true); expect(requests.summaries.some((entry) => entry.url.searchParams.get("vehicle_type") === "2")).toBe(true); await page.getByTestId("self-serve-nav-confirm").click(); await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); expect(requests.allowedServices).toHaveLength(1); expect(requests.allowedServices[0].body).toMatchObject({ lane_id: 7, task_ids: [9001], }); expect(requests.commands).toHaveLength(0); }); test("question answers stay clickable while answer sync refreshes in the background", async ({ page }) => { const requests = captureSelfServeGatewayRequests(page); const api = await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: true, }); api.selfServe.answerResponseDelayMs = 1000; const followUpQuestion = { id: 21, question: "Are the side mirrors folded?", description: "Can be answered while the first answer syncs.", answer: null, order_priority: 2, }; const preview = api.selfServe.previewByKey["7:AB12345"]; const initialQuestions = [...preview.questions, followUpQuestion]; preview.questions = initialQuestions; api.selfServe.summaryByKey["7:AB12345"].questions = initialQuestions; api.selfServe.answerResponseByKey["7:AB12345:11:true"].questions = [ { ...initialQuestions[0], answer: true }, followUpQuestion, ]; api.selfServe.answerResponseByKey["7:AB12345:21:false"] = { ...api.selfServe.answerResponseByKey["7:AB12345:11:true"], questions: [initialQuestions[0], { ...followUpQuestion, answer: false }], }; await primeSession(page, { token: "self-serve-background-answer-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await fillRegistration(page, "ab12345"); await selectVehicleType(page, 2); await page.getByTestId("self-serve-nav-next").click(); await expect(page.getByTestId("self-serve-question-11")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-question-21")).toBeVisible(); const firstAnswerRequest = waitForQuestionAnswerRequest(page, 11, true); await page.getByTestId("self-serve-question-11-yes").click(); await firstAnswerRequest; await expect(page.getByTestId("self-serve-questions-inline-loading")).toBeVisible(); await expect(page.getByTestId("self-serve-question-11-no")).toBeEnabled(); await expect(page.getByTestId("self-serve-question-21-no")).toBeEnabled(); const secondAnswerRequest = waitForQuestionAnswerRequest(page, 21, false); await page.getByTestId("self-serve-question-21-no").click(); await secondAnswerRequest; await expect.poll(() => requests.answers.length).toBe(2); expect(requests.answers.map((entry) => entry.body)).toEqual( expect.arrayContaining([ expect.objectContaining({ question: 11, value: true }), expect.objectContaining({ question: 21, value: false }), ]) ); }); test("question cards keep their position when answer refreshes return reordered questions", async ({ page }) => { const api = await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: true, }); api.selfServe.answerResponseDelayMs = 1000; const preview = api.selfServe.previewByKey["7:AB12345"]; const primaryQuestion = preview.questions[0]; const followUpQuestion = { id: 21, question: "Are the side mirrors folded?", description: "Keeps the list tall enough to detect jumps.", answer: null, order_priority: 2, }; const initialQuestions = [primaryQuestion, followUpQuestion]; preview.questions = initialQuestions; api.selfServe.summaryBySessionId[501].questions = initialQuestions; api.selfServe.summaryByKey["7:AB12345"].questions = initialQuestions; await primeSession(page, { token: "self-serve-stable-question-order-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await fillRegistration(page, "ab12345"); await selectVehicleType(page, 2); await page.getByTestId("self-serve-nav-next").click(); const questionCards = page.locator( '[data-testid="self-serve-question-cards"] > [data-testid^="self-serve-question-"]' ); await expect(questionCards).toHaveCount(2, { timeout: 10_000 }); await expect .poll(async () => questionCards.evaluateAll((cards) => cards.map((card) => card.getAttribute("data-testid")))) .toEqual(["self-serve-question-11", "self-serve-question-21"]); const firstQuestion = page.getByTestId("self-serve-question-11"); let stableQuestionBoxSamples = 0; let previousQuestionBoxKey = ""; await expect .poll( async () => { const [box, viewport] = await Promise.all([firstQuestion.boundingBox(), page.viewportSize()]); if (!box || !viewport || box.x < 0 || box.x >= viewport.width / 2) { stableQuestionBoxSamples = 0; previousQuestionBoxKey = ""; return false; } const boxKey = [box.x, box.y, box.width, box.height].map((value) => Math.round(value)).join(":"); stableQuestionBoxSamples = boxKey === previousQuestionBoxKey ? stableQuestionBoxSamples + 1 : 0; previousQuestionBoxKey = boxKey; return stableQuestionBoxSamples >= 2; }, { timeout: 10_000 } ) .toBe(true); const firstQuestionBefore = await firstQuestion.boundingBox(); expect(firstQuestionBefore).not.toBeNull(); const reorderedAnsweredQuestions = [{ ...followUpQuestion }, { ...primaryQuestion, answer: false }]; const reorderedSummary = { ...api.selfServe.answerResponseByKey["7:AB12345:11:true"], questions: reorderedAnsweredQuestions, }; api.selfServe.answerResponseByKey["7:AB12345:11:false"] = reorderedSummary; api.selfServe.previewByKey["7:AB12345"].questions = reorderedAnsweredQuestions; api.selfServe.summaryBySessionId[501].questions = reorderedAnsweredQuestions; api.selfServe.summaryByKey["7:AB12345"].questions = reorderedAnsweredQuestions; const answerRequest = waitForQuestionAnswerRequest(page, 11, false); const noButton = page.getByTestId("self-serve-question-11-no"); await noButton.click(); await expect(noButton).toHaveClass(/is-danger/); await expect(noButton).not.toHaveClass(/is-light/); await expect(page.getByTestId("self-serve-questions-inline-loading")).toBeVisible(); const firstQuestionWhileLoading = await firstQuestion.boundingBox(); expect(firstQuestionWhileLoading).not.toBeNull(); expect(Math.abs((firstQuestionWhileLoading?.y || 0) - (firstQuestionBefore?.y || 0))).toBeLessThanOrEqual(4); await answerRequest; await expect(page.getByTestId("self-serve-questions-inline-loading")).toBeHidden({ timeout: 10_000 }); await expect .poll(async () => questionCards.evaluateAll((cards) => cards.map((card) => card.getAttribute("data-testid")))) .toEqual(["self-serve-question-11", "self-serve-question-21"]); }); test("allowed-services edge gateway timeout stays on questions and can be retried", async ({ page }) => { const requests = captureSelfServeGatewayRequests(page); await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: { laneAllowedServiceResponses: [ { success: false, data: { message: "Edge gateway command timed out", }, }, { success: true, data: { allowed_services: ["MACHINE"], }, }, ], }, }); await primeSession(page, { token: "self-serve-edge-services-retry-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await fillRegistration(page, "ab12345"); await selectVehicleType(page, 2); await page.getByTestId("self-serve-nav-next").click(); await expect(page.getByTestId("self-serve-question-11")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("self-serve-question-11-yes").click(); await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 }); await page.getByTestId("self-serve-nav-confirm").click(); await expect(page.getByTestId("self-serve-runtime-error")).toContainText("Edge gateway command timed out"); await expect(page.getByTestId("self-serve-questions-step")).toBeVisible(); await expect(page.getByTestId("self-serve-lane-step")).toBeHidden(); expect(requests.allowedServices).toHaveLength(1); expect(requests.commands).toHaveLength(0); await page.getByTestId("self-serve-nav-confirm").click(); await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-runtime-error")).toBeHidden(); expect(requests.allowedServices).toHaveLength(2); expect(requests.commands).toHaveLength(0); }); test("global machine wash disable keeps questions visible and disables machine wash selection", async ({ page }) => { const requests = captureSelfServeGatewayRequests(page); const api = await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: { laneAllowedServices: [], }, }); for (const payload of [ api.selfServe.previewByKey["7:AB12345"], api.selfServe.answerResponseByKey["7:AB12345:11:true"], api.selfServe.summaryBySessionId[501], api.selfServe.summaryByKey["7:AB12345"], ]) { payload.allowed_services = []; payload.machine_available = false; payload.machine_wash_enabled = false; payload.tasks = []; } await primeSession(page, { token: "self-serve-machine-globally-disabled-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await fillRegistration(page, "ab12345"); await selectVehicleType(page, 2); await page.getByTestId("self-serve-nav-next").click(); await expect(page.getByTestId("self-serve-question-11")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("self-serve-question-11-yes").click(); await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 }); await page.getByTestId("self-serve-nav-confirm").click(); await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole("radio", { name: /Maskine/i })).toBeDisabled(); await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 }); expect(requests.commands).toHaveLength(0); }); test("@dynamic-image tasks flow renders dynamic image, requires completion, and restores after reload", async ({ page, }) => { const requests = captureSelfServeGatewayRequests(page); await seedSavedProgress(page, { washInProgress: true, washLaneId: 7, washStartTime: Date.now() - 30_000, currentStep: 3, licensePlateInput: "AB12345", vehicleTypeSelect: 2, radioLaneOption: 7, radioWashType: "Machine", customerNumberInput: "12345679", completedTasks: {}, }); const api = await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: true, }); // Keep the post-toggle response pending long enough for every browser to // observe the loading skeleton before the refreshed image replaces it. api.selfServe.dynamicImageDelayMs = [750, 3_000, 700]; const answeredSummary = api.selfServe.answerResponseByKey["7:AB12345:11:true"]; answeredSummary.allowed_services = ["PROGRAM_PICKER", "MACHINE"]; answeredSummary.tasks = [ { ...answeredSummary.tasks[0], condition_id: null, gate_type: "ALWAYS", gate_ref_id: null, services: ["PROGRAM_PICKER"], buttons: [], dynamic_images_vehicle_type: 4, }, { id: 9002, task: "Machine access", description: "Enable the wash machine relay.", order_priority: 2, services: ["MACHINE"], condition_id: null, gate_type: "ALWAYS", gate_ref_id: null, buttons: [0, 2, "start", 5], dynamic_images_vehicle_type: 3, attachments: [], }, ]; api.selfServe.previewByKey["7:AB12345"].questions = answeredSummary.questions; api.selfServe.previewByKey["7:AB12345"].tasks = answeredSummary.tasks; api.selfServe.summaryBySessionId[501] = answeredSummary; api.selfServe.summaryByKey["7:AB12345"] = answeredSummary; await primeSession(page, { token: "self-serve-tasks-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start", { waitUntil: "domcontentloaded" }); await expect(page.getByTestId("self-serve-tasks-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-dynamic-image-skeleton")).toBeHidden({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-dynamic-image")).toBeVisible(); const dynamicImageFrame = page.getByTestId("self-serve-dynamic-image-frame"); const loadedDynamicImageFrameBox = await roundedBoundingBox(dynamicImageFrame); await expect(page.getByTestId("self-serve-task-9002")).toBeVisible(); await expect(page.getByTestId("self-serve-task-9002")).toContainText("Machine access"); await expect(page.getByTestId("self-serve-task-9002")).not.toContainText("#3 Machine access"); await expect .poll(() => JSON.parse(requests.dynamicImages.at(-1)?.url.searchParams.get("buttons") || "[]")) .toEqual(["program_picker", 0, 2, "start", 5]); const initialDynamicImageUrl = requests.dynamicImages.at(-1).url; expect(JSON.parse(initialDynamicImageUrl.searchParams.get("buttons"))).toEqual([ "program_picker", 0, 2, "start", 5, ]); expect(initialDynamicImageUrl.searchParams.get("current_step")).toBe("0"); expect(initialDynamicImageUrl.searchParams.get("thumb_position")).toBe("4"); await expect(page.getByTestId("self-serve-task-9001-attachment-302")).toBeVisible(); await expect(page.getByTestId("self-serve-task-9001")).toContainText("Prepare the truck #4"); await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled(); await page.getByTestId("self-serve-task-9001-toggle").click(); await expect(page.getByTestId("self-serve-dynamic-image-skeleton")).toBeVisible({ timeout: 2_000 }); await expect.poll(() => roundedBoundingBox(dynamicImageFrame)).toEqual(loadedDynamicImageFrameBox); await expect(page.getByTestId("self-serve-dynamic-image-skeleton")).toBeHidden({ timeout: 10_000 }); await expect.poll(() => requests.dynamicImages.at(-1)?.url.searchParams.get("current_step")).toBe("1"); expect(JSON.parse(requests.dynamicImages.at(-1).url.searchParams.get("buttons"))).toEqual([ "program_picker", 0, 2, "start", 5, ]); await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled(); await page.getByTestId("self-serve-task-9002-toggle").click(); await expect.poll(() => requests.dynamicImages.at(-1)?.url.searchParams.get("current_step")).toBe("5"); expect(JSON.parse(requests.dynamicImages.at(-1).url.searchParams.get("buttons"))).toEqual([ "program_picker", 0, 2, "start", 5, ]); await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled(); await page.getByTestId("self-serve-nav-confirm").click(); await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible(); await seedSavedProgress(page, { washInProgress: true, washLaneId: 7, washStartTime: Date.now() - 45_000, currentStep: 4, licensePlateInput: "AB12345", vehicleTypeSelect: 2, radioLaneOption: 7, customerNumberInput: "12345679", }); await page.reload({ waitUntil: "domcontentloaded" }); await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 }); }); test("machine start keeps task instructions after a stale post-start summary", async ({ page }) => { const requests = captureSelfServeGatewayRequests(page); const api = await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: true, }); const answeredSummary = api.selfServe.answerResponseByKey["7:AB12345:11:true"]; const machineTasks = [ { ...answeredSummary.tasks[0], services: ["MACHINE"], buttons: [1], dynamic_images_vehicle_type: 2, }, { id: 9002, task: "Machine access", description: "Enable the wash machine relay.", order_priority: 2, services: ["MACHINE"], condition_id: null, gate_type: "ALWAYS", gate_ref_id: null, buttons: [2, "start"], dynamic_images_vehicle_type: 3, attachments: [], }, ]; const activeMachineSummary = { ...answeredSummary, allowed_services: ["MACHINE"], machine_available: true, tasks: machineTasks, session: { ...answeredSummary.session, status: "IN_PROGRESS", allowed: true, }, }; api.selfServe.answerResponseByKey["7:AB12345:11:true"] = activeMachineSummary; api.selfServe.summaryBySessionId[501] = activeMachineSummary; api.selfServe.summaryByKey["7:AB12345"] = { ...activeMachineSummary, allowed_services: [], tasks: [], }; await primeSession(page, { token: "self-serve-machine-start-stale-summary-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await fillRegistration(page, "ab12345"); await selectVehicleType(page, 2); await page.getByTestId("self-serve-nav-next").click(); await expect(page.getByTestId("self-serve-question-11")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("self-serve-question-11-yes").click(); await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 }); await page.getByTestId("self-serve-nav-confirm").click(); await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("self-serve-lane-option-7").click(); await page.getByTestId("self-serve-wash-type-machine").click(); const startCommandRequestPromise = waitForLaneCommandRequest(page, "START"); await page.getByTestId("self-serve-nav-confirm").click(); const startCommandRequest = await startCommandRequestPromise; expect(startCommandRequest.postDataJSON?.()).toMatchObject({ lane_id: 7, command: "START", wash_type: "Machine", }); await expect .poll(() => requests.summaries.some( (entry) => entry.url.searchParams.get("lane_id") === "7" && entry.url.searchParams.get("reg") === "AB12345" ) ) .toBe(true); await expect(page.getByTestId("self-serve-tasks-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-task-9002")).toBeVisible(); await expect(page.getByTestId("self-serve-tasks-step")).not.toContainText("Spørgsmål besvaret"); }); test("manual wash hides machine tasks when the machine service is allowed", async ({ page }) => { await seedSavedProgress(page, { washInProgress: true, washLaneId: 7, washStartTime: Date.now() - 30_000, currentStep: 3, licensePlateInput: "AB12345", vehicleTypeSelect: 2, radioLaneOption: 7, radioWashType: "Manual", customerNumberInput: "12345679", completedTasks: {}, }); const api = await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: true, }); const answeredSummary = api.selfServe.answerResponseByKey["7:AB12345:11:true"]; answeredSummary.tasks = [ ...answeredSummary.tasks, { id: 9004, task: "Press reset", description: "Dynamic image button task without the MACHINE service.", order_priority: 4, services: [], buttons: ["reset"], dynamic_images_vehicle_type: 2, }, { id: 9005, task: "Tryk reset", description: "Legacy machine button task without structured button metadata.", order_priority: 5, services: [], buttons: [], dynamic_images_vehicle_type: null, }, ]; api.selfServe.previewByKey["7:AB12345"].questions = answeredSummary.questions; api.selfServe.previewByKey["7:AB12345"].tasks = answeredSummary.tasks; api.selfServe.summaryBySessionId[501] = answeredSummary; api.selfServe.summaryByKey["7:AB12345"] = answeredSummary; await primeSession(page, { token: "self-serve-manual-task-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-tasks-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-dynamic-image")).toBeHidden(); await expect(page.getByTestId("self-serve-task-9001")).toBeHidden(); await expect(page.getByTestId("self-serve-task-9002")).toBeHidden(); await expect(page.getByTestId("self-serve-task-9004")).toBeHidden(); await expect(page.getByTestId("self-serve-task-9005")).toBeHidden(); await expect(page.getByText("Press reset")).toBeHidden(); await expect(page.getByText("Tryk reset")).toBeHidden(); await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled(); }); test("machine-unavailable question result hides dynamic image button tasks", async ({ page }) => { await seedSavedProgress(page, { washInProgress: true, washLaneId: 7, washStartTime: Date.now() - 30_000, currentStep: 3, licensePlateInput: "AB12345", vehicleTypeSelect: 2, radioLaneOption: 7, radioWashType: "Machine", customerNumberInput: "12345679", completedTasks: {}, }); const api = await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: true, }); const buttonTask = { id: 9004, task: "Press reset", description: "Dynamic image button task without the MACHINE service.", order_priority: 4, services: [], buttons: ["reset"], dynamic_images_vehicle_type: 2, }; const legacyButtonTask = { id: 9005, task: "Tryk reset", description: "Legacy machine button task without structured button metadata.", order_priority: 5, services: [], buttons: [], dynamic_images_vehicle_type: null, }; const blockedSummary = { ...api.selfServe.answerResponseByKey["7:AB12345:11:true"], session: { ...api.selfServe.answerResponseByKey["7:AB12345:11:true"].session, allowed: false, status: "MACHINE_NOT_ALLOWED", }, questions: [ { id: 11, question: "Is the tarp removed?", description: "Required before machine wash.", answer: false, order_priority: 1, }, ], tasks: [buttonTask, legacyButtonTask], }; api.selfServe.previewByKey["7:AB12345"] = { ...api.selfServe.previewByKey["7:AB12345"], allowed: false, allowed_services: [], questions: blockedSummary.questions, tasks: blockedSummary.tasks, }; api.selfServe.summaryBySessionId[501] = blockedSummary; api.selfServe.summaryByKey["7:AB12345"] = blockedSummary; await primeSession(page, { token: "self-serve-machine-blocked-task-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-tasks-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-dynamic-image")).toBeHidden(); await expect(page.getByTestId("self-serve-task-9004")).toBeHidden(); await expect(page.getByTestId("self-serve-task-9005")).toBeHidden(); await expect(page.getByText("Press reset")).toBeHidden(); await expect(page.getByText("Tryk reset")).toBeHidden(); await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled(); }); test("in-progress flow opens property access and exit gates via lane command endpoint", async ({ page }) => { await seedSavedProgress(page, { washInProgress: true, washLaneId: 7, washStartTime: Date.now() - 30_000, currentStep: 4, licensePlateInput: "AB12345", vehicleTypeSelect: 2, radioLaneOption: 7, customerNumberInput: "12345679", }); await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: true, }); await primeSession(page, { token: "self-serve-property-gates-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 }); const accessCommandRequestPromise = waitForLaneCommandRequest(page, "OPEN_PROPERTY_ACCESS_GATE"); await page.getByTestId("self-serve-nav-open-property-access-gate").click(); const accessCommandRequest = await accessCommandRequestPromise; expect(accessCommandRequest.postDataJSON?.()).toMatchObject({ lane_id: 7, command: "OPEN_PROPERTY_ACCESS_GATE", }); const exitCommandRequestPromise = waitForLaneCommandRequest(page, "OPEN_PROPERTY_EXIT_GATE"); await page.getByTestId("self-serve-nav-open-property-exit-gate").click(); const exitCommandRequest = await exitCommandRequestPromise; expect(exitCommandRequest.postDataJSON?.()).toMatchObject({ lane_id: 7, command: "OPEN_PROPERTY_EXIT_GATE", }); await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible(); }); test("mobile layout keeps primary controls usable", async ({ page }, testInfo) => { test.skip(!testInfo.project.name.includes("mobile"), "Mobile sanity runs on the mobile project only."); await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, selfServe: true, }); await primeSession(page, { token: "self-serve-mobile-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-start-page")).toBeVisible({ timeout: 45_000 }); const selfServeSteps = page.getByTestId("self-serve-steps"); await expect(selfServeSteps.locator(".steps.mobile-compact")).toBeVisible(); await expect.poll(async () => selfServeSteps.locator(".step-item:visible").count()).toBeGreaterThanOrEqual(4); await fillRegistration(page, "ab12345"); await selectVehicleType(page, 2); await expect(page.getByTestId("self-serve-nav-next")).toBeVisible(); await expect(page.getByTestId("self-serve-vehicle-selector-description")).toContainText("Truck"); await page.getByTestId("self-serve-nav-next").click(); await expect(page.getByTestId("self-serve-question-11")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("self-serve-question-11-yes").click(); await expect(page.getByTestId("self-serve-nav-confirm")).toBeEnabled({ timeout: 10_000 }); await page.getByTestId("self-serve-nav-confirm").click(); await expect(page.getByTestId("self-serve-lane-step")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("self-serve-lane-option-7").click(); await page.getByTestId("self-serve-wash-type-manual").click(); const prewashActions = page.getByTestId("self-serve-prewash-bottom-actions"); const prewashNavigation = prewashActions.getByTestId("self-serve-prewash-navigation-actions"); await expect(prewashActions).toBeVisible(); await expect(prewashActions).toHaveCSS("position", "fixed"); await expect(prewashNavigation.getByTestId("self-serve-nav-previous")).toBeVisible(); await expect(prewashNavigation.getByTestId("self-serve-nav-confirm")).toBeVisible(); await expect(prewashNavigation.getByTestId("self-serve-nav-confirm")).toBeEnabled(); const footerBox = await page.locator(".fixed-bottom-footer").boundingBox(); const actionsBox = await prewashActions.boundingBox(); expect(footerBox).not.toBeNull(); expect(actionsBox).not.toBeNull(); expect(actionsBox.y + actionsBox.height).toBeLessThanOrEqual(footerBox.y + 2); await prewashNavigation.getByTestId("self-serve-nav-previous").click(); await expect(page.getByTestId("self-serve-questions-step")).toBeVisible(); await page.getByTestId("self-serve-nav-confirm").click(); await expect(page.getByTestId("self-serve-lane-step")).toBeVisible(); }); test("mocked polling keeps active wash local until the customer completes when backend reports it ended", async ({ page, }) => { await seedSavedProgress(page, { washInProgress: true, washLaneId: 7, washStartTime: Date.now() - 30_000, currentStep: 4, licensePlateInput: "AB12345", vehicleTypeSelect: 2, radioLaneOption: 7, radioWashType: "Manual", customerNumberInput: "12345679", }); await mockApi(page, { authenticated: true, permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, sessionData: { customer_number: 12345679, }, selfServe: { inProgressByLaneId: { 7: { lane_id: 7, in_progress: false, session: { id: 801, lane_id: 7, reg: "AB12345", customer_number: 12345679, vehicle_type_id: 2, status: "COMPLETED", }, customer: { customer_number: 12345679 }, vehicle: { reg: "AB12345", type: 2 }, }, }, }, }); await primeSession(page, { token: "self-serve-polling-completed-token", permissions: CUSTOMER_SELF_SERVE_PERMISSIONS, }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-bottom-actions")).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId("self-serve-completed-step")).toBeHidden(); await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible(); await expect .poll(async () => page.evaluate(() => window.localStorage.getItem("mywash_progress_v6"))) .not.toBeNull(); await advanceGuidedWashToLastStep(page); const stopCommandRequestPromise = waitForLaneCommandRequest(page, "STOP"); await page.getByTestId("self-serve-nav-complete").click(); await stopCommandRequestPromise; await expect(page.getByTestId("self-serve-completed-step")).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId("self-serve-live-elapsed")).toBeHidden(); await expect.poll(async () => page.evaluate(() => window.localStorage.getItem("mywash_progress_v6"))).toBeNull(); }); test("admin preview modal reuses shared question/task rendering", async ({ page }) => { await mockApi(page, { authenticated: true, permissions: ["admin", "department_access_6"], selfServe: true, }); await primeSession(page, { token: "self-serve-admin-token", permissions: ["admin", "department_access_6"], }); await page.goto("/admin/6/modules/wash-lanes/7"); await expect(page.getByTestId("department-wash-lane-page")).toBeVisible({ timeout: 10_000 }); await page.getByTestId("self-serve-admin-open-try-modal").first().click(); await expect(page.getByTestId("self-serve-try-modal")).toBeVisible(); await page.getByTestId("self-serve-try-reg").fill("ab12345"); await page.getByTestId("self-serve-try-refresh").click(); await expect(page.getByTestId("self-serve-question-11")).toBeVisible(); await page.getByTestId("self-serve-question-11-yes").click(); await expect(page.getByText("Prepare the truck")).toBeVisible(); await expect(page.getByText("SESSION_SYNCED")).toBeVisible(); const answeredQuestion = page.locator("li", { hasText: "Is the tarp removed?:" }).last(); await expect(answeredQuestion).toBeVisible(); await expect(answeredQuestion.locator("strong")).toHaveText("Ja"); }); });