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 legacyPerProcessWorkers = parsePositiveInt(process.env.PLAYWRIGHT_PARALLEL_WORKERS); 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-tablet", "chromium-mobile"], defaultWorkers: 2, }, { name: "firefox", projects: ["firefox-desktop", "firefox-tablet", "firefox-mobile"], defaultWorkers: 1, }, { name: "webkit", projects: ["webkit-desktop", "webkit-tablet", "webkit-mobile"], defaultWorkers: 1, }, ]; function parsePositiveInt(value) { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } function normalizePositiveInt(value, fallback) { const parsed = parsePositiveInt(value); return parsed ?? fallback; } function resolveGroupWorkers(group) { const envName = `PLAYWRIGHT_PARALLEL_WORKERS_${group.name.toUpperCase()}`; const groupWorkers = parsePositiveInt(process.env[envName]); if (groupWorkers !== null) { return groupWorkers; } if (legacyPerProcessWorkers !== null) { return legacyPerProcessWorkers; } return group.defaultWorkers; } function resolveConfiguredGroups() { return groups.map((group) => ({ ...group, workers: resolveGroupWorkers(group), })); } function validateWorkerCap(configuredGroups) { const totalWorkers = configuredGroups.reduce((sum, group) => sum + group.workers, 0); if (totalWorkers > 5) { const groupSummary = configuredGroups.map((group) => `${group.name}=${group.workers}`).join(", "); throw new Error( `Parallel Playwright worker cap exceeded (${totalWorkers} > 5). Configure the group workers so their sum stays at or below 5. Current values: ${groupSummary}` ); } return totalWorkers; } 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", ...forwardedArgs, ...group.projects.flatMap((project) => ["--project", project])]; const child = spawn(process.execPath, [playwrightCliPath, ...args], { cwd: workingDirectory, env: { ...process.env, PLAYWRIGHT_BASE_URL: "", PLAYWRIGHT_DEV_PORT: String(devPort), PLAYWRIGHT_WORKERS: String(group.workers), 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, workers: group.workers, }); }); }); } 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} ${result.workers} ${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}
Group Projects Port Workers Status Report
`; 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() { const configuredGroups = resolveConfiguredGroups(); const totalWorkers = validateWorkerCap(configuredGroups); console.log( `Starting Playwright CI in parallel with ${ configuredGroups.length } processes and ${totalWorkers} total worker(s): ${configuredGroups .map((group) => `${group.name}=${group.workers}`) .join(", ")}.` ); await Promise.all(configuredGroups.map((group) => cleanupDevServerArtifacts(getArtifactNamespace(group)))); const results = await Promise.all(configuredGroups.map((group, index) => spawnGroup(group, index))); await writeCombinedReportIndex(results); await Promise.all(configuredGroups.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();