Resolve recommended-profile Critical and High findings, update vulnerable dependencies, restore invoice queue E2E authentication setup, and clear the remaining frontend Qodana findings.
493 lines
12 KiB
JavaScript
493 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}`);
|
|
}
|
|
|
|
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;
|
|
});
|