Files
pleno-vue/scripts/run-playwright-pr.mjs
T

465 lines
11 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\/.+\.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();
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;
}
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;
});