Booking #{{ booking.id }}
+{{ formatBookingDateTime(getDisplayBooking(booking)) }}
++ {{ getDisplayBooking(booking)?.customer_name || t("admin.pos.not_found") }} +
+ + + ++ {{ t("admin.pos.order_booking_selector.services_label") }} +
+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 ` +
Each child run used its own Vite port, dev-server pid file, and artifact directory.
+| Group | +Projects | +Port | +Status | +Report | +
|---|
{{ title || t("admin.pos.order_booking_selector.title") }}
+ +Booking #{{ booking.id }}
++ {{ getDisplayBooking(booking)?.customer_name || t("admin.pos.not_found") }} +
+ + + ++ {{ t("admin.pos.order_booking_selector.services_label") }} +
+{{ popups.get()?.message || t('admin.pos.order_booking_selector.help_text') }}
-| {{ucFirst(SessionUser.objects.products.meta.labels.single)}} | -{{ucFirst(SessionUser.objects.global.language.quantity)}} | -||
|---|---|---|---|
| {{ ucFirst(SessionUser.objects.products.meta.labels.single) }} | +{{ ucFirst(SessionUser.objects.global.language.quantity) }} | +||
| {{ SessionUser.objects.products.functions.getProductName(parseInt(vehicleObject.type)) }} | -1 | -||
| + {{ getVehicleAddonName(item) }} | -{{ item.amount }} | +{{ SessionUser.objects.products.functions.getProductName(parseInt(vehicleObject.type)) }} | +1 |
| + {{ getVehicleAddonName(item) }} | +{{ item.amount }} | +