diff --git a/package.json b/package.json index 2e8b49f3..afbcafd0 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,8 @@ "preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173", "test:unit": "vitest run", "test:e2e": "playwright test", - "test:e2e:ci": "playwright test --reporter=line,html", + "test:e2e:ci": "node scripts/run-playwright-ci-parallel.mjs", + "test:e2e:ci:serial": "playwright test --reporter=line,html", "test:e2e:smoke": "playwright test --grep @smoke --project=chromium-desktop --project=chromium-mobile", "test:e2e:prod": "playwright test --config=playwright.prod.config.ts", "test:e2e:live": "playwright test --config=playwright.live.config.ts", diff --git a/playwright.config.ts b/playwright.config.ts index e7a31266..c26b50b3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,8 +1,27 @@ +import path from "node:path"; import { defineConfig, devices } from "@playwright/test"; const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173); const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${devPort}`; const isCI = !!process.env.CI; +const artifactNamespace = (process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "").trim(); +const artifactRoot = artifactNamespace + ? path.join("output", "playwright", artifactNamespace) + : path.join("output", "playwright"); +const htmlReportOutputFolder = path.join(artifactRoot, "report"); +const configuredWorkers = Number(process.env.PLAYWRIGHT_WORKERS || 2); +const workers = Number.isFinite(configuredWorkers) && configuredWorkers > 0 ? configuredWorkers : 2; +const reporterMode = (process.env.PLAYWRIGHT_REPORTER_MODE || "").trim(); +const reporter = + reporterMode === "line-html" + ? [ + ["line"], + ["html", { open: "never", outputFolder: htmlReportOutputFolder }], + ] + : [ + ["list"], + ["html", { open: "never", outputFolder: htmlReportOutputFolder }], + ]; export default defineConfig({ testDir: "./tests/e2e", @@ -11,18 +30,15 @@ export default defineConfig({ fullyParallel: true, forbidOnly: isCI, retries: isCI ? 2 : 0, - workers: 2, + workers, ...(process.env.PLAYWRIGHT_BASE_URL ? {} : { globalSetup: "./playwright.global-setup.mjs", globalTeardown: "./playwright.global-teardown.mjs", }), - reporter: [ - ["list"], - ["html", { open: "never", outputFolder: "output/playwright/report" }] - ], - outputDir: "output/playwright/test-results", + reporter, + outputDir: path.join(artifactRoot, "test-results"), use: { baseURL, trace: "retain-on-failure", diff --git a/playwright.global-setup.mjs b/playwright.global-setup.mjs index 8dafb725..ec5252d0 100644 --- a/playwright.global-setup.mjs +++ b/playwright.global-setup.mjs @@ -6,7 +6,10 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173); const baseURL = `http://localhost:${devPort}`; -const pidFile = path.resolve(process.cwd(), "output/playwright/dev-server.json"); +const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `port-${devPort}`) + .trim() + .replace(/[^a-zA-Z0-9._-]+/g, "-"); +const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`); async function getListeningProcessOnWindows(port) { const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], { @@ -124,6 +127,10 @@ export default async function globalSetup() { { cwd: process.cwd(), detached: true, + env: { + ...process.env, + PLAYWRIGHT: "1", + }, stdio: "ignore", windowsHide: true, } @@ -131,6 +138,10 @@ export default async function globalSetup() { : spawn("npm", ["run", "dev", "--", "--host", "localhost", "--port", String(devPort), "--strictPort"], { cwd: process.cwd(), detached: true, + env: { + ...process.env, + PLAYWRIGHT: "1", + }, stdio: "ignore", }); diff --git a/playwright.global-teardown.mjs b/playwright.global-teardown.mjs index 3db013ab..eea93a5f 100644 --- a/playwright.global-teardown.mjs +++ b/playwright.global-teardown.mjs @@ -4,7 +4,11 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); -const pidFile = path.resolve(process.cwd(), "output/playwright/dev-server.json"); +const devPort = Number(process.env.PLAYWRIGHT_DEV_PORT || 5173); +const runtimeNamespace = String(process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || `port-${devPort}`) + .trim() + .replace(/[^a-zA-Z0-9._-]+/g, "-"); +const pidFile = path.resolve(process.cwd(), "output/playwright", `dev-server-${runtimeNamespace}.json`); async function killProcessTree(pid) { if (!pid) { diff --git a/scripts/run-playwright-ci-parallel.mjs b/scripts/run-playwright-ci-parallel.mjs new file mode 100644 index 00000000..088e479e --- /dev/null +++ b/scripts/run-playwright-ci-parallel.mjs @@ -0,0 +1,232 @@ +import { execFile, spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import readline from "node:readline"; +import { promisify } from "node:util"; + +const forwardedArgs = process.argv.slice(2); +const workingDirectory = process.cwd(); +const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js"); +const basePort = normalizePositiveInt(process.env.PLAYWRIGHT_PARALLEL_BASE_PORT, 5191); +const perProcessWorkers = normalizePositiveInt(process.env.PLAYWRIGHT_PARALLEL_WORKERS, 1); +const reportIndexDirectory = path.join(workingDirectory, "output", "playwright", "ci-parallel-report"); +const execFileAsync = promisify(execFile); +const activeChildren = new Set(); +let isShuttingDown = false; + +const groups = [ + { + name: "chromium", + projects: ["chromium-desktop", "chromium-mobile"], + }, + { + name: "firefox", + projects: ["firefox-desktop"], + }, + { + name: "webkit", + projects: ["webkit-desktop", "webkit-mobile"], + }, +]; + +function normalizePositiveInt(value, fallback) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function getArtifactNamespace(group) { + return `ci-parallel-${group.name}`; +} + +function getDevServerPidFile(artifactNamespace) { + return path.join(workingDirectory, "output", "playwright", `dev-server-${artifactNamespace}.json`); +} + +async function killProcessTree(pid) { + if (!pid) { + return; + } + + if (process.platform === "win32") { + await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"]).catch(() => {}); + return; + } + + try { + process.kill(-pid, "SIGTERM"); + } catch { + try { + process.kill(pid, "SIGTERM"); + } catch { + // ignore + } + } +} + +async function cleanupDevServerArtifacts(artifactNamespace) { + const pidFile = getDevServerPidFile(artifactNamespace); + + try { + const file = await fs.readFile(pidFile, "utf8"); + const { pid } = JSON.parse(file); + await killProcessTree(pid); + } catch { + // ignore missing pid files or already-exited processes + } + + await fs.rm(pidFile, { force: true }).catch(() => {}); +} + +function prefixStream(stream, prefix) { + const lineReader = readline.createInterface({ input: stream }); + lineReader.on("line", (line) => { + process.stdout.write(`[${prefix}] ${line}\n`); + }); +} + +function spawnGroup(group, index) { + const devPort = basePort + index; + const artifactNamespace = getArtifactNamespace(group); + const args = [ + "test", + ...group.projects.flatMap((project) => ["--project", project]), + ...forwardedArgs, + ]; + + const child = spawn(process.execPath, [playwrightCliPath, ...args], { + cwd: workingDirectory, + env: { + ...process.env, + PLAYWRIGHT_BASE_URL: "", + PLAYWRIGHT_DEV_PORT: String(devPort), + PLAYWRIGHT_WORKERS: String(perProcessWorkers), + PLAYWRIGHT_ARTIFACT_NAMESPACE: artifactNamespace, + PLAYWRIGHT_REPORTER_MODE: "line-html", + PLAYWRIGHT: "1", + }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + activeChildren.add(child); + + prefixStream(child.stdout, group.name); + prefixStream(child.stderr, `${group.name}:err`); + + return new Promise((resolve) => { + child.on("close", (code) => { + activeChildren.delete(child); + resolve({ + name: group.name, + code: code ?? 1, + artifactNamespace, + projects: group.projects, + port: devPort, + }); + }); + }); +} + +async function writeCombinedReportIndex(results) { + await fs.rm(reportIndexDirectory, { recursive: true, force: true }); + await fs.mkdir(reportIndexDirectory, { recursive: true }); + + const rows = results + .map((result) => { + const status = result.code === 0 ? "passed" : "failed"; + const statusColor = result.code === 0 ? "#166534" : "#991b1b"; + const reportHref = `../${result.artifactNamespace}/report/index.html`; + + return ` + + ${result.name} + ${result.projects.join(", ")} + ${result.port} + ${status} + Open report + `; + }) + .join("\n"); + + const html = ` + + + + Playwright Parallel Reports + + + +

Playwright Parallel Reports

+

Each child run used its own Vite port, dev-server pid file, and artifact directory.

+ + + + + + + + + + + +${rows} + +
GroupProjectsPortStatusReport
+ +`; + + await fs.writeFile(path.join(reportIndexDirectory, "index.html"), html, "utf8"); +} + +async function shutdown(signal) { + if (isShuttingDown) { + return; + } + + isShuttingDown = true; + process.stderr.write(`Received ${signal}. Stopping parallel Playwright children...\n`); + + await Promise.all([...activeChildren].map((child) => killProcessTree(child.pid))); + await Promise.all(groups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group)))); +} + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => { + void shutdown(signal).finally(() => { + process.exit(130); + }); + }); +} + +async function main() { + console.log( + `Starting Playwright CI in parallel with ${groups.length} processes and ${perProcessWorkers} worker(s) per process.` + ); + + await Promise.all(groups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group)))); + const results = await Promise.all(groups.map((group, index) => spawnGroup(group, index))); + await writeCombinedReportIndex(results); + await Promise.all(groups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group)))); + + const failedRuns = results.filter((result) => result.code !== 0); + if (failedRuns.length > 0) { + console.error( + `Parallel Playwright CI failed for: ${failedRuns.map((result) => `${result.name} (${result.code})`).join(", ")}` + ); + process.exit(1); + } + + console.log( + `Parallel Playwright CI passed. Combined report index: ${path.relative(workingDirectory, path.join(reportIndexDirectory, "index.html"))}` + ); +} + +await main(); diff --git a/src/components/displays/department/pos/steps/PosDepartmentStep1.vue b/src/components/displays/department/pos/steps/PosDepartmentStep1.vue index 26835bb4..def3348d 100644 --- a/src/components/displays/department/pos/steps/PosDepartmentStep1.vue +++ b/src/components/displays/department/pos/steps/PosDepartmentStep1.vue @@ -1,67 +1,95 @@ - + diff --git a/src/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue b/src/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue new file mode 100644 index 00000000..edab6560 --- /dev/null +++ b/src/components/displays/department/pos/steps/elements/PosDesktopOrderBookingSelectorModal.vue @@ -0,0 +1,628 @@ + + + + + diff --git a/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue b/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue index 01ccd3b7..6cea3f13 100644 --- a/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue +++ b/src/components/displays/department/pos/steps/mobile/PosDepartmentStepMobile2.vue @@ -19,7 +19,7 @@ import PosDepartmentStep2MobileVehicleSelection import PosDepartmentStepMobileButtonNextStep from "@/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobileButtonNextStep.vue"; import { primaryItem } from "./objects/PosDepartmentStepMobileFlow.vue"; -import { order_id, order_notes, reference as persistedReference, reg_1, reg_2, reg_3, department_id, customer_id, getCustomerEmail, customer_name, isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue"; +import { order_id, order_notes, order_po, reference as persistedReference, reg_1, reg_2, reg_3, department_id, customer_id, getCustomerEmail, customer_name, isAddonRestricted, canBuyAdditionalServices } from "@/components/shop/POSDepartmentProcess.vue"; import { createOrderItem, getOrderItems, removeOrderItem } from "@/components/shop/OrdersItems.vue"; import { PosProduct } from "@/components/displays/department/pos/steps/mobile/objects/PosProduct.vue"; import PosDepartmentStepMobileButtonClearAll @@ -153,6 +153,12 @@ const getFirstWashProduct = async (): Promise => { } }; +const normalizeRegistrationValue = (value: string | null | undefined) => + String(value ?? '') + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]/g, ''); + const applyPendingBookingFromSelection = async () => { const booking: any = await getSelectedBooking(); if (!booking) return false; @@ -168,6 +174,7 @@ const applyPendingBookingFromSelection = async () => { if (booking.po && typeof booking.po === 'string' && booking.po.trim() !== '') { try { await SessionUser.objects.orders.set.po(order_id.value, booking.po); + order_po.value = booking.po.trim(); } catch (e) { console.error('Failed to set PO on order from booking', e); } @@ -184,22 +191,85 @@ const applyPendingBookingFromSelection = async () => { } items = Array.isArray(fullBookingData.items) ? fullBookingData.items : []; // Apply reference and notes from full booking - if (fullBookingData.reference && fullBookingData.reference.trim() !== '') { + const bookingReference = String(fullBookingData.reference ?? fullBookingData.reference_number ?? '').trim(); + if (bookingReference !== '') { try { - await SessionUser.objects.orders.set.reference(order_id.value, fullBookingData.reference); - persistedReference.value = fullBookingData.reference; + await SessionUser.objects.orders.set.reference(order_id.value, bookingReference); + persistedReference.value = bookingReference; + metadata.setReference(bookingReference); hydrateReferenceFromSources(); } catch (setRefError) { console.error('Failed to set reference on order from booking:', setRefError); } } - if (fullBookingData.notes !== undefined && fullBookingData.notes !== null && fullBookingData.notes.trim() !== '') { + const bookingNotes = String(fullBookingData.notes ?? fullBookingData.note ?? '').trim(); + if (bookingNotes !== '') { try { - await SessionUser.objects.orders.set.notes(order_id.value, fullBookingData.notes); + await SessionUser.objects.orders.set.notes(order_id.value, bookingNotes); + order_notes.value = bookingNotes; + notes.value = bookingNotes; + metadata.setNotes(bookingNotes); } catch (setNotesError) { console.error('Failed to set notes on order from booking:', setNotesError); } } + + const bookingReg1 = normalizeRegistrationValue(fullBookingData.reg_1 ?? booking.reg_1 ?? reg_1.value); + const bookingReg2 = normalizeRegistrationValue(fullBookingData.reg_2 ?? booking.reg_2 ?? ''); + const currentReg1 = normalizeRegistrationValue(reg_1.value); + const currentReg2 = normalizeRegistrationValue(reg_2.value); + + if (bookingReg1 && bookingReg1 !== currentReg1) { + reg_1.value = bookingReg1; + await SessionUser.objects.orders.set.reg_1(order_id.value, bookingReg1); + } + + if (bookingReg2 !== currentReg2) { + reg_2.value = bookingReg2; + await SessionUser.objects.orders.set.reg_2(order_id.value, bookingReg2); + } + + const bookingMatches = Array.isArray(vehicles.vehicle_1.value?.booking_matches) + ? vehicles.vehicle_1.value.booking_matches + : [booking]; + const bookingCustomerNumber = + Number.parseInt( + String( + fullBookingData?.customer_number ?? + fullBookingData?.customer_id ?? + booking?.customer_number ?? + booking?.customer_id ?? + vehicles.vehicle_1.value?.customer_id ?? + customer_id.value ?? + 0 + ), + 10 + ) || 0; + + vehicles.select(1, { + ...(vehicles.vehicle_1.value || {}), + reg: bookingReg1 || vehicles.vehicle_1.value?.reg || '', + customer_id: bookingCustomerNumber, + status: 'booked', + barred: false, + booking_id: booking.id, + booking_matches: bookingMatches, + reference: bookingReference || vehicles.vehicle_1.value?.reference || null, + }); + + if (bookingReg2) { + vehicles.select(2, { + ...(vehicles.vehicle_2.value || {}), + reg: bookingReg2, + customer_id: bookingCustomerNumber, + status: 'booked', + barred: false, + booking_id: booking.id, + booking_matches: bookingMatches, + }); + } else { + vehicles.select(2, null); + } } catch (fetchError) { console.error('Failed to fetch full booking for items:', fetchError); lastAppliedBookingId.value = booking.id; @@ -424,7 +494,6 @@ const getNormalizedOrderId = () => { const normalizeReferenceValue = (value: unknown) => String(value ?? ""); const hasReferenceValue = (value: unknown) => normalizeReferenceValue(value).trim() !== ""; -const normalizeRegistrationValue = (value: string | null | undefined) => String(value ?? "").trim().toUpperCase().replace(/[^A-Z0-9]/g, ""); const syncVehicleRegistrationFromOrder = (vehicleIndex: number, value: string | null | undefined) => { const normalizedValue = normalizeRegistrationValue(value); diff --git a/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupRenderer.vue b/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupRenderer.vue index ae0ab4bf..da1b076c 100644 --- a/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupRenderer.vue +++ b/src/components/displays/department/pos/steps/mobile/elements/PosDepartmentStepMobilePopupRenderer.vue @@ -7,7 +7,7 @@ import { popupComponentKeyToComponent } from "@/components/displays/department/p