* Allow hosted runners for manual frontend tests * Fix hosted full E2E i18n failures * Stabilize release assignment E2E selection * Gate full E2E on focused regressions --------- Co-authored-by: Jeppe Bundgaard <jb@truckwash.dk>
2080 lines
75 KiB
JavaScript
2080 lines
75 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.";
|
|
|
|
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 seedAuthenticatedState(page, token);
|
|
}
|
|
|
|
async function mockSelectedSubuserGrant(page, { customerNumber = 12345679, permissions = ["SELFSERVE_LIST"] } = {}) {
|
|
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: ["user"],
|
|
sessionData: {
|
|
customer_number: 12345679,
|
|
},
|
|
selfServe: true,
|
|
});
|
|
api.selfServe.commandResponseDelayMs = [500, 0];
|
|
await primeSession(page, {
|
|
token: "self-serve-user-token",
|
|
permissions: ["user"],
|
|
});
|
|
|
|
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: ["user"],
|
|
sessionData: {
|
|
customer_number: 12345679,
|
|
},
|
|
selfServe: true,
|
|
});
|
|
await primeSession(page, {
|
|
token: "self-serve-denied-geolocation-token",
|
|
permissions: ["user"],
|
|
});
|
|
|
|
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: ["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);
|
|
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: ["user"],
|
|
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: ["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-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: ["user"],
|
|
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: ["user"],
|
|
});
|
|
|
|
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: ["user"],
|
|
selfServe: {
|
|
previewResponseDelayMs: 1000,
|
|
summaryResponseDelayMs: 1000,
|
|
},
|
|
});
|
|
await primeSession(page, {
|
|
token: "self-serve-next-loading-token",
|
|
permissions: ["user"],
|
|
});
|
|
|
|
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: ["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-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: ["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("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: ["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: 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: ["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")).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: ["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: 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: ["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")).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: ["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,
|
|
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: ["user"],
|
|
sessionData: {
|
|
customer_number: null,
|
|
},
|
|
selfServe: true,
|
|
});
|
|
await mockSelectedSubuserGrant(page);
|
|
await primeSession(page, {
|
|
token: "self-serve-subuser-grant-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,
|
|
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: ["user"],
|
|
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: ["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 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: ["user"],
|
|
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: ["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-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: ["user"],
|
|
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: ["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 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: ["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 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: ["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("question cards keep their position when answer refreshes return reordered questions", async ({ page }) => {
|
|
const api = await mockApi(page, {
|
|
authenticated: true,
|
|
permissions: ["user"],
|
|
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: ["user"],
|
|
});
|
|
|
|
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: ["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("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: ["user"],
|
|
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: ["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-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: ["user"],
|
|
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: ["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")).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: ["user"],
|
|
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: ["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-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: ["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({ 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: ["user"],
|
|
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: ["user"],
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|