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, coreOnly: 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 === "--core-only") { parsed.coreOnly = 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. --core-only Run only the core @pr gate --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:pr -- --core-only --project=chromium-desktop 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}`); } 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", PLAYWRIGHT_WORKERS: process.env.PLAYWRIGHT_WORKERS || "1", }, 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\/(?!quarantine\/).+\.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: [], directSpecFiles: [], skippedDirectSpecFiles: [], fallback: false, }; const selectedProjects = getSelectedProjects(); for (const rawFile of changedFiles) { const file = normalizePath(rawFile); if (isE2eSpec(file)) { selection.directSpecFiles.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 mappedProjects = mapping.projects.length > 0 ? mapping.projects : selectedProjects; const projects = mappedProjects.filter((project) => selectedProjects.includes(project)); if (projects.length === 0) { continue; } for (const spec of mapping.specs) { addSpec(selection, spec, projects); } } } if (selection.specProjects.size === 0 && !selection.fallback) { for (const file of selection.directSpecFiles) { addSpec(selection, file, selectedProjects); selection.mappedFiles.push(file); } } else { selection.skippedDirectSpecFiles.push(...selection.directSpecFiles); } 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(); for (const project of projects) { const code = await runPlaywright({ label: `core ${prGrep} gate (${project})`, artifactSuffix: `core-${project}`, commandArgs: ["--grep", prGrep, "--project", project], }); if (code !== 0) { return code; } } return 0; } 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( ", " )}` ); for (const project of projects) { const code = await runPlaywright({ label: `fallback ${smokeGrep} gate (${project})`, artifactSuffix: `smoke-fallback-${project}`, commandArgs: ["--grep", smokeGrep, "--grep-invert", prGrep, "--project", project], }); if (code !== 0) { return code; } } return 0; } const groups = groupSpecsByProjects(selection.specProjects); if (groups.length === 0) { console.log("[playwright-pr] No changed-area E2E specs selected."); return 0; } if (selection.skippedDirectSpecFiles.length > 0) { console.log( `[playwright-pr] Source mappings selected changed-area specs; direct E2E file edits are covered by mapped/core gates: ${selection.skippedDirectSpecFiles.join( ", " )}` ); } for (const [index, group] of groups.entries()) { for (const project of group.projects) { const code = await runPlaywright({ label: `changed-area specs ${index + 1}/${groups.length} (${project})`, artifactSuffix: `changed-${index + 1}-${project}`, commandArgs: [...group.specs, "--project", project], }); 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; } if (args.coreOnly && args.changedOnly) { throw new Error("--core-only and --changed-only cannot be used together."); } await fs.access(playwrightCliPath); if (!args.changedOnly) { const coreCode = await runCorePrGate(); if (coreCode !== 0) { process.exitCode = coreCode; return; } } if (args.coreOnly) { 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; });