From 5081fe07761693cdb17b2bd03db6287ca1b1456e Mon Sep 17 00:00:00 2001 From: Jeppe Bundgaard Date: Mon, 18 May 2026 10:59:56 +0200 Subject: [PATCH] Add Playwright PR runner and Vitest unit test fast runner scripts; integrate with test commands and update specs/tests for PR-based selection. --- .github/workflows/tests.yml | 21 +- package.json | 11 +- scripts/playwright-pr-mapping.mjs | 83 ++++ scripts/run-playwright-full-slice.mjs | 12 + scripts/run-playwright-pr.mjs | 429 ++++++++++++++++++ scripts/run-vitest-unit-batches.mjs | 33 +- scripts/run-vitest-unit-fast.mjs | 219 +++++++++ .../displays/user/vehicles/vehiclesTable.vue | 2 +- tests/e2e/auth.smoke.spec.js | 2 +- tests/e2e/booking-selfserve.smoke.spec.js | 2 +- tests/e2e/edge-gateways.smoke.spec.js | 2 +- tests/e2e/i18n.smoke.spec.ts | 2 +- tests/e2e/invoice-distribution.smoke.spec.js | 2 +- tests/e2e/invoicing-period.smoke.spec.js | 2 +- tests/e2e/navigation.smoke.spec.js | 2 +- tests/e2e/pos-mobile-order-flow.spec.js | 2 +- .../e2e/superuser-system-status.smoke.spec.js | 2 +- tests/e2e/workfeed-config.smoke.spec.js | 2 +- ...cing-period-queue-refresh.behavior.spec.js | 3 + tests/unit/serial-tests.txt | 30 ++ tests/unit/setup.js | 113 ++++- vite.config.js | 4 + 22 files changed, 952 insertions(+), 28 deletions(-) create mode 100644 scripts/playwright-pr-mapping.mjs create mode 100644 scripts/run-playwright-pr.mjs create mode 100644 scripts/run-vitest-unit-fast.mjs create mode 100644 tests/unit/serial-tests.txt diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 04c4364e..0b6f1d3e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -61,17 +61,18 @@ jobs: env: VITEST_BATCH_SIZE: 5 - e2e-smoke: + e2e-pr: if: github.event_name != 'schedule' needs: build-and-unit runs-on: [self-hosted, Linux, X64, default] - strategy: - fail-fast: false - matrix: - project: [chromium-desktop, chromium-mobile] + env: + PLAYWRIGHT_PR_BASE: ${{ github.event.pull_request.base.sha || github.event.before }} + PLAYWRIGHT_PR_HEAD: ${{ github.sha }} steps: - name: Checkout repository uses: actions/checkout@v5 + with: + fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v5 @@ -85,17 +86,15 @@ jobs: - name: Install Playwright browsers run: npx playwright install --with-deps chromium - - name: Run Playwright smoke tests (${{ matrix.project }}) - run: npx playwright test --grep @smoke --project="${{ matrix.project }}" + - name: Run Playwright PR tests + run: npm run test:e2e:pr -- --base="$PLAYWRIGHT_PR_BASE" --head="$PLAYWRIGHT_PR_HEAD" - name: Upload Playwright report if: failure() uses: actions/upload-artifact@v4 with: - name: playwright-report-smoke-${{ matrix.project }} - path: | - output/playwright/report - output/playwright/test-results + name: playwright-report-pr + path: output/playwright if-no-files-found: ignore retention-days: 7 diff --git a/package.json b/package.json index b53a0c20..806d4532 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,14 @@ "preview:prod": "npm run build && npm run preview -- --host 127.0.0.1 --port 4173", "text:fix-encoding": "node scripts/text-encoding.mjs fix", "text:check-encoding": "node scripts/text-encoding.mjs check", - "test:unit": "npm run text:check-encoding && node scripts/run-vitest-unit-batches.mjs", + "test:unit": "npm run text:check-encoding && npm run test:unit:fast && npm run test:unit:serial", + "test:unit:fast": "node scripts/run-vitest-unit-fast.mjs", + "test:unit:serial": "node scripts/run-vitest-unit-batches.mjs --from-list tests/unit/serial-tests.txt", "test:unit:single": "vitest run", - "test:e2e": "playwright test", + "test:e2e": "npm run test:e2e:matrix", + "test:e2e:matrix": "playwright test", + "test:e2e:pr": "node scripts/run-playwright-pr.mjs", + "test:e2e:changed": "node scripts/run-playwright-pr.mjs --changed-only", "test:e2e:i18n:views": "playwright test tests/e2e/i18n.views.spec.ts --project=chromium-desktop", "test:e2e:ci": "node scripts/run-playwright-ci-parallel.mjs", "test:e2e:full:slice": "node scripts/run-playwright-full-slice.mjs", @@ -30,7 +35,7 @@ "test:e2e:live": "playwright test --config=playwright.live.config.ts", "test:e2e:release": "npm run test:e2e:prod && npm run test:e2e:live", "test:e2e:pos-mobile-live": "node -e \"const { spawnSync } = require('child_process'); const result = spawnSync('npx', ['playwright', 'test', 'tests/e2e/adminModulePosMobileOrderFlow.spec.ts', '--project=chromium-mobile'], { stdio: 'inherit', shell: true, env: { ...process.env, PLAYWRIGHT_LIVE: '1' } }); process.exit(result.status ?? 1);\"", - "test:all": "npm run test:unit && npm run test:e2e:smoke", + "test:all": "npm run test:unit && npm run test:e2e:pr", "twa:build": "bubblewrap build", "twa:update": "bubblewrap update" }, diff --git a/scripts/playwright-pr-mapping.mjs b/scripts/playwright-pr-mapping.mjs new file mode 100644 index 00000000..f9eef9f1 --- /dev/null +++ b/scripts/playwright-pr-mapping.mjs @@ -0,0 +1,83 @@ +export const chromiumProjects = ["chromium-desktop", "chromium-mobile"]; +export const prGrep = "@pr"; +export const smokeGrep = "@smoke"; + +export const fallbackChangePatterns = [ + /^package(?:-lock)?\.json$/u, + /^vite\.config\.js$/u, + /^playwright(?:\..+)?\.config\.(?:js|ts)$/u, + /^playwright\.global-(?:setup|teardown)\.mjs$/u, + /^scripts\/run-playwright/u, + /^tests\/e2e\/(?:support|fixtures)\//u, +]; + +export const sourceMappings = [ + { + name: "auth", + patterns: [/^src\/(?:views|components|middleware)\/.*auth/iu, /^src\/views\/auth\//u, /^src\/components\/session\//u], + specs: ["tests/e2e/auth.smoke.spec.js", "tests/e2e/userAuth.spec.ts"], + projects: chromiumProjects, + }, + { + name: "public-routes", + patterns: [/^src\/views\/pages\//u, /^src\/components\/page\//u, /^src\/router\.js$/u], + specs: ["tests/e2e/navigation.smoke.spec.js"], + projects: chromiumProjects, + }, + { + name: "booking", + patterns: [/bookings?/iu, /time-bookings/iu, /^src\/views\/guest\/book\//u], + specs: [ + "tests/e2e/booking-selfserve.smoke.spec.js", + "tests/e2e/userBookWash.spec.ts", + "tests/e2e/userBookings.spec.ts", + ], + projects: chromiumProjects, + }, + { + name: "pos", + patterns: [/\/pos[/-]/iu, /POS/iu, /^src\/assets\/pos\.css$/u], + specs: ["tests/e2e/pos-flow.spec.js", "tests/e2e/pos-mobile-order-flow.spec.js", "tests/e2e/admin-pos-orders.spec.ts"], + projects: chromiumProjects, + }, + { + name: "edge-gateways", + patterns: [/edge[-/]?gateways?/iu, /edgeGateway/iu], + specs: ["tests/e2e/edge-gateways.smoke.spec.js", "tests/e2e/edge-gateways.routes.spec.js"], + projects: chromiumProjects, + }, + { + name: "invoicing", + patterns: [/invoic/iu, /economic[-/]?queue/iu, /collected-order/iu], + specs: [ + "tests/e2e/invoicing-period.smoke.spec.js", + "tests/e2e/invoice-distribution.smoke.spec.js", + "tests/e2e/invoice-transfer-queue-history.spec.js", + ], + projects: chromiumProjects, + }, + { + name: "workfeed", + patterns: [/workfeed/iu], + specs: ["tests/e2e/workfeed-config.smoke.spec.js"], + projects: chromiumProjects, + }, + { + name: "system-status", + patterns: [/system[-/]?status/iu, /systemDatabase/iu, /replication/iu], + specs: ["tests/e2e/superuser-system-status.smoke.spec.js"], + projects: chromiumProjects, + }, + { + name: "self-serve", + patterns: [/self[-/]?serve/iu, /selfserve/iu, /wash\/MyWash/iu], + specs: ["tests/e2e/self-serve-wash.spec.js", "tests/e2e/self-serve-studio-flow.spec.js"], + projects: chromiumProjects, + }, + { + name: "i18n", + patterns: [/^src\/i18n\//u, /i18n/iu, /locales/iu], + specs: ["tests/e2e/i18n.smoke.spec.ts"], + projects: chromiumProjects, + }, +]; diff --git a/scripts/run-playwright-full-slice.mjs b/scripts/run-playwright-full-slice.mjs index 4422bd74..11b77827 100644 --- a/scripts/run-playwright-full-slice.mjs +++ b/scripts/run-playwright-full-slice.mjs @@ -18,6 +18,7 @@ const ownedFilesByRole = { "i18n.smoke.spec.ts", "i18n.views.spec.ts", "navigation.smoke.spec.js", + "qr-new-customer-layout.spec.ts", "self-serve-wash.spec.js", "user-orders.spec.ts", "userBookings.spec.ts", @@ -61,16 +62,27 @@ const ownedFilesByRole = { "pos.visual.spec.js", ], superuser: [ + "edge-gateways.fleet-outline.spec.js", "edge-gateways.routes.spec.js", "edge-gateways.smoke.spec.js", "edge-gateways.visual.spec.js", "invoice-distribution.smoke.spec.js", + "invoice-transfer-monitor.spec.ts", "invoice-transfer-queue-history.spec.js", "invoicing-period.smoke.spec.js", + "issue-repro-duplicates-date.spec.js", + "self-serve-sessions.spec.js", + "self-serve-studio-audit-navigation.spec.js", + "self-serve-studio-flow.spec.js", "superuser-bookings.spec.ts", "superuser-customer-complaints.spec.ts", + "superuser-customers-mass-import.spec.ts", + "superuser-department-branding.spec.js", "superuser-department-gates.spec.ts", + "superuser-department-lanes.spec.ts", + "superuser-departments-archive.spec.ts", "superuser-drafts.spec.ts", + "superuser-products-layout.spec.ts", "superuser-system-status.smoke.spec.js", "superuser-vehicles.smoke.spec.js", "workfeed-config.smoke.spec.js", diff --git a/scripts/run-playwright-pr.mjs b/scripts/run-playwright-pr.mjs new file mode 100644 index 00000000..9fbb952d --- /dev/null +++ b/scripts/run-playwright-pr.mjs @@ -0,0 +1,429 @@ +import { execFile, spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { chromiumProjects, fallbackChangePatterns, prGrep, smokeGrep, sourceMappings } from "./playwright-pr-mapping.mjs"; + +const execFileAsync = promisify(execFile); +const workingDirectory = process.cwd(); +const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js"); +const args = parseArgs(process.argv.slice(2)); +const activeChildren = new Set(); +let isShuttingDown = false; + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => { + void shutdown(signal).finally(() => { + process.exit(130); + }); + }); +} + +function parseArgs(rawArgs) { + const parsed = { + help: false, + changedOnly: false, + listOnly: false, + base: "", + head: "", + projects: [], + forwardedArgs: [], + }; + + for (let index = 0; index < rawArgs.length; index += 1) { + const value = rawArgs[index]; + + if (value === "--") { + parsed.forwardedArgs.push(...rawArgs.slice(index + 1)); + break; + } + + if (value === "--help" || value === "-h") { + parsed.help = true; + continue; + } + + if (value === "--changed-only") { + parsed.changedOnly = true; + continue; + } + + if (value === "--list-only") { + parsed.listOnly = true; + continue; + } + + if (value === "--base" && rawArgs[index + 1] !== undefined) { + parsed.base = rawArgs[index + 1]; + index += 1; + continue; + } + + if (value.startsWith("--base=")) { + parsed.base = value.slice("--base=".length); + continue; + } + + if (value === "--head" && rawArgs[index + 1] !== undefined) { + parsed.head = rawArgs[index + 1]; + index += 1; + continue; + } + + if (value.startsWith("--head=")) { + parsed.head = value.slice("--head=".length); + continue; + } + + if (value === "--project" && rawArgs[index + 1] !== undefined) { + parsed.projects.push(rawArgs[index + 1]); + index += 1; + continue; + } + + if (value.startsWith("--project=")) { + parsed.projects.push(value.slice("--project=".length)); + continue; + } + + parsed.forwardedArgs.push(value); + } + + return parsed; +} + +function printUsage() { + console.log(` +Playwright PR runner + +Usage: + node scripts/run-playwright-pr.mjs [options] [-- ] + +Options: + --base Base ref for changed-area detection + --head Head ref for changed-area detection. Default: HEAD + --project Restrict to one Chromium Playwright project. Can be repeated. + --changed-only Run changed-area selection without the core @pr gate + --list-only List selected tests instead of running them + -h, --help Show help + +Examples: + npm run test:e2e:pr + npm run test:e2e:changed -- --base=HEAD~1 --head=HEAD +`); +} + +function normalizePath(value) { + return value.replace(/\\/g, "/").replace(/^\.\//u, ""); +} + +function isZeroSha(value) { + return /^0{40}$/u.test(value || ""); +} + +function getSelectedProjects(projects = args.projects) { + return projects.length > 0 ? projects : chromiumProjects; +} + +function unique(values) { + return [...new Set(values)]; +} + +function sanitizeNamespace(value) { + return value.replace(/[^a-zA-Z0-9._-]+/g, "-"); +} + +function getArtifactNamespace(suffix) { + const baseNamespace = process.env.PLAYWRIGHT_ARTIFACT_NAMESPACE || "pr"; + return sanitizeNamespace(`${baseNamespace}-${suffix}`); +} + +function buildProjectArgs(projects) { + return projects.flatMap((project) => ["--project", project]); +} + +async function runPlaywright({ label, commandArgs, artifactSuffix }) { + const finalArgs = ["test", ...commandArgs, ...args.forwardedArgs]; + + if (args.listOnly && !finalArgs.includes("--list")) { + finalArgs.push("--list"); + } + + console.log(`[playwright-pr] ${args.listOnly ? "Listing" : "Running"} ${label}: playwright ${finalArgs.join(" ")}`); + + return new Promise((resolve) => { + const child = spawn(process.execPath, [playwrightCliPath, ...finalArgs], { + cwd: workingDirectory, + env: { + ...process.env, + PLAYWRIGHT: "1", + PLAYWRIGHT_ARTIFACT_NAMESPACE: getArtifactNamespace(artifactSuffix), + PLAYWRIGHT_REPORTER_MODE: "line-html", + }, + stdio: "inherit", + windowsHide: true, + }); + + activeChildren.add(child); + child.on("close", (code) => { + activeChildren.delete(child); + resolve(code ?? 1); + }); + }); +} + +async function tryGitDiff(rangeArgs) { + const { stdout } = await execFileAsync("git", ["diff", "--name-only", "--diff-filter=ACMR", ...rangeArgs], { + cwd: workingDirectory, + maxBuffer: 16 * 1024 * 1024, + }); + + return stdout + .split(/\r?\n/u) + .map((line) => normalizePath(line.trim())) + .filter(Boolean); +} + +async function getChangedFiles() { + const base = args.base.trim(); + const head = args.head.trim() || "HEAD"; + + if (base && !isZeroSha(base)) { + try { + return { + files: await tryGitDiff([`${base}...${head}`]), + source: `${base}...${head}`, + }; + } catch { + try { + return { + files: await tryGitDiff([`${base}..${head}`]), + source: `${base}..${head}`, + }; + } catch { + return { + files: [], + source: `${base}...${head}`, + unavailable: true, + }; + } + } + } + + try { + return { + files: await tryGitDiff(["HEAD~1...HEAD"]), + source: "HEAD~1...HEAD", + }; + } catch { + return { + files: [], + source: "HEAD~1...HEAD", + unavailable: true, + }; + } +} + +function addSpec(selection, spec, projects) { + const normalizedSpec = normalizePath(spec); + const existingProjects = selection.specProjects.get(normalizedSpec) || new Set(); + + for (const project of projects) { + existingProjects.add(project); + } + + selection.specProjects.set(normalizedSpec, existingProjects); +} + +function isE2eSpec(file) { + return /^tests\/e2e\/.+\.spec\.(?:js|ts)$/u.test(file); +} + +function shouldFallback(file) { + return fallbackChangePatterns.some((pattern) => pattern.test(file)); +} + +function isFrontendSource(file) { + return /^src\//u.test(file) || /^public\//u.test(file) || /^index\.html$/u.test(file); +} + +function selectChangedTests(changedFiles) { + const selection = { + specProjects: new Map(), + mappedFiles: [], + unmappedFiles: [], + fallback: false, + }; + + const selectedProjects = getSelectedProjects(); + + for (const rawFile of changedFiles) { + const file = normalizePath(rawFile); + + if (isE2eSpec(file)) { + addSpec(selection, file, selectedProjects); + selection.mappedFiles.push(file); + continue; + } + + if (shouldFallback(file)) { + selection.fallback = true; + selection.unmappedFiles.push(file); + continue; + } + + if (!isFrontendSource(file)) { + continue; + } + + const matches = sourceMappings.filter((mapping) => mapping.patterns.some((pattern) => pattern.test(file))); + + if (matches.length === 0) { + selection.fallback = true; + selection.unmappedFiles.push(file); + continue; + } + + selection.mappedFiles.push(file); + for (const mapping of matches) { + const projects = mapping.projects.filter((project) => selectedProjects.includes(project)); + for (const spec of mapping.specs) { + addSpec(selection, spec, projects.length > 0 ? projects : selectedProjects); + } + } + } + + return selection; +} + +function groupSpecsByProjects(specProjects) { + const groups = new Map(); + + for (const [spec, projects] of specProjects.entries()) { + const projectList = unique([...projects]).sort(); + const key = projectList.join(","); + const existing = groups.get(key) || { + projects: projectList, + specs: [], + }; + existing.specs.push(spec); + groups.set(key, existing); + } + + return [...groups.values()].map((group) => ({ + ...group, + specs: group.specs.sort(), + })); +} + +async function runCorePrGate() { + const projects = getSelectedProjects(); + return runPlaywright({ + label: `core ${prGrep} gate`, + artifactSuffix: "core", + commandArgs: ["--grep", prGrep, ...buildProjectArgs(projects)], + }); +} + +async function runChangedSelection(selection) { + const projects = getSelectedProjects(); + + if (selection.fallback) { + console.log( + `[playwright-pr] Falling back to broader ${smokeGrep} coverage because these changed files were unmapped: ${selection.unmappedFiles.join(", ")}` + ); + return runPlaywright({ + label: `fallback ${smokeGrep} gate`, + artifactSuffix: "smoke-fallback", + commandArgs: ["--grep", smokeGrep, "--grep-invert", prGrep, ...buildProjectArgs(projects)], + }); + } + + const groups = groupSpecsByProjects(selection.specProjects); + if (groups.length === 0) { + console.log("[playwright-pr] No changed-area E2E specs selected."); + return 0; + } + + for (const [index, group] of groups.entries()) { + const code = await runPlaywright({ + label: `changed-area specs ${index + 1}/${groups.length}`, + artifactSuffix: `changed-${index + 1}`, + commandArgs: [...group.specs, ...buildProjectArgs(group.projects)], + }); + + if (code !== 0) { + return code; + } + } + + return 0; +} + +async function shutdown(signal) { + if (isShuttingDown) { + return; + } + + isShuttingDown = true; + console.error(`Received ${signal}. Stopping active Playwright PR children...`); + + await Promise.all( + [...activeChildren].map( + (child) => + new Promise((resolve) => { + if (!child.pid) { + resolve(); + return; + } + + if (process.platform === "win32") { + const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + killer.on("close", resolve); + return; + } + + child.kill("SIGTERM"); + resolve(); + }) + ) + ); +} + +async function main() { + if (args.help) { + printUsage(); + return; + } + + await fs.access(playwrightCliPath); + + if (!args.changedOnly) { + const coreCode = await runCorePrGate(); + if (coreCode !== 0) { + process.exitCode = coreCode; + return; + } + } + + const changed = await getChangedFiles(); + if (changed.unavailable) { + console.log(`[playwright-pr] Changed-area diff unavailable for ${changed.source}; skipping changed-area selection.`); + return; + } + + console.log(`[playwright-pr] Changed-area diff ${changed.source}: ${changed.files.length} file(s).`); + const selection = selectChangedTests(changed.files); + const changedCode = await runChangedSelection(selection); + if (changedCode !== 0) { + process.exitCode = changedCode; + } +} + +await main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/scripts/run-vitest-unit-batches.mjs b/scripts/run-vitest-unit-batches.mjs index 274086f0..bc731ec8 100644 --- a/scripts/run-vitest-unit-batches.mjs +++ b/scripts/run-vitest-unit-batches.mjs @@ -2,7 +2,7 @@ import { spawn } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; -const workingDirectory = process.cwd(); +const workingDirectory = await fs.realpath(process.cwd()); const vitestCliPath = path.join(workingDirectory, "node_modules", "vitest", "vitest.mjs"); const unitTestsRoot = path.join(workingDirectory, "tests", "unit"); const args = parseArgs(process.argv.slice(2)); @@ -23,6 +23,7 @@ function parseArgs(rawArgs) { const parsed = { help: false, batchSize: null, + fromList: null, specFilters: [], forwardedArgs: [], }; @@ -51,6 +52,17 @@ function parseArgs(rawArgs) { continue; } + if (value === "--from-list" && rawArgs[index + 1]) { + parsed.fromList = rawArgs[index + 1]; + index += 1; + continue; + } + + if (value.startsWith("--from-list=")) { + parsed.fromList = value.slice("--from-list=".length); + continue; + } + if (value.startsWith("-")) { parsed.forwardedArgs.push(...rawArgs.slice(index)); break; @@ -80,15 +92,31 @@ Usage: Options: --batch-size Spec files per batch. Default: 5 + --from-list Read additional spec filters from a newline-delimited file -h, --help Show help Examples: npm run test:unit + npm run test:unit:serial npm run test:unit -- tests/unit/select-vehicle-form-pos.spec.js npm run test:unit -- tests/unit/select-vehicle-form-pos.spec.js -- --reporter=verbose `); } +async function readSpecFilterList(listFile) { + if (!listFile) { + return []; + } + + const listPath = path.isAbsolute(listFile) ? listFile : path.join(workingDirectory, listFile); + const contents = await fs.readFile(listPath, "utf8"); + + return contents + .split(/\r?\n/u) + .map((line) => line.replace(/#.*/u, "").trim()) + .filter(Boolean); +} + async function collectSpecFiles(targetDirectory) { const directoryEntries = await fs.readdir(targetDirectory, { withFileTypes: true }); const files = []; @@ -234,7 +262,8 @@ async function main() { await fs.access(vitestCliPath); const discoveredFiles = await collectSpecFiles(unitTestsRoot); - const selectedFiles = applyFilters(discoveredFiles, args.specFilters); + const listFilters = await readSpecFilterList(args.fromList); + const selectedFiles = applyFilters(discoveredFiles, [...listFilters, ...args.specFilters]); if (selectedFiles.length === 0) { throw new Error("No unit spec files matched the provided filters."); diff --git a/scripts/run-vitest-unit-fast.mjs b/scripts/run-vitest-unit-fast.mjs new file mode 100644 index 00000000..d40eb996 --- /dev/null +++ b/scripts/run-vitest-unit-fast.mjs @@ -0,0 +1,219 @@ +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; + +const workingDirectory = await fs.realpath(process.cwd()); +const vitestCliPath = path.join(workingDirectory, "node_modules", "vitest", "vitest.mjs"); +const unitTestsRoot = path.join(workingDirectory, "tests", "unit"); +const serialListPath = path.join(unitTestsRoot, "serial-tests.txt"); +const args = parseArgs(process.argv.slice(2)); + +let activeChild = null; +let isShuttingDown = false; + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => { + void shutdown(signal).finally(() => { + process.exit(130); + }); + }); +} + +function parseArgs(rawArgs) { + const parsed = { + help: false, + specFilters: [], + forwardedArgs: [], + }; + + for (let index = 0; index < rawArgs.length; index += 1) { + const value = rawArgs[index]; + + if (value === "--") { + parsed.forwardedArgs.push(...rawArgs.slice(index + 1)); + break; + } + + if (value === "--help" || value === "-h") { + parsed.help = true; + continue; + } + + if (value.startsWith("-")) { + parsed.forwardedArgs.push(...rawArgs.slice(index)); + break; + } + + parsed.specFilters.push(value); + } + + return parsed; +} + +function normalizePathSegments(value) { + return value.replace(/\\/g, "/"); +} + +function printUsage() { + console.log(` +Vitest fast unit runner + +Usage: + node scripts/run-vitest-unit-fast.mjs [spec-filter ...] [-- ] + +Runs all unit specs except entries in tests/unit/serial-tests.txt. + +Environment: + VITEST_FAST_MAX_WORKERS Optional max workers for the fast pass + +Examples: + npm run test:unit:fast + npm run test:unit:fast -- tests/unit/workfeed + npm run test:unit:fast -- --reporter=verbose +`); +} + +async function collectSpecFiles(targetDirectory) { + const directoryEntries = await fs.readdir(targetDirectory, { withFileTypes: true }); + const files = []; + + for (const entry of directoryEntries) { + const absolutePath = path.join(targetDirectory, entry.name); + + if (entry.isDirectory()) { + files.push(...(await collectSpecFiles(absolutePath))); + continue; + } + + if (entry.isFile() && entry.name.endsWith(".spec.js")) { + files.push(absolutePath); + } + } + + return files.sort((left, right) => left.localeCompare(right)); +} + +async function readSerialAllowlist() { + const contents = await fs.readFile(serialListPath, "utf8"); + + return new Set( + contents + .split(/\r?\n/u) + .map((line) => line.replace(/#.*/u, "").trim()) + .filter(Boolean) + .map(normalizePathSegments) + ); +} + +function matchesFilter(relativePath, rawFilter) { + const normalizedRelativePath = normalizePathSegments(relativePath); + const normalizedFilter = normalizePathSegments(rawFilter.trim()); + + if (!normalizedFilter) { + return false; + } + + return ( + normalizedRelativePath === normalizedFilter || + normalizedRelativePath.endsWith(normalizedFilter) || + normalizedRelativePath.includes(normalizedFilter) + ); +} + +function applyFilters(files, specFilters) { + if (specFilters.length === 0) { + return files; + } + + return files.filter((absolutePath) => { + const relativePath = path.relative(workingDirectory, absolutePath); + return specFilters.some((filter) => matchesFilter(relativePath, filter)); + }); +} + +function hasForwardedArg(name) { + return args.forwardedArgs.some((value) => value === name || value.startsWith(`${name}=`)); +} + +function resolveMaxWorkers() { + return (process.env.VITEST_FAST_MAX_WORKERS || "").trim(); +} + +async function runVitest(files) { + const enforcedArgs = []; + + const configuredMaxWorkers = resolveMaxWorkers(); + if (configuredMaxWorkers && !hasForwardedArg("--maxWorkers")) { + enforcedArgs.push(`--maxWorkers=${configuredMaxWorkers}`); + } + + return new Promise((resolve) => { + activeChild = spawn(process.execPath, [vitestCliPath, "run", ...files, ...enforcedArgs, ...args.forwardedArgs], { + cwd: workingDirectory, + env: process.env, + stdio: "inherit", + windowsHide: true, + }); + + activeChild.on("close", (code) => { + activeChild = null; + resolve(code ?? 1); + }); + }); +} + +async function shutdown(signal) { + if (isShuttingDown) { + return; + } + + isShuttingDown = true; + console.error(`Received ${signal}. Stopping active Vitest fast run...`); + + if (activeChild?.pid) { + if (process.platform === "win32") { + spawn("taskkill", ["/PID", String(activeChild.pid), "/T", "/F"], { + stdio: "ignore", + windowsHide: true, + }); + } else { + activeChild.kill("SIGTERM"); + } + } +} + +async function main() { + if (args.help) { + printUsage(); + return; + } + + await fs.access(vitestCliPath); + + const serialAllowlist = await readSerialAllowlist(); + const discoveredFiles = await collectSpecFiles(unitTestsRoot); + const fastFiles = discoveredFiles.filter((absolutePath) => { + const relativePath = normalizePathSegments(path.relative(workingDirectory, absolutePath)); + return !serialAllowlist.has(relativePath); + }); + const selectedFiles = applyFilters(fastFiles, args.specFilters); + + if (selectedFiles.length === 0) { + console.log("No parallel-safe unit spec files matched the provided filters."); + return; + } + + console.log( + `Running ${selectedFiles.length} parallel-safe unit spec file(s); ${serialAllowlist.size} spec file(s) remain in the serial allowlist.` + ); + + const code = await runVitest(selectedFiles); + if (code !== 0) { + process.exitCode = code; + } +} + +await main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; +}); diff --git a/src/components/displays/user/vehicles/vehiclesTable.vue b/src/components/displays/user/vehicles/vehiclesTable.vue index fa153b4c..b45a643b 100644 --- a/src/components/displays/user/vehicles/vehiclesTable.vue +++ b/src/components/displays/user/vehicles/vehiclesTable.vue @@ -240,7 +240,7 @@ const getProductOptionsLabel = (vehicle) => { v-if="!props.compact" :user_id="object.user_id" :reg_1="object.reg" - :displayActionsDirectly="false" + :displayActionsDirectly="true" >