Fix frontend smoke test races

This commit is contained in:
Jeppe Bundgaard
2026-05-11 03:15:56 +02:00
parent 87ad8f9d31
commit c9b34ba36c
7 changed files with 176 additions and 8 deletions
+10 -1
View File
@@ -165,8 +165,17 @@ export function useWashProgress(options) {
}
const restoredStep = savedProgress.currentStep;
const isSameRestoredProgress = () => (
Boolean(washInProgress.value) === Boolean(savedProgress.washInProgress) &&
String(washLaneId.value ?? "") === String(savedProgress.washLaneId ?? "") &&
String(washStartTime.value ?? "") === String(savedProgress.washStartTime ?? "") &&
String(licensePlateInput.value ?? "") === String(savedProgress.licensePlateInput ?? "") &&
String(vehicleTypeSelect.value ?? "") === String(savedProgress.vehicleTypeSelect ?? "") &&
String(radioLaneOption.value ?? "") === String(savedProgress.radioLaneOption ?? "")
);
setTimeout(() => {
if (currentStep.value !== restoredStep) {
if (isSameRestoredProgress() && currentStep.value !== restoredStep) {
currentStep.value = restoredStep;
}
isRestoring.value = false;
@@ -35,6 +35,35 @@ const selectedJob = ref(null);
let pollTimer = null;
let loadPromise = null;
let isMounted = false;
let pendingLoadAfterCurrent = false;
let pendingLoadSilent = true;
let pendingLoadIsPoll = true;
const queuePendingLoad = ({ silent = false, isPoll = false } = {}) => {
pendingLoadAfterCurrent = true;
pendingLoadSilent = pendingLoadSilent && silent;
pendingLoadIsPoll = pendingLoadIsPoll && isPoll;
};
const takePendingLoad = () => {
if (!pendingLoadAfterCurrent || !isMounted) {
pendingLoadAfterCurrent = false;
pendingLoadSilent = true;
pendingLoadIsPoll = true;
return null;
}
const pendingLoad = {
silent: pendingLoadSilent,
isPoll: pendingLoadIsPoll,
};
pendingLoadAfterCurrent = false;
pendingLoadSilent = true;
pendingLoadIsPoll = true;
return pendingLoad;
};
const toPositiveInt = (value, fallback = null) => {
const parsed = Number.parseInt(String(value), 10);
@@ -357,6 +386,7 @@ const startPolling = () => {
const loadQueueHistory = async ({ silent = false, isPoll = false } = {}) => {
if (loadPromise) {
queuePendingLoad({ silent, isPoll });
return loadPromise;
}
@@ -409,6 +439,11 @@ const loadQueueHistory = async ({ silent = false, isPoll = false } = {}) => {
loading.value = false;
refreshing.value = false;
loadPromise = null;
const pendingLoad = takePendingLoad();
if (pendingLoad) {
void loadQueueHistory(pendingLoad);
}
}
})();
@@ -886,7 +886,7 @@ watch(() => licensePlateInput.value, (newValue, oldValue) => {
});
watch(() => currentStep.value, (newStep, oldStep) => {
if (isRestoring.value && newStep === 0 && oldStep !== 0 && typeof oldStep !== "undefined") {
if (isRestoring.value && washInProgress.value && newStep === steps.VEHICLE && oldStep !== steps.VEHICLE && typeof oldStep !== "undefined") {
currentStep.value = oldStep;
return;
}
+9 -5
View File
@@ -1,21 +1,25 @@
import { expect, test } from "@playwright/test";
import { mockApi } from "./support/network.js";
const AUTH_ENTRY_TIMEOUT = 60_000;
test.describe("Auth entry smoke", () => {
test.describe.configure({ timeout: 90_000 });
test("@smoke protected routes redirect to login without token", async ({ page }) => {
await mockApi(page);
await page.goto("/user");
await expect(page).toHaveURL(/\/login/);
await expect(page.getByTestId("login-submit")).toBeVisible();
await expect(page).toHaveURL(/\/login/, { timeout: AUTH_ENTRY_TIMEOUT });
await expect(page.getByTestId("login-submit")).toBeVisible({ timeout: AUTH_ENTRY_TIMEOUT });
});
test("@smoke login route renders credential controls", async ({ page }) => {
await mockApi(page, { authenticated: false });
await page.goto("/login");
await expect(page.getByTestId("login-customer-number")).toBeVisible();
await expect(page.getByTestId("login-password")).toBeVisible();
await expect(page.getByTestId("login-submit")).toBeVisible();
await expect(page.getByTestId("login-customer-number")).toBeVisible({ timeout: AUTH_ENTRY_TIMEOUT });
await expect(page.getByTestId("login-password")).toBeVisible({ timeout: AUTH_ENTRY_TIMEOUT });
await expect(page.getByTestId("login-submit")).toBeVisible({ timeout: AUTH_ENTRY_TIMEOUT });
});
test("@smoke qr login route renders without QR bootstrap import errors", async ({ page }) => {
+2 -1
View File
@@ -227,7 +227,8 @@ test.describe("Self-serve wash", () => {
await expect(page.getByTestId("self-serve-completed-step")).toBeVisible();
await page.getByTestId("self-serve-nav-close").click();
await expect(page.getByTestId("self-serve-completed-step")).toBeHidden();
await expect(page.getByTestId("self-serve-nav-complete")).toBeVisible();
await expect(page.getByTestId("self-serve-vehicle-step")).toBeVisible();
await expect(page.getByTestId("self-serve-nav-complete")).toBeHidden();
});
test("start route resumes the authenticated customer's active server wash from another device", async ({ page }) => {
@@ -39,8 +39,10 @@ vi.mock("vue-i18n", async (importOriginal) => {
vi.mock("@/components/session/token/SessionUser.vue", () => ({
SessionUser: {
canAccessAdmin: () => true,
canAccessAssignedDepartment: (departmentId) => [1, 2].includes(Number(departmentId)),
canAccessDepartment: (departmentId) => [1, 2].includes(Number(departmentId)),
functions: {
getAccessibleDepartments: () => [1, 2],
getDepartmentIdFromUrl: () => 1,
date: {
isToday: () => false,
+117
View File
@@ -0,0 +1,117 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { ref } from "vue";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useWashProgress } from "@/composables/useWashProgress.js";
const buildSavedProgress = (overrides = {}) => ({
washInProgress: true,
washLaneId: 7,
washStartTime: Date.now() - 30_000,
currentStep: 4,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioWashType: "Manual",
radioLaneOption: 7,
customerNumberInput: "12345679",
answers: {},
completedTasks: {},
nearestDepartmentId: 6,
forceNearestDepartmentEvaluationId: 0,
savedAt: Date.now(),
isForcingNearestDepartment: false,
...overrides,
});
const createProgress = () => {
const state = {
currentStep: ref(0),
washInProgress: ref(false),
washLaneId: ref(null),
washStartTime: ref(null),
licensePlateInput: ref(null),
vehicleTypeSelect: ref(null),
radioWashType: ref("Manual"),
radioLaneOption: ref("Any"),
customerNumberInput: ref(null),
answers: ref({}),
completedTasks: ref({}),
forceNearestDepartmentEvaluationId: ref(0),
isForcingNearestDepartment: ref(false),
};
let progress = null;
const wrapper = mount({
setup() {
progress = useWashProgress({
...state,
nearestDepartmentId: () => 6,
applyRestoredState: (savedProgress) => {
state.washInProgress.value = savedProgress.washInProgress;
state.washLaneId.value = savedProgress.washLaneId;
state.washStartTime.value = savedProgress.washStartTime;
state.currentStep.value = savedProgress.currentStep;
state.licensePlateInput.value = savedProgress.licensePlateInput;
state.vehicleTypeSelect.value = savedProgress.vehicleTypeSelect;
state.radioWashType.value = savedProgress.radioWashType;
state.radioLaneOption.value = savedProgress.radioLaneOption;
state.customerNumberInput.value = savedProgress.customerNumberInput;
state.answers.value = savedProgress.answers;
state.completedTasks.value = savedProgress.completedTasks;
state.forceNearestDepartmentEvaluationId.value = savedProgress.forceNearestDepartmentEvaluationId;
state.isForcingNearestDepartment.value = savedProgress.isForcingNearestDepartment;
},
});
return {};
},
template: "<div />",
});
return { progress, state, wrapper };
};
describe("useWashProgress", () => {
const wrappers = [];
afterEach(() => {
wrappers.splice(0).forEach((wrapper) => wrapper.unmount());
localStorage.clear();
vi.useRealTimers();
});
it("does not reapply a restored step after the restored wash state changes", () => {
vi.useFakeTimers();
localStorage.setItem("mywash_progress_v6", JSON.stringify(buildSavedProgress()));
const { progress, state, wrapper } = createProgress();
wrappers.push(wrapper);
progress.restoreProgress();
state.washInProgress.value = false;
state.washLaneId.value = null;
state.washStartTime.value = null;
state.licensePlateInput.value = "";
state.currentStep.value = 0;
vi.advanceTimersByTime(1500);
expect(state.currentStep.value).toBe(0);
expect(progress.isRestoring.value).toBe(false);
});
it("reapplies the restored step while the restored wash state is still current", () => {
vi.useFakeTimers();
localStorage.setItem("mywash_progress_v6", JSON.stringify(buildSavedProgress()));
const { progress, state, wrapper } = createProgress();
wrappers.push(wrapper);
progress.restoreProgress();
state.currentStep.value = 0;
vi.advanceTimersByTime(1500);
expect(state.currentStep.value).toBe(4);
expect(progress.isRestoring.value).toBe(false);
});
});