Add Playwright PR runner and Vitest unit test fast runner scripts; integrate with test commands and update specs/tests for PR-based selection.

This commit is contained in:
Jeppe Bundgaard
2026-05-18 10:59:56 +02:00
parent 656e43d449
commit 5081fe0776
22 changed files with 952 additions and 28 deletions
+10 -11
View File
@@ -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
+8 -3
View File
@@ -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"
},
+83
View File
@@ -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,
},
];
+12
View File
@@ -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",
+429
View File
@@ -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] [-- <playwright args>]
Options:
--base <ref> Base ref for changed-area detection
--head <ref> Head ref for changed-area detection. Default: HEAD
--project <name> 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;
});
+31 -2
View File
@@ -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 <n> Spec files per batch. Default: 5
--from-list <file> 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.");
+219
View File
@@ -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 ...] [-- <vitest args>]
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;
});
@@ -240,7 +240,7 @@ const getProductOptionsLabel = (vehicle) => {
v-if="!props.compact"
:user_id="object.user_id"
:reg_1="object.reg"
:displayActionsDirectly="false"
:displayActionsDirectly="true"
>
<template #actions>
<!-- View (Redirect to the vehicle page) -->
+1 -1
View File
@@ -6,7 +6,7 @@ const AUTH_ENTRY_TIMEOUT = 60_000;
test.describe("Auth entry smoke", () => {
test.describe.configure({ timeout: 90_000 });
test("@smoke protected routes redirect to login without token", async ({ page }) => {
test("@smoke @pr protected routes redirect to login without token", async ({ page }) => {
await mockApi(page);
await page.goto("/user");
await expect(page).toHaveURL(/\/login/, { timeout: AUTH_ENTRY_TIMEOUT });
+1 -1
View File
@@ -2,7 +2,7 @@ import { expect, test } from "@playwright/test";
import { mockApi } from "./support/network.js";
test.describe("Booking/self-serve smoke", () => {
test("@smoke guest booking route loads and keeps key controls available", async ({ page }) => {
test("@smoke @pr guest booking route loads and keeps key controls available", async ({ page }) => {
await mockApi(page);
await page.goto("/guest/book/wash", { waitUntil: "domcontentloaded" });
+1 -1
View File
@@ -40,7 +40,7 @@ async function acceleratePageTimers(page, timerScale = 0.01) {
test.describe("Edge gateway management smoke", () => {
test.describe.configure({ mode: "serial" });
test("@smoke shows technical request details for backend errors", async ({ page }) => {
test("@smoke @pr shows technical request details for backend errors", async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
+1 -1
View File
@@ -76,7 +76,7 @@ function collectSubstringPaths(value: unknown, substring: string, prefix = ""):
}
test.describe("Danish locale smoke", () => {
test("@smoke normalizes targeted Danish locale copy in da locale", async ({ request }) => {
test("@smoke @pr normalizes targeted Danish locale copy in da locale", async ({ request }) => {
const response = await request.get("/src/i18n/locales/da.json");
expect(response.ok()).toBe(true);
+1 -1
View File
@@ -58,7 +58,7 @@ async function prepareInvoiceDistributionPage(page, overrides = {}) {
}
test.describe("Invoice distribution smoke", () => {
test("@smoke overview loads and quick-open month action works", async ({ page }) => {
test("@smoke @pr overview loads and quick-open month action works", async ({ page }) => {
const componentWarnings = [];
page.on("console", (message) => {
const text = message.text();
+1 -1
View File
@@ -910,7 +910,7 @@ function expectBoxInside(innerBox, outerBox, label, tolerance = 1) {
}
test.describe("Invoicing period tab", () => {
test("@smoke period view does not throw queue refresh errors on load", async ({ page }) => {
test("@smoke @pr period view does not throw queue refresh errors on load", async ({ page }) => {
const pageErrors = [];
page.on("pageerror", (error) => {
pageErrors.push(error.message);
+1 -1
View File
@@ -6,7 +6,7 @@ test.describe("Public route smoke", () => {
await mockApi(page);
});
test("@smoke app boot loads landing page", async ({ page }) => {
test("@smoke @pr app boot loads landing page", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveURL(/\/$/);
await expect(page.getByTestId("landing-title")).toContainText("Truck Wash");
+1 -1
View File
@@ -1294,7 +1294,7 @@ test.describe("POS mobile order flow", () => {
await expect(page.getByTestId("pos-mobile-step-1-reference-warning-icon")).toHaveCount(0);
});
test("@smoke manual input happy path creates, completes, and resets", async ({ page }) => {
test("@smoke @pr manual input happy path creates, completes, and resets", async ({ page }) => {
const fixture = createMobilePosFixture();
await createOrderFromStep1(page, fixture, {
reg: "ZZ00000",
@@ -501,7 +501,7 @@ const longTitleSnapshot = {
};
test.describe("Superuser system status smoke", () => {
test("@smoke superuser status dashboard renders Danish translations for degraded infrastructure and recent sessions", async ({
test("@smoke @pr superuser status dashboard renders Danish translations for degraded infrastructure and recent sessions", async ({
page,
}) => {
await bootSuperuser(page);
+1 -1
View File
@@ -50,7 +50,7 @@ test.describe("Workfeed configuration smoke", () => {
await primeSuperuserSession(page);
});
test("@smoke renders workfeed config page and runs diagnostics", async ({ page }) => {
test("@smoke @pr renders workfeed config page and runs diagnostics", async ({ page }) => {
await page.goto("/superuser/configuration/workfeed");
await expect(page).toHaveURL(/\/superuser\/configuration\/workfeed$/);
@@ -273,6 +273,7 @@ describe("Invoicing period queue-driven refresh", () => {
page: 1,
limit: 100,
search: "",
flagTab: "all",
includeRequiresAction: 1,
includeBooked: 1,
},
@@ -338,6 +339,7 @@ describe("Invoicing period queue-driven refresh", () => {
page: 1,
limit: 100,
search: "",
flagTab: "all",
includeRequiresAction: 1,
includeBooked: 1,
},
@@ -393,6 +395,7 @@ describe("Invoicing period queue-driven refresh", () => {
page: 2,
limit: 100,
search: "",
flagTab: "all",
includeRequiresAction: 1,
includeBooked: 1,
periodWarm: 1,
+30
View File
@@ -0,0 +1,30 @@
# Specs in this allowlist mutate shared browser state, fake timers, storage, or process globals.
# Keep this list small; new unit specs default to the parallel fast pass.
tests/unit/action-settings-wheel-button.spec.js
tests/unit/authenticated-request.spec.js
tests/unit/collected-order-invoices-closed-at-modal.spec.js
tests/unit/customer-search-field-pos.spec.js
tests/unit/department-daily-report-complaints.spec.js
tests/unit/department-gates-config-modal.spec.js
tests/unit/department-lanes-relay-options.spec.js
tests/unit/economic-transfer-queue.spec.js
tests/unit/edge-gateway-service.spec.js
tests/unit/invoicing-period-queue-refresh.behavior.spec.js
tests/unit/invoicing-period-queue-state.behavior.spec.js
tests/unit/orders-change-customer.spec.js
tests/unit/orders-items.spec.js
tests/unit/orders-table.spec.js
tests/unit/origin-migration.spec.js
tests/unit/paginated-list-export.spec.js
tests/unit/pos-department-process.spec.js
tests/unit/pos-last-scanned-license-plates.spec.js
tests/unit/request-queue-progress.spec.js
tests/unit/select-vehicle-form-pos.spec.js
tests/unit/self-serve-machine-connectivity.spec.js
tests/unit/session-token-initialization-contract.spec.js
tests/unit/superuser-invoices-view.spec.js
tests/unit/superuser-system-status-dashboard.spec.js
tests/unit/system-search-modal-contract.spec.js
tests/unit/user-booking-date-time-selector.spec.js
tests/unit/use-wash-progress.spec.js
tests/unit/xlvask-usage-amount-cache.spec.js
+112 -1
View File
@@ -1 +1,112 @@
// Intentionally empty. The shared Vitest config expects this file to exist.
import { afterEach, beforeEach, vi } from "vitest";
import { enableAutoUnmount } from "@vue/test-utils";
enableAutoUnmount(afterEach);
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
class TestIntersectionObserver {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
function createMatchMedia(query) {
return {
matches: false,
media: query,
onchange: null,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent() {
return false;
},
};
}
function ensureBrowserMocks() {
if (!globalThis.ResizeObserver) {
globalThis.ResizeObserver = TestResizeObserver;
}
if (!globalThis.IntersectionObserver) {
globalThis.IntersectionObserver = TestIntersectionObserver;
}
if (globalThis.window) {
if (!window.matchMedia) {
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: createMatchMedia,
});
}
if (!window.scrollTo) {
Object.defineProperty(window, "scrollTo", {
configurable: true,
writable: true,
value() {},
});
}
}
if (globalThis.URL) {
if (!URL.createObjectURL) {
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
writable: true,
value: vi.fn(() => "blob:vitest-object-url"),
});
}
if (!URL.revokeObjectURL) {
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
writable: true,
value: vi.fn(),
});
}
}
}
function clearStorage(storage) {
try {
storage?.clear();
} catch {
// Some jsdom URL modes can make storage unavailable.
}
}
beforeEach(() => {
ensureBrowserMocks();
});
afterEach(async () => {
await Promise.resolve();
await Promise.resolve();
vi.useRealTimers();
vi.clearAllMocks();
vi.unstubAllGlobals();
if (globalThis.document?.body) {
document.body.innerHTML = "";
}
if (globalThis.window) {
clearStorage(window.localStorage);
clearStorage(window.sessionStorage);
}
ensureBrowserMocks();
});
+4
View File
@@ -223,6 +223,10 @@ export default defineConfig(({ mode }) => {
}
}
},
test: {
setupFiles: [fromProjectRoot('tests', 'unit', 'setup.js')],
isolate: true
},
server: {
watch: {
ignored: ['**/output/playwright/**', '**/node_modules.codex-backup/**']