497 lines
12 KiB
JavaScript
497 lines
12 KiB
JavaScript
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] [-- <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.
|
|
--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}`);
|
|
}
|
|
|
|
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",
|
|
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;
|
|
});
|