import { expect, test } from "@playwright/test"; import { mockApi, primeMockSession } from "./support/network.js"; 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, permissions, sessionData = {} }) { await primeMockSession(page, { token }); } 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(); } } function waitForLaneCommandRequest(page, command) { return page.waitForRequest((request) => { if (!request.url().includes("/modules/self-serve/lane/command")) { return false; } if (request.method() !== "POST") { return false; } const body = request.postDataJSON?.() || {}; return body.command === command; }); } 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); } 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; } 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, }) => { await mockApi(page, { authenticated: true, permissions: ["user"], selfServe: true, }); await primeSession(page, { token: "self-serve-user-token", permissions: ["user"], }); await page.goto("/user/wash"); await expect(page.getByTestId("self-serve-wash-home")).toBeVisible(); await expect(page.getByTestId("self-serve-home-nearest-name")).toContainText("Roskilde"); const orderedDepartmentIds = await page .locator('[data-testid^="self-serve-home-department-"]') .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid"))); expect(orderedDepartmentIds).toEqual(["self-serve-home-department-2", "self-serve-home-department-3"]); await page.getByTestId("self-serve-home-start").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); 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-live-elapsed")).toBeVisible({ timeout: 10_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 expect(page.getByTestId("self-serve-finishing-wash")).toContainText( "Afslutter vask, porten åbnes automatisk" ); 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(); 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 resumes the authenticated customer's active server wash from another device", async ({ page }) => { const requests = captureSelfServeGatewayRequests(page); await mockApi(page, { authenticated: true, permissions: ["user"], 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: ["user"], }); 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); await expect(page.getByText("6 / 6")).toBeVisible(); 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("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: ["user"], selfServe: true, }); await primeSession(page, { token: "self-serve-direct-token", permissions: ["user"], }); 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-lane-step")).toBeVisible({ timeout: 10_000 }); await expect(page.getByRole("radio", { name: /Maskine/i })).toBeDisabled(); await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled(); 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: ["user"], 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: ["user"], }); 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("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: ["user"], sessionData: { customer_number: 12345679, runtime_config: { economic: { transaction_draft_customer_number: 777, }, }, }, selfServe: true, }); await primeSession(page, { token: "self-serve-customer-number-token", permissions: ["user"], }); 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, defer_relay_side_effects: true, }); await page.waitForTimeout(250); expect(orderFilters.some((filters) => filters.includes("customer_id:777"))).toBe(false); }); 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", }); await mockApi(page, { authenticated: true, permissions: ["user"], sessionData: { customer_number: 12345679, }, selfServe: { commandResponses: [ { success: false, data: { message: "Edge gateway command timed out", }, }, { success: true }, ], }, }); await primeSession(page, { token: "self-serve-start-retry-token", permissions: ["user"], }); 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 keeps the session in 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: ["user"], selfServe: { commandResponses: [ { success: false, data: { message: "Stop command failed", }, }, ], }, }); await primeSession(page, { token: "self-serve-stop-failure-token", permissions: ["user"], }); 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-action-error")).toContainText("Stop command failed"); await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible(); await expect(page.getByTestId("self-serve-completed-step")).toBeHidden(); await expect(page.getByTestId("self-serve-nav-complete")).toBeVisible(); }); 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: ["user"], selfServe: { 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: ["user"], }); 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 expect(page.getByTestId("self-serve-finishing-wash")).toContainText( "Afslutter vask, porten åbnes automatisk" ); 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(); 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: ["user"], selfServe: true, }); await primeSession(page, { token: "self-serve-edge-readonly-token", permissions: ["user"], }); 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 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: ["user"], 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: ["user"], }); 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("allowed-services edge gateway timeout stays on questions and can be retried", async ({ page }) => { const requests = captureSelfServeGatewayRequests(page); await mockApi(page, { authenticated: true, permissions: ["user"], 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: ["user"], }); 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("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: ["user"], selfServe: true, }); api.selfServe.dynamicImageDelayMs = [750]; 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: ["user"], }); 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")).toBeVisible(); await expect(page.getByTestId("self-serve-dynamic-image-skeleton")).toBeHidden({ timeout: 10_000 }); await expect(page.getByTestId("self-serve-dynamic-image")).toBeVisible(); 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("#4 Prepare the truck"); await expect(page.getByTestId("self-serve-nav-confirm")).toBeDisabled(); await page.getByTestId("self-serve-task-9001-toggle").click(); 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("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: ["user"], 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: ["user"], }); 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: ["user"], 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: ["user"], }); 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: ["user"], selfServe: true, }); await primeSession(page, { token: "self-serve-property-gates-token", permissions: ["user"], }); 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: ["user"], selfServe: true, }); await primeSession(page, { token: "self-serve-mobile-token", permissions: ["user"], }); await page.goto("/user/wash/start"); await expect(page.getByTestId("self-serve-start-page")).toBeVisible(); 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"); }); 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"); }); });