Files
pleno-vue/tests/e2e/self-serve-wash.spec.js
T
Jeppe B 1729443cc3 Resolve frontend Qodana critical and high findings (#176)
Resolve recommended-profile Critical and High findings, update vulnerable dependencies, restore invoice queue E2E authentication setup, and clear the remaining frontend Qodana findings.
2026-07-17 06:22:51 +02:00

2089 lines
76 KiB
JavaScript

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,
});
api.selfServe.dynamicImageDelayMs = [750, 700, 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");
});
});