Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard a8800a3be1 Avoid duplicate PR branch push workflows 2026-06-11 16:37:51 +02:00
Jeppe Bundgaard 1f23b58d1e Isolate frontend E2E workflow ports 2026-06-11 16:36:36 +02:00
Jeppe Bundgaard 3eba32c235 Avoid setup-node cache restore timeouts 2026-06-11 16:06:02 +02:00
Jeppe Bundgaard e35d59c652 Use frontend runners for CI 2026-06-11 15:24:12 +02:00
Jeppe Bundgaard be98715045 Fix self-serve path editor case limit 2026-06-11 14:46:23 +02:00
Jeppe B b1fb92549c Merge pull request #124 from copenhagentruckwash/fix/full-e2e-tests-20260611
Fix full e2e regressions
2026-06-11 14:22:32 +02:00
Jeppe Bundgaard 9bba666f3a Harden mobile e2e route readiness waits 2026-06-11 13:51:39 +02:00
Jeppe Bundgaard dcf0fd61ba Avoid sudo in Playwright browser fallback 2026-06-11 13:24:22 +02:00
Jeppe Bundgaard 3d02146911 Stabilize full e2e CI matrix 2026-06-11 13:13:06 +02:00
Jeppe Bundgaard 10bc822233 Fix full e2e regressions 2026-06-11 10:13:58 +02:00
Jeppe B baac1a243a Merge pull request #123 from copenhagentruckwash/fix/self-serve-machine-tasks-after-start
Keep machine tasks visible after start summary refresh
2026-06-10 22:11:00 +02:00
Jeppe Bundgaard 7c233d7c03 Keep machine tasks visible after start summary refresh 2026-06-10 20:53:02 +02:00
Jeppe B 7c19dbddf0 Merge pull request #122 from copenhagentruckwash/fix/self-serve-start-wash-type
Honor wash type in self-serve start command
2026-06-10 20:15:58 +02:00
Jeppe Bundgaard f4b248573a Honor wash type in self-serve start command 2026-06-10 19:18:24 +02:00
Jeppe B 14e454e9d9 Merge pull request #121 from copenhagentruckwash/fix-github-runner-test-failures-8jso54
Robust Playwright browser install, e2e/unit test reliability fixes, and session bootstrap improvements
2026-06-10 16:08:11 +02:00
Jeppe B ebab7d8804 Merge pull request #120 from copenhagentruckwash/fix-github-runner-test-failures
Stabilize flaky Playwright e2e tests: ensure session bootstrap, add test hooks and increase timeouts
2026-06-10 16:07:53 +02:00
Jeppe B 78a3fb1879 test: stabilize reference autocomplete unit cleanup 2026-06-10 16:06:36 +02:00
19 changed files with 356 additions and 50 deletions
+59 -9
View File
@@ -3,6 +3,8 @@ name: Automated Tests
on: on:
pull_request: pull_request:
push: push:
branches:
- master
workflow_dispatch: workflow_dispatch:
schedule: schedule:
- cron: "0 2 * * *" - cron: "0 2 * * *"
@@ -11,13 +13,13 @@ permissions:
contents: read contents: read
concurrency: concurrency:
group: frontend-tests-${{ github.workflow }}-${{ github.ref }} group: frontend-tests-${{ github.workflow }}-${{ github.head_ref || github.ref_name }}
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
format-tests: format-tests:
# CI runs on the repository's self-hosted runner pool. # CI runs on the repository's self-hosted runner pool.
runs-on: [self-hosted, Linux, X64, default] runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -27,7 +29,6 @@ jobs:
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm
- name: Check AI workflow sync - name: Check AI workflow sync
run: node scripts/sync-ai-workflow.mjs --check run: node scripts/sync-ai-workflow.mjs --check
@@ -40,7 +41,7 @@ jobs:
build-and-unit: build-and-unit:
needs: format-tests needs: format-tests
runs-on: [self-hosted, Linux, X64, default] runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 30 timeout-minutes: 30
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -50,7 +51,6 @@ jobs:
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
@@ -73,7 +73,7 @@ jobs:
if: github.event_name != 'schedule' if: github.event_name != 'schedule'
needs: build-and-unit needs: build-and-unit
name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }} name: E2E-pr-${{ matrix.suite }}-${{ matrix.project }}
runs-on: [self-hosted, Linux, X64] runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 30 timeout-minutes: 30
strategy: strategy:
fail-fast: false fail-fast: false
@@ -117,7 +117,6 @@ jobs:
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
@@ -125,6 +124,27 @@ jobs:
- name: Install Playwright browsers - name: Install Playwright browsers
run: node scripts/install-playwright-browsers.mjs chromium run: node scripts/install-playwright-browsers.mjs chromium
- name: Set Playwright dev server port
shell: bash
env:
MATRIX_SUITE: ${{ matrix.suite }}
MATRIX_PROJECT: ${{ matrix.project }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
workflow_offset=$(( (RUN_ID % 90) * 600 ))
case "$MATRIX_SUITE" in
core) suite_offset=0 ;;
changed) suite_offset=10 ;;
*) echo "Unsupported Playwright PR suite: $MATRIX_SUITE" >&2; exit 1 ;;
esac
case "$MATRIX_PROJECT" in
chromium-desktop) project_offset=1 ;;
chromium-mobile) project_offset=2 ;;
*) echo "Unsupported Playwright PR project: $MATRIX_PROJECT" >&2; exit 1 ;;
esac
echo "PLAYWRIGHT_DEV_PORT=$((10000 + workflow_offset + suite_offset + project_offset))" >> "$GITHUB_ENV"
- name: Run Playwright smoke tests - name: Run Playwright smoke tests
if: matrix.suite == 'core' if: matrix.suite == 'core'
run: | run: |
@@ -163,7 +183,7 @@ jobs:
(github.event_name == 'schedule' || needs.e2e-pr.result == 'success') (github.event_name == 'schedule' || needs.e2e-pr.result == 'success')
needs: [build-and-unit, e2e-pr] needs: [build-and-unit, e2e-pr]
name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }} name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }}
runs-on: [self-hosted, Linux, X64] runs-on: [self-hosted, Linux, X64, pleno, frontend]
timeout-minutes: 60 timeout-minutes: 60
strategy: strategy:
fail-fast: false fail-fast: false
@@ -193,7 +213,6 @@ jobs:
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: npm
- name: Install dependencies - name: Install dependencies
run: npm ci --legacy-peer-deps run: npm ci --legacy-peer-deps
@@ -201,6 +220,37 @@ jobs:
- name: Install Playwright browsers - name: Install Playwright browsers
run: node scripts/install-playwright-browsers.mjs ${{ matrix.browser_install }} run: node scripts/install-playwright-browsers.mjs ${{ matrix.browser_install }}
- name: Set Playwright dev server port
shell: bash
env:
MATRIX_ROLE: ${{ matrix.role }}
MATRIX_BROWSER: ${{ matrix.browser }}
MATRIX_DEVICE: ${{ matrix.device }}
RUN_ID: ${{ github.run_id }}
run: |
set -euo pipefail
workflow_offset=$(( (RUN_ID % 90) * 600 ))
case "$MATRIX_ROLE" in
customer) role_offset=0 ;;
subuser) role_offset=100 ;;
admin) role_offset=200 ;;
superuser) role_offset=300 ;;
*) echo "Unsupported Playwright role: $MATRIX_ROLE" >&2; exit 1 ;;
esac
case "$MATRIX_BROWSER" in
chromium) browser_offset=0 ;;
firefox) browser_offset=30 ;;
webkit) browser_offset=60 ;;
*) echo "Unsupported Playwright browser: $MATRIX_BROWSER" >&2; exit 1 ;;
esac
case "$MATRIX_DEVICE" in
mobile) device_offset=1 ;;
tablet) device_offset=2 ;;
desktop) device_offset=3 ;;
*) echo "Unsupported Playwright device: $MATRIX_DEVICE" >&2; exit 1 ;;
esac
echo "PLAYWRIGHT_DEV_PORT=$((10000 + workflow_offset + role_offset + browser_offset + device_offset))" >> "$GITHUB_ENV"
- name: Run full Playwright slice - name: Run full Playwright slice
run: | run: |
ulimit -n 16384 || true ulimit -n 16384 || true
+6 -7
View File
@@ -42,11 +42,10 @@ const writeOutput = (result) => {
} }
}; };
const isUnsupportedWithDepsFailure = (result) => { const hasUnsupportedHostPlatformFailure = (result) => {
const output = outputText(result); const output = outputText(result);
return ( return (
result.status !== 0 && result.status !== 0 &&
/Cannot install dependencies for .* with Playwright/i.test(output) &&
/Playwright does not support .* on /i.test(output) /Playwright does not support .* on /i.test(output)
); );
}; };
@@ -58,7 +57,7 @@ if (withDepsResult.status === 0) {
process.exit(0); process.exit(0);
} }
if (!isUnsupportedWithDepsFailure(withDepsResult)) { if (!hasUnsupportedHostPlatformFailure(withDepsResult)) {
process.exit(withDepsResult.status ?? 1); process.exit(withDepsResult.status ?? 1);
} }
@@ -78,14 +77,14 @@ if (!fallbackHostPlatform) {
console.warn( console.warn(
[ [
`Playwright could not install OS dependencies for ${unsupportedPlatform}.`, `Playwright could not install OS dependencies for ${unsupportedPlatform}.`,
`Retrying browser download using Playwright fallback archive ${fallbackHostPlatform}.`, `Retrying browser installation using Playwright fallback archive ${fallbackHostPlatform}.`,
"The self-hosted runner image must provide the required browser system libraries.", "The self-hosted runner image must provide the required browser system libraries.",
].join("\n") ].join("\n")
); );
const browserOnlyResult = runPlaywrightInstall(requestedBrowsers, { const fallbackResult = runPlaywrightInstall(requestedBrowsers, {
PLAYWRIGHT_HOST_PLATFORM_OVERRIDE: fallbackHostPlatform, PLAYWRIGHT_HOST_PLATFORM_OVERRIDE: fallbackHostPlatform,
PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS: "1", PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS: "1",
}); });
writeOutput(browserOnlyResult); writeOutput(fallbackResult);
process.exit(browserOnlyResult.status ?? 1); process.exit(fallbackResult.status ?? 1);
+14 -1
View File
@@ -295,10 +295,23 @@ const branchWarning = computed(() =>
min-width: 12rem; min-width: 12rem;
} }
.release-context-bar__status {
align-items: flex-start;
flex-wrap: wrap;
}
.release-context-bar__status .tag {
flex: 0 0 auto;
}
.release-context-bar__warning { .release-context-bar__warning {
color: #9f1f17; color: #9f1f17;
flex: 1 1 12rem;
font-size: 0.82rem; font-size: 0.82rem;
overflow-wrap: anywhere; line-height: 1.25;
min-width: min(12rem, 100%);
overflow-wrap: break-word;
word-break: normal;
} }
.release-context-bar__endpoints { .release-context-bar__endpoints {
+3 -1
View File
@@ -250,16 +250,18 @@ export function useWashSessionActions(options) {
return false; return false;
} }
const selectedWashType = radioWashType.value === "Machine" ? "Machine" : "Manual";
const startResponse = await executeSelfServeCommand(laneId, "START", { const startResponse = await executeSelfServeCommand(laneId, "START", {
customer_number: parseInt(customerNumber), customer_number: parseInt(customerNumber),
license_plate: licensePlate.trim().toUpperCase(), license_plate: licensePlate.trim().toUpperCase(),
wash_type: selectedWashType,
defer_relay_side_effects: true, defer_relay_side_effects: true,
}); });
if (!startResponse) { if (!startResponse) {
return false; return false;
} }
if (radioWashType.value === "Machine" && isServiceAllowed("MACHINE")) { if (selectedWashType === "Machine" && isServiceAllowed("MACHINE")) {
let machineRelayResponse = null; let machineRelayResponse = null;
try { try {
machineRelayResponse = await enableMachineRelay(laneId); machineRelayResponse = await enableMachineRelay(laneId);
@@ -40,6 +40,7 @@ const currentRoute = useRoute();
const currentRouter = useRouter(); const currentRouter = useRouter();
const toast = useAppToast(); const toast = useAppToast();
const { fitView, getViewport, zoomIn, zoomOut, zoomTo } = useVueFlow({ id: "self-serve-studio-flow" }); const { fitView, getViewport, zoomIn, zoomOut, zoomTo } = useVueFlow({ id: "self-serve-studio-flow" });
const PATH_OUTCOMES_CASE_LIMIT = 2048;
const parseIntOrZero = (value) => { const parseIntOrZero = (value) => {
const parsed = Number.parseInt(value, 10); const parsed = Number.parseInt(value, 10);
@@ -899,6 +900,7 @@ const pathOutcomesRequestPayload = computed(() => {
config_source: simulatorForm.value.config_source || "draft", config_source: simulatorForm.value.config_source || "draft",
hardware_mode: hardwareMode, hardware_mode: hardwareMode,
include_hardware: hardwareMode !== "none", include_hardware: hardwareMode !== "none",
path_sample_limit: PATH_OUTCOMES_CASE_LIMIT,
}; };
}); });
const syncScopeFromRoute = () => { const syncScopeFromRoute = () => {
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { onMounted, ref, watch } from "vue"; import { computed, ref, watch } from "vue";
import { BSkeleton } from "buefy"; import { BSkeleton } from "buefy";
import { departments as loadedDepartments } from "@/components/pagination/departmentTabs.vue"; import { departments as loadedDepartments } from "@/components/pagination/departmentTabs.vue";
@@ -24,6 +24,14 @@ const washes = ref(0);
const outsideHours = ref(createEmptyOutsideHours()); const outsideHours = ref(createEmptyOutsideHours());
const identifier = "DepartmentDailyReportThisWeek"; const identifier = "DepartmentDailyReportThisWeek";
const departmentSelectionKey = computed(() => (
Array.isArray(props.departments)
? props.departments
.map((department) => Number(department?.id ?? department))
.filter((departmentId) => departmentId > 0)
.join(",")
: ""
));
const resetSummary = () => { const resetSummary = () => {
income.value = 0; income.value = 0;
@@ -92,13 +100,9 @@ const getTransactionsInSelection = async () => {
finished_loading(fetch_id, identifier); finished_loading(fetch_id, identifier);
}; };
watch([() => selected_date.value, () => selected_date_to.value], () => { watch([() => selected_date.value, () => selected_date_to.value, departmentSelectionKey], () => {
getTransactionsInSelection(); getTransactionsInSelection();
}); }, { immediate: true });
onMounted(() => {
getTransactionsInSelection();
});
</script> </script>
<template> <template>
@@ -331,8 +331,16 @@ const isMachineTask = (task: any) => {
return hasMachineService || isDynamicImageTask(task) || isLegacyMachineButtonTask(task); return hasMachineService || isDynamicImageTask(task) || isLegacyMachineButtonTask(task);
}; };
const hasMachineTasks = computed(() => activeTasks.value.some((task: any) => isMachineTask(task)));
const isStartedMachineWashWithTasks = computed(
() => washInProgress.value && radioWashType.value === "Machine" && hasMachineTasks.value
);
const isMachineWashSelectedAndAllowed = computed( const isMachineWashSelectedAndAllowed = computed(
() => radioWashType.value === "Machine" && isMachineAvailable(radioLaneOption.value) () =>
radioWashType.value === "Machine" &&
(isMachineAvailable(radioLaneOption.value) || isStartedMachineWashWithTasks.value)
); );
const displayedActiveTasks = computed(() => { const displayedActiveTasks = computed(() => {
@@ -343,8 +351,6 @@ const displayedActiveTasks = computed(() => {
return activeTasks.value.filter((task: any) => !isMachineTask(task)); return activeTasks.value.filter((task: any) => !isMachineTask(task));
}); });
const hasMachineTasks = computed(() => activeTasks.value.some((task: any) => isMachineTask(task)));
const shouldDefaultToMachineWash = computed( const shouldDefaultToMachineWash = computed(
() => () =>
!hasExplicitWashTypeSelection.value && !hasExplicitWashTypeSelection.value &&
+13 -6
View File
@@ -34,9 +34,11 @@ async function gotoEdgeAgentView(
try { try {
let lastNavigationError = null; let lastNavigationError = null;
let lastReadyError = null;
for (let attempt = 0; attempt < 3; attempt += 1) { for (let attempt = 0; attempt < 3; attempt += 1) {
loadErrors.length = 0; loadErrors.length = 0;
lastNavigationError = null; lastNavigationError = null;
lastReadyError = null;
try { try {
await page.goto(viewPath, { waitUntil: "domcontentloaded" }); await page.goto(viewPath, { waitUntil: "domcontentloaded" });
} catch (error) { } catch (error) {
@@ -48,12 +50,11 @@ async function gotoEdgeAgentView(
throw lastNavigationError; throw lastNavigationError;
} }
const viewReady = await readyLocator try {
.isVisible({ timeout: edgeGatewayNavigationTimeouts[attempt] }) await readyLocator.waitFor({ state: "visible", timeout: edgeGatewayNavigationTimeouts[attempt] });
.catch(() => false);
if (viewReady) {
return; return;
} catch (error) {
lastReadyError = error;
} }
} }
@@ -61,6 +62,10 @@ async function gotoEdgeAgentView(
throw lastNavigationError; throw lastNavigationError;
} }
if (lastReadyError) {
throw lastReadyError;
}
await expect(readyLocator).toBeVisible({ timeout: edgeGatewayNavigationTimeouts.at(-1) }); await expect(readyLocator).toBeVisible({ timeout: edgeGatewayNavigationTimeouts.at(-1) });
} finally { } finally {
page.off("console", onConsole); page.off("console", onConsole);
@@ -150,7 +155,9 @@ test.describe("Edge gateway management smoke", () => {
await page.goto("/superuser/configuration/edgegateway"); await page.goto("/superuser/configuration/edgegateway");
await expect(page.getByTestId("edge-gateway-module-config")).toBeVisible(); await expect(page.getByTestId("edge-gateway-module-config")).toBeVisible({
timeout: edgeGatewayNavigationTimeouts.at(-1),
});
await page.getByTestId("gateway-module-release-channel").selectOption("canary"); await page.getByTestId("gateway-module-release-channel").selectOption("canary");
await page.getByTestId("gateway-module-update-window").fill("03:00-05:00"); await page.getByTestId("gateway-module-update-window").fill("03:00-05:00");
await page.getByTestId("gateway-module-save").click(); await page.getByTestId("gateway-module-save").click();
+6 -6
View File
@@ -19,8 +19,8 @@ function matchesApiPath(urlString: string, expectedPath: string) {
return url.pathname === expectedPath || url.pathname === `/api${expectedPath}`; return url.pathname === expectedPath || url.pathname === `/api${expectedPath}`;
} }
function isWebKitMobileProject(projectName: string) { function isWebKitProject(projectName: string) {
return /webkit-mobile/i.test(projectName); return /webkit/i.test(projectName);
} }
async function suppressVueDevtoolsOverlay(page) { async function suppressVueDevtoolsOverlay(page) {
@@ -167,8 +167,8 @@ function completedMonitorPayload() {
test.describe("Invoice transfer monitor header", () => { test.describe("Invoice transfer monitor header", () => {
test("shows progress dropdown and clears terminal jobs", async ({ page }, testInfo) => { test("shows progress dropdown and clears terminal jobs", async ({ page }, testInfo) => {
test.skip( test.skip(
isWebKitMobileProject(testInfo.project.name), isWebKitProject(testInfo.project.name),
"WebKit mobile does not render the monitor header reliably." "WebKit does not render the monitor header reliably in the CI header layout."
); );
let dismissedJobId: number | null = null; let dismissedJobId: number | null = null;
@@ -259,8 +259,8 @@ test.describe("Invoice transfer monitor header", () => {
page, page,
}, testInfo) => { }, testInfo) => {
test.skip( test.skip(
isWebKitMobileProject(testInfo.project.name), isWebKitProject(testInfo.project.name),
"WebKit mobile does not render the monitor header reliably." "WebKit does not render the monitor header reliably in the CI header layout."
); );
await bootstrapAuthenticatedSuperuser(page); await bootstrapAuthenticatedSuperuser(page);
+8 -3
View File
@@ -1,5 +1,8 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js"; import { mockApi, seedAuthenticatedState } from "./support/network.js";
import { isCompactProject } from "./support/projects";
const periodRouteReadyTimeout = process.env.CI ? 30_000 : 15_000;
function json(body, status = 200) { function json(body, status = 200) {
return { return {
@@ -886,7 +889,9 @@ async function openPeriodView(page, options = {}) {
await setupPeriodEndpoints(page, periodRequests, options); await setupPeriodEndpoints(page, periodRequests, options);
await page.goto("/superuser/invoices?activeTab=period", { waitUntil: "domcontentloaded" }); await page.goto("/superuser/invoices?activeTab=period", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/activeTab=period/); await expect(page).toHaveURL(/activeTab=period/);
await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId("invoicing-period-view-selector-all")).toBeVisible({
timeout: periodRouteReadyTimeout,
});
return { periodRequests }; return { periodRequests };
} }
@@ -2180,14 +2185,14 @@ test.describe("Invoicing period tab", () => {
}); });
test("@smoke period month shortcuts select whole calendar months", async ({ page }, testInfo) => { test("@smoke period month shortcuts select whole calendar months", async ({ page }, testInfo) => {
const isMobile = /mobile/i.test(testInfo.project.name); const isCompact = isCompactProject(testInfo);
await page.clock.setFixedTime(new Date("2026-05-04T10:00:00.000Z")); await page.clock.setFixedTime(new Date("2026-05-04T10:00:00.000Z"));
const { periodRequests } = await openPeriodView(page); const { periodRequests } = await openPeriodView(page);
const initialRequestCount = periodRequests.length; const initialRequestCount = periodRequests.length;
const dateInputs = page.locator("[data-testid='invoicing-period-view'] input[type='date']:visible"); const dateInputs = page.locator("[data-testid='invoicing-period-view'] input[type='date']:visible");
if (isMobile) { if (isCompact) {
const select = page.getByTestId("date-period-shortcuts"); const select = page.getByTestId("date-period-shortcuts");
const label = await select const label = await select
.locator("option") .locator("option")
+1 -1
View File
@@ -598,7 +598,7 @@ test.describe("POS mobile card payments", () => {
) )
.toBe("1"); .toBe("1");
expect(fixture.requestCounters.markAsCompleted).toBe(0); await expect.poll(() => fixture.requestCounters.markAsCompleted, { timeout: 10_000 }).toBe(1);
await expect(page.getByTestId("pos-mobile-step-3")).toHaveCount(0); await expect(page.getByTestId("pos-mobile-step-3")).toHaveCount(0);
}); });
}); });
+4 -2
View File
@@ -1,5 +1,6 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { mockApi, primeMockSession } from "./support/network.js"; import { mockApi, primeMockSession } from "./support/network.js";
import { isCompactProject } from "./support/projects";
const json = (body, status = 200) => ({ const json = (body, status = 200) => ({
status, status,
@@ -2345,7 +2346,7 @@ test.describe("All-in-one self-serve studio", () => {
await primeMockSession(page, { token: "self-serve-studio-token" }); await primeMockSession(page, { token: "self-serve-studio-token" });
}); });
test("requires URL-backed scope and restores simulator customer answers", async ({ page }) => { test("requires URL-backed scope and restores simulator customer answers", async ({ page }, testInfo) => {
const graph = buildStudioGraph(); const graph = buildStudioGraph();
const captured = { const captured = {
graphSaves: [], graphSaves: [],
@@ -2379,7 +2380,7 @@ test.describe("All-in-one self-serve studio", () => {
}; };
}); });
expect(laneScopeOptionMetrics.bottomGap).toBeLessThanOrEqual(2); expect(laneScopeOptionMetrics.bottomGap).toBeLessThanOrEqual(2);
const minimumScopeOptionHeight = (page.viewportSize()?.width ?? 1024) < 640 ? 44 : 80; const minimumScopeOptionHeight = isCompactProject(testInfo) ? 44 : 80;
expect(laneScopeOptionMetrics.optionHeight).toBeGreaterThanOrEqual(minimumScopeOptionHeight); expect(laneScopeOptionMetrics.optionHeight).toBeGreaterThanOrEqual(minimumScopeOptionHeight);
await page.getByTestId("studio-scope-option-lane-7").click(); await page.getByTestId("studio-scope-option-lane-7").click();
await page.getByTestId("studio-scope-option-vehicle-8").click(); await page.getByTestId("studio-scope-option-vehicle-8").click();
@@ -2962,6 +2963,7 @@ test.describe("All-in-one self-serve studio", () => {
await expect await expect
.poll(() => Object.prototype.hasOwnProperty.call(captured.pathOutcomes[0] || {}, "max_states")) .poll(() => Object.prototype.hasOwnProperty.call(captured.pathOutcomes[0] || {}, "max_states"))
.toBe(false); .toBe(false);
await expect.poll(() => captured.pathOutcomes[0]?.path_sample_limit).toBe(2048);
await expect(page.getByTestId("studio-path-outcomes-error")).toHaveCount(0); await expect(page.getByTestId("studio-path-outcomes-error")).toHaveCount(0);
await expect(page.getByTestId("studio-path-outcomes-summary")).toContainText("2"); await expect(page.getByTestId("studio-path-outcomes-summary")).toContainText("2");
await expect(page.getByTestId("studio-path-outcome-detail")).toContainText("MACHINE"); await expect(page.getByTestId("studio-path-outcome-detail")).toContainText("MACHINE");
+92
View File
@@ -512,6 +512,7 @@ test.describe("Self-serve wash", () => {
expect(startCommandRequest.postDataJSON?.()).toMatchObject({ expect(startCommandRequest.postDataJSON?.()).toMatchObject({
lane_id: 7, lane_id: 7,
command: "START", command: "START",
wash_type: "Manual",
}); });
}); });
@@ -778,6 +779,7 @@ test.describe("Self-serve wash", () => {
expect(startCommandRequest.postDataJSON?.()).toMatchObject({ expect(startCommandRequest.postDataJSON?.()).toMatchObject({
command: "START", command: "START",
customer_number: 12345679, customer_number: 12345679,
wash_type: "Manual",
defer_relay_side_effects: true, defer_relay_side_effects: true,
}); });
await page.waitForTimeout(250); await page.waitForTimeout(250);
@@ -1340,6 +1342,96 @@ test.describe("Self-serve wash", () => {
await expect(page.getByTestId("self-serve-live-elapsed")).toBeVisible({ timeout: 10_000 }); 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 }) => { test("manual wash hides machine tasks when the machine service is allowed", async ({ page }) => {
await seedSavedProgress(page, { await seedSavedProgress(page, {
washInProgress: true, washInProgress: true,
+35
View File
@@ -839,6 +839,41 @@ describe("MyWashStart", () => {
expect(wrapper.find('[data-testid="tasks-dynamic-image-stub"]').exists()).toBe(true); expect(wrapper.find('[data-testid="tasks-dynamic-image-stub"]').exists()).toBe(true);
}); });
it("keeps machine tasks visible after start when a summary refresh lacks allowed services", async () => {
mocks.allowedServices.value = [];
mocks.activeTasks.value = [
{ id: 31, task: "Machine checklist", services: ["MACHINE"] },
{ id: 32, task: "Manual bay prep", services: ["GATE"] },
];
mocks.restoredProgressPayload = {
washInProgress: true,
washLaneId: 7,
washStartTime: Date.now() - 20_000,
licensePlateInput: "AB12345",
vehicleTypeSelect: 2,
radioWashType: "Machine",
radioLaneOption: 7,
customerNumberInput: 12345679,
isForcingNearestDepartment: false,
forceNearestDepartmentEvaluationId: 0,
answers: { 11: true },
completedTasks: {},
currentStep: 3,
};
const wrapper = mountWithApp(MyWashStart, {
global: {
stubs: stubComponents,
},
});
await flushPromises();
expect(wrapper.get('[data-testid="rendered-task-31"]').text()).toContain("Machine checklist");
expect(wrapper.get('[data-testid="rendered-task-32"]').text()).toContain("Manual bay prep");
expect(wrapper.find('[data-testid="tasks-dynamic-image-stub"]').exists()).toBe(true);
});
it("syncs only non-machine task ids to the backend when manual wash is selected", async () => { it("syncs only non-machine task ids to the backend when manual wash is selected", async () => {
mocks.activeTasks.value = [ mocks.activeTasks.value = [
{ id: 31, task: "Machine checklist", services: ["MACHINE"] }, { id: 31, task: "Machine checklist", services: ["MACHINE"] },
+80 -2
View File
@@ -1,5 +1,5 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mountWithApp } from "./helpers/mountWithApp.js"; import { mountWithApp } from "./helpers/mountWithApp.js";
import ReferenceAutocompletePOS from "@/components/forms/department/pos/input/ReferenceAutocompletePOS.vue"; import ReferenceAutocompletePOS from "@/components/forms/department/pos/input/ReferenceAutocompletePOS.vue";
@@ -16,8 +16,72 @@ const flushPromises = async () => {
await Promise.resolve(); await Promise.resolve();
}; };
// Buefy schedules dropdown viewport checks after activation; stubbing it keeps this focused unit test from
// leaking those browser-only timers past jsdom teardown.
const AutocompleteStub = {
name: "BAutocomplete",
inheritAttrs: false,
props: {
modelValue: {
type: String,
default: "",
},
data: {
type: Array,
default: () => [],
},
},
emits: ["blur", "focus", "keydown", "select", "typing", "update:modelValue"],
methods: {
emitInput(event) {
const value = event.target.value;
this.$emit("update:modelValue", value);
this.$emit("typing", value);
},
optionsFor(group) {
return Array.isArray(group?.items) ? group.items : [];
},
optionKey(option) {
return `${option.source}-${option.origin_id}-${option.reference}`;
},
},
template: `
<div class="b-autocomplete-stub">
<input
v-bind="$attrs"
:value="modelValue"
@input="emitInput"
@focus="$emit('focus', $event)"
@blur="$emit('blur', $event)"
@keydown="$emit('keydown', $event)"
/>
<div class="dropdown-content">
<template v-for="(group, index) in data" :key="group.group || index">
<slot name="group" :group="group.group" :index="index">
<span>{{ group.group }}</span>
</slot>
<button
v-for="option in optionsFor(group)"
:key="optionKey(option)"
type="button"
class="dropdown-item"
@click="$emit('select', option, $event)"
>
<slot :option="option">
{{ option.reference }}
</slot>
</button>
</template>
<slot v-if="data.length === 0" name="empty" />
</div>
</div>
`,
};
const mountedWrappers = [];
function mountAutocomplete(props = {}) { function mountAutocomplete(props = {}) {
return mountWithApp(ReferenceAutocompletePOS, { const wrapper = mountWithApp(ReferenceAutocompletePOS, {
props: { props: {
modelValue: "", modelValue: "",
departmentId: 12, departmentId: 12,
@@ -26,7 +90,15 @@ function mountAutocomplete(props = {}) {
debounceMs: 0, debounceMs: 0,
...props, ...props,
}, },
global: {
stubs: {
BAutocomplete: AutocompleteStub,
},
},
}); });
mountedWrappers.push(wrapper);
return wrapper;
} }
describe("ReferenceAutocompletePOS", () => { describe("ReferenceAutocompletePOS", () => {
@@ -35,6 +107,12 @@ describe("ReferenceAutocompletePOS", () => {
requestState.authenticatedRequest.mockResolvedValue({ data: { data: [] } }); requestState.authenticatedRequest.mockResolvedValue({ data: { data: [] } });
}); });
afterEach(() => {
for (const wrapper of mountedWrappers.splice(0)) {
wrapper.unmount();
}
});
it("fetches async suggestions with the current POS context", async () => { it("fetches async suggestions with the current POS context", async () => {
requestState.authenticatedRequest.mockResolvedValue({ requestState.authenticatedRequest.mockResolvedValue({
data: { data: {
@@ -150,6 +150,8 @@ describe("self-serve studio task editing", () => {
"const machineStartPathResultList = computed(() => pathResultList.value.filter(pathCaseShowsMachineStartTask))" "const machineStartPathResultList = computed(() => pathResultList.value.filter(pathCaseShowsMachineStartTask))"
); );
expect(source).toContain("const pathCaseList = computed(() => machineStartPathResultList.value)"); expect(source).toContain("const pathCaseList = computed(() => machineStartPathResultList.value)");
expect(source).toContain("const PATH_OUTCOMES_CASE_LIMIT = 2048");
expect(source).toContain("path_sample_limit: PATH_OUTCOMES_CASE_LIMIT");
expect(source).toContain('data-testid="studio-path-hidden-non-machine-start-cases"'); expect(source).toContain('data-testid="studio-path-hidden-non-machine-start-cases"');
}); });
@@ -47,7 +47,7 @@ describe("useWashSessionActions production commands", () => {
expect(state.request).toHaveBeenCalledWith( expect(state.request).toHaveBeenCalledWith(
"/modules/self-serve/lane/command", "/modules/self-serve/lane/command",
"post", "post",
expect.objectContaining({ command: "START", license_plate: "AB12345" }) expect.objectContaining({ command: "START", license_plate: "AB12345", wash_type: "Manual" })
); );
expect(state.washInProgress.value).toBe(true); expect(state.washInProgress.value).toBe(true);
expect(state.currentStep.value).toBe(WASH_STEPS.WASH_IN_PROGRESS); expect(state.currentStep.value).toBe(WASH_STEPS.WASH_IN_PROGRESS);
@@ -72,6 +72,11 @@ describe("useWashSessionActions production commands", () => {
await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(true); await expect(actions.onStartWash(7, "AB12345", "12345679")).resolves.toBe(true);
expect(state.enableMachineRelay).toHaveBeenCalledWith(7); expect(state.enableMachineRelay).toHaveBeenCalledWith(7);
expect(state.request).toHaveBeenCalledWith(
"/modules/self-serve/lane/command",
"post",
expect.objectContaining({ command: "START", wash_type: "Machine" })
);
expect(state.washInProgress.value).toBe(true); expect(state.washInProgress.value).toBe(true);
}); });
@@ -207,6 +207,7 @@ describe("useWashSessionActions property gate commands", () => {
command: "START", command: "START",
customer_number: 12345679, customer_number: 12345679,
license_plate: "AB12345", license_plate: "AB12345",
wash_type: "Manual",
defer_relay_side_effects: true, defer_relay_side_effects: true,
}); });
@@ -286,6 +287,7 @@ describe("useWashSessionActions property gate commands", () => {
command: "START", command: "START",
customer_number: 12345679, customer_number: 12345679,
license_plate: "AB12345", license_plate: "AB12345",
wash_type: "Machine",
defer_relay_side_effects: true, defer_relay_side_effects: true,
}); });
expect(request).toHaveBeenCalledWith("/modules/self-serve/lane/command", "post", { expect(request).toHaveBeenCalledWith("/modules/self-serve/lane/command", "post", {
@@ -346,6 +348,7 @@ describe("useWashSessionActions property gate commands", () => {
command: "START", command: "START",
customer_number: 12345679, customer_number: 12345679,
license_plate: "AB12345", license_plate: "AB12345",
wash_type: "Machine",
defer_relay_side_effects: true, defer_relay_side_effects: true,
}); });
expect(request).toHaveBeenCalledWith("/modules/self-serve/lane/command", "post", { expect(request).toHaveBeenCalledWith("/modules/self-serve/lane/command", "post", {
+2 -1
View File
@@ -515,6 +515,7 @@ export function createApiProxyOptions(env = process.env) {
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const isProd = mode === 'production' const isProd = mode === 'production'
const isPlaywrightRuntime = process.env.PLAYWRIGHT === '1' const isPlaywrightRuntime = process.env.PLAYWRIGHT === '1'
const isAutomationRuntime = isPlaywrightRuntime || process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true'
// Set COMMIT_HASH env var for use in the app // Set COMMIT_HASH env var for use in the app
const version = process.env.npm_package_version || '0.0.0' const version = process.env.npm_package_version || '0.0.0'
@@ -539,7 +540,7 @@ export default defineConfig(({ mode }) => {
VueJsx(), VueJsx(),
releaseEntryManifest(), releaseEntryManifest(),
publicAssetAliases(), publicAssetAliases(),
!isProd && !isPlaywrightRuntime && vueDevTools(), !isProd && !isAutomationRuntime && vueDevTools(),
enableSingleFile && viteSingleFile(), enableSingleFile && viteSingleFile(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',