Resolve recommended-profile Critical and High findings, update vulnerable dependencies, restore invoice queue E2E authentication setup, and clear the remaining frontend Qodana findings.
760 lines
21 KiB
JavaScript
760 lines
21 KiB
JavaScript
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 execFileAsync = promisify(execFile);
|
|
const workingDirectory = process.cwd();
|
|
const playwrightCliPath = path.join(workingDirectory, "node_modules", "@playwright", "test", "cli.js");
|
|
const projects = ["chromium-desktop", "chromium-mobile"];
|
|
const outputDirectory = path.join(workingDirectory, "output", "playwright", "batched-chromium");
|
|
const testListDirectory = path.join(outputDirectory, "test-lists");
|
|
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const batchSize = normalizePositiveInt(args.batchSize ?? process.env.PLAYWRIGHT_BATCH_SIZE, 25);
|
|
const shardWorkers = normalizePositiveInt(
|
|
args.workers ?? process.env.PLAYWRIGHT_BATCH_WORKERS ?? process.env.PLAYWRIGHT_WORKERS,
|
|
2
|
|
);
|
|
const devPort = normalizePositiveInt(args.port ?? process.env.PLAYWRIGHT_BATCH_DEV_PORT ?? process.env.PLAYWRIGHT_DEV_PORT, 5193);
|
|
const baseURL = `http://localhost:${devPort}`;
|
|
const viteDevArgs = [
|
|
"run",
|
|
"dev",
|
|
"--",
|
|
"--host",
|
|
"localhost",
|
|
"--port",
|
|
String(devPort),
|
|
"--strictPort",
|
|
...(process.platform === "win32" ? ["--configLoader", "runner"] : []),
|
|
];
|
|
|
|
let activePlaywrightChild = null;
|
|
let activeDevServerProcess = null;
|
|
let isShuttingDown = false;
|
|
|
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
process.on(signal, () => {
|
|
void shutdown(signal).finally(() => {
|
|
process.exit(130);
|
|
});
|
|
});
|
|
}
|
|
|
|
function parseArgs(rawArgs) {
|
|
const parsed = {
|
|
rerunFailed: false,
|
|
dryRun: false,
|
|
help: false,
|
|
batchSize: null,
|
|
workers: null,
|
|
port: null,
|
|
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 === "--rerun-failed") {
|
|
parsed.rerunFailed = true;
|
|
continue;
|
|
}
|
|
|
|
if (value === "--dry-run") {
|
|
parsed.dryRun = true;
|
|
continue;
|
|
}
|
|
|
|
if (value === "--help" || value === "-h") {
|
|
parsed.help = true;
|
|
continue;
|
|
}
|
|
|
|
if (value === "--batch-size" && rawArgs[index + 1]) {
|
|
parsed.batchSize = rawArgs[index + 1];
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (value.startsWith("--batch-size=")) {
|
|
parsed.batchSize = value.slice("--batch-size=".length);
|
|
continue;
|
|
}
|
|
|
|
if (value === "--workers" && rawArgs[index + 1]) {
|
|
parsed.workers = rawArgs[index + 1];
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (value.startsWith("--workers=")) {
|
|
parsed.workers = value.slice("--workers=".length);
|
|
continue;
|
|
}
|
|
|
|
if (value === "--port" && rawArgs[index + 1]) {
|
|
parsed.port = rawArgs[index + 1];
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if (value.startsWith("--port=")) {
|
|
parsed.port = value.slice("--port=".length);
|
|
continue;
|
|
}
|
|
|
|
parsed.forwardedArgs.push(value);
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function normalizePositiveInt(value, fallback) {
|
|
const parsed = Number(value);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function stripAnsi(input) {
|
|
// eslint-disable-next-line no-control-regex -- ANSI escape sequences intentionally start with ESC.
|
|
return input.replace(new RegExp("\\x1b\\[[0-9;]*m", "g"), "");
|
|
}
|
|
|
|
function formatDuration(durationMs) {
|
|
return `${(durationMs / 1000).toFixed(1)}s`;
|
|
}
|
|
|
|
function chunkTests(testLines, size) {
|
|
const chunks = [];
|
|
for (let index = 0; index < testLines.length; index += size) {
|
|
const shard = Math.floor(index / size) + 1;
|
|
const lines = testLines.slice(index, index + size);
|
|
chunks.push({
|
|
shard,
|
|
start: index + 1,
|
|
end: index + lines.length,
|
|
lines,
|
|
});
|
|
}
|
|
return chunks;
|
|
}
|
|
|
|
function printUsage() {
|
|
console.log(`
|
|
Playwright batched chromium runner
|
|
|
|
Usage:
|
|
node scripts/run-playwright-batched-chromium.mjs [options] [-- <playwright args>]
|
|
|
|
Options:
|
|
--batch-size <n> Tests per chunk. Default: 25
|
|
--workers <n> PLAYWRIGHT_WORKERS for primary runs. Default: 2
|
|
--port <n> Vite dev server port. Default: 5193
|
|
--rerun-failed Re-run failed chunks with PLAYWRIGHT_WORKERS=1
|
|
--dry-run Compute and print chunk plan without executing tests
|
|
-h, --help Show help
|
|
`);
|
|
}
|
|
|
|
function validateForwardedArgs(forwardedArgs) {
|
|
for (const value of forwardedArgs) {
|
|
if (value === "--list" || value.startsWith("--list=")) {
|
|
throw new Error("--list is managed by this script and cannot be forwarded.");
|
|
}
|
|
|
|
if (value === "--project" || value.startsWith("--project=")) {
|
|
throw new Error("--project is managed by this script and cannot be forwarded.");
|
|
}
|
|
|
|
if (value === "--shard" || value.startsWith("--shard=")) {
|
|
throw new Error("--shard is managed by this script and cannot be forwarded.");
|
|
}
|
|
|
|
if (value === "--test-list" || value.startsWith("--test-list=")) {
|
|
throw new Error("--test-list is managed by this script and cannot be forwarded.");
|
|
}
|
|
|
|
if (value === "--test-list-invert" || value.startsWith("--test-list-invert=")) {
|
|
throw new Error("--test-list-invert is managed by this script and cannot be forwarded.");
|
|
}
|
|
}
|
|
}
|
|
|
|
function prefixStream(stream, prefix) {
|
|
const lineReader = readline.createInterface({ input: stream });
|
|
lineReader.on("line", (line) => {
|
|
process.stdout.write(`[${prefix}] ${line}\n`);
|
|
});
|
|
}
|
|
|
|
async function getListeningProcessOnWindows(port) {
|
|
const { stdout } = await execFileAsync("netstat", ["-ano", "-p", "tcp"], { cwd: workingDirectory });
|
|
const match = stdout.match(new RegExp(`^\\s*TCP\\s+[^\\s]+:${port}\\s+[^\\s]+\\s+LISTENING\\s+(\\d+)\\s*$`, "mi"));
|
|
if (!match) {
|
|
return null;
|
|
}
|
|
|
|
const pid = Number(match[1]);
|
|
const command = `Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}' | Select-Object -ExpandProperty CommandLine`;
|
|
const processResult = await execFileAsync("powershell", ["-NoProfile", "-Command", command], {
|
|
cwd: workingDirectory,
|
|
}).catch(() => ({ stdout: "" }));
|
|
|
|
return {
|
|
id: pid,
|
|
commandLine: processResult.stdout.trim(),
|
|
};
|
|
}
|
|
|
|
async function getListeningProcessOnUnix(port) {
|
|
try {
|
|
const { stdout } = await execFileAsync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-Fp", "-Fc"], {
|
|
cwd: workingDirectory,
|
|
});
|
|
const pidMatch = stdout.match(/^p(\d+)$/m);
|
|
const commandMatch = stdout.match(/^c(.+)$/m);
|
|
if (!pidMatch) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: Number(pidMatch[1]),
|
|
commandLine: commandMatch ? commandMatch[1] : "",
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function getListeningProcess(port) {
|
|
if (process.platform === "win32") {
|
|
return getListeningProcessOnWindows(port);
|
|
}
|
|
|
|
return getListeningProcessOnUnix(port);
|
|
}
|
|
|
|
async function killProcessTree(pid) {
|
|
if (!pid) {
|
|
return;
|
|
}
|
|
|
|
if (process.platform === "win32") {
|
|
await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"], { cwd: workingDirectory }).catch(() => {});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
process.kill(-pid, "SIGTERM");
|
|
} catch {
|
|
try {
|
|
process.kill(pid, "SIGTERM");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
async function waitForServerReady(url, timeoutMs = 120_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(url, { redirect: "manual" });
|
|
if (response.status < 500) {
|
|
return;
|
|
}
|
|
} catch {
|
|
// keep polling
|
|
}
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
}
|
|
|
|
throw new Error(`Timed out waiting for Vite server at ${url}.`);
|
|
}
|
|
|
|
async function startDevServer() {
|
|
const existingProcess = await getListeningProcess(devPort);
|
|
if (existingProcess) {
|
|
if (!/vite(?:\.js)?/i.test(existingProcess.commandLine || "")) {
|
|
throw new Error(`Port ${devPort} is already in use by a non-Vite process: ${existingProcess.commandLine}`);
|
|
}
|
|
|
|
await killProcessTree(existingProcess.id);
|
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
}
|
|
|
|
activeDevServerProcess =
|
|
process.platform === "win32"
|
|
? spawn(
|
|
"cmd.exe",
|
|
["/d", "/s", "/c", `npm.cmd ${viteDevArgs.join(" ")}`],
|
|
{
|
|
cwd: workingDirectory,
|
|
env: {
|
|
...process.env,
|
|
PLAYWRIGHT: "1",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
}
|
|
)
|
|
: spawn("npm", viteDevArgs, {
|
|
cwd: workingDirectory,
|
|
env: {
|
|
...process.env,
|
|
PLAYWRIGHT: "1",
|
|
},
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
|
|
prefixStream(activeDevServerProcess.stdout, "dev-server");
|
|
prefixStream(activeDevServerProcess.stderr, "dev-server:err");
|
|
|
|
await waitForServerReady(baseURL);
|
|
console.log(`Vite dev server is ready at ${baseURL}.`);
|
|
}
|
|
|
|
async function stopDevServer() {
|
|
if (!activeDevServerProcess) {
|
|
return;
|
|
}
|
|
|
|
await killProcessTree(activeDevServerProcess.pid);
|
|
activeDevServerProcess = null;
|
|
}
|
|
|
|
async function runPlaywrightCommand(commandArgs, envOverrides = {}, inheritOutput = false) {
|
|
if (inheritOutput) {
|
|
return new Promise((resolve) => {
|
|
activePlaywrightChild = spawn(process.execPath, [playwrightCliPath, ...commandArgs], {
|
|
cwd: workingDirectory,
|
|
env: {
|
|
...process.env,
|
|
...envOverrides,
|
|
},
|
|
stdio: "inherit",
|
|
windowsHide: true,
|
|
});
|
|
|
|
activePlaywrightChild.on("close", (code) => {
|
|
activePlaywrightChild = null;
|
|
resolve({
|
|
code: code ?? 1,
|
|
stdout: "",
|
|
stderr: "",
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
try {
|
|
const result = await execFileAsync(process.execPath, [playwrightCliPath, ...commandArgs], {
|
|
cwd: workingDirectory,
|
|
env: {
|
|
...process.env,
|
|
...envOverrides,
|
|
},
|
|
maxBuffer: 30 * 1024 * 1024,
|
|
windowsHide: true,
|
|
});
|
|
|
|
return {
|
|
code: 0,
|
|
stdout: result.stdout,
|
|
stderr: result.stderr,
|
|
};
|
|
} catch (error) {
|
|
const stdout = typeof error.stdout === "string" ? error.stdout : "";
|
|
const stderr = typeof error.stderr === "string" ? error.stderr : "";
|
|
const code = typeof error.code === "number" ? error.code : 1;
|
|
return {
|
|
code,
|
|
stdout,
|
|
stderr,
|
|
};
|
|
}
|
|
}
|
|
|
|
async function preflightCollectTests(forwardedArgs) {
|
|
const commandArgs = [
|
|
"test",
|
|
...projects.flatMap((project) => ["--project", project]),
|
|
"--list",
|
|
...forwardedArgs,
|
|
];
|
|
const result = await runPlaywrightCommand(
|
|
commandArgs,
|
|
{
|
|
PLAYWRIGHT_BASE_URL: baseURL,
|
|
PLAYWRIGHT: "1",
|
|
},
|
|
false
|
|
);
|
|
|
|
const combinedOutput = stripAnsi(`${result.stdout}\n${result.stderr}`);
|
|
const totalMatch = combinedOutput.match(/Total:\s+(\d+)\s+tests?/i);
|
|
if (!totalMatch) {
|
|
throw new Error(`Failed to parse Playwright test count from --list output.\n${combinedOutput}`);
|
|
}
|
|
|
|
const listedTests = combinedOutput
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.filter((line) => line.startsWith("["));
|
|
|
|
const parsedTotal = Number(totalMatch[1]);
|
|
if (listedTests.length === 0) {
|
|
throw new Error("Playwright --list returned zero runnable tests.");
|
|
}
|
|
|
|
if (listedTests.length !== parsedTotal) {
|
|
console.warn(
|
|
`Warning: --list reported ${parsedTotal} tests but ${listedTests.length} test lines were parsed. Using parsed lines.`
|
|
);
|
|
}
|
|
|
|
return {
|
|
totalTests: listedTests.length,
|
|
listedTests,
|
|
};
|
|
}
|
|
|
|
async function writeTestListFile(passLabel, chunk, totalShards) {
|
|
await fs.mkdir(testListDirectory, { recursive: true });
|
|
const filename = `${passLabel}-shard-${chunk.shard}-of-${totalShards}.txt`;
|
|
const targetPath = path.join(testListDirectory, filename);
|
|
await fs.writeFile(targetPath, `${chunk.lines.join("\n")}\n`, "utf8");
|
|
return targetPath;
|
|
}
|
|
|
|
async function runShard({ chunk, totalShards, workers, passLabel, forwardedArgs }) {
|
|
const testListPath = await writeTestListFile(passLabel, chunk, totalShards);
|
|
const commandArgs = [
|
|
"test",
|
|
...projects.flatMap((project) => ["--project", project]),
|
|
"--test-list",
|
|
testListPath,
|
|
...forwardedArgs,
|
|
];
|
|
|
|
const isRerun = passLabel === "rerun";
|
|
const artifactNamespace = isRerun
|
|
? `batched-chromium-rerun-shard-${chunk.shard}-of-${totalShards}`
|
|
: `batched-chromium-shard-${chunk.shard}-of-${totalShards}`;
|
|
|
|
console.log(
|
|
`[${passLabel}] Running shard ${chunk.shard}/${totalShards} (${chunk.start}-${chunk.end}, ${chunk.lines.length} tests, workers=${workers})`
|
|
);
|
|
|
|
const startedAt = Date.now();
|
|
const result = await runPlaywrightCommand(
|
|
commandArgs,
|
|
{
|
|
PLAYWRIGHT_BASE_URL: baseURL,
|
|
PLAYWRIGHT_DEV_PORT: String(devPort),
|
|
PLAYWRIGHT_WORKERS: String(workers),
|
|
PLAYWRIGHT_ARTIFACT_NAMESPACE: artifactNamespace,
|
|
PLAYWRIGHT_REPORTER_MODE: "line-html",
|
|
PLAYWRIGHT: "1",
|
|
},
|
|
true
|
|
);
|
|
|
|
return {
|
|
pass: passLabel,
|
|
shard: chunk.shard,
|
|
totalShards,
|
|
tests: chunk.lines.length,
|
|
start: chunk.start,
|
|
end: chunk.end,
|
|
workers,
|
|
code: result.code,
|
|
durationMs: Date.now() - startedAt,
|
|
artifactNamespace,
|
|
testListPath: path.relative(workingDirectory, testListPath),
|
|
};
|
|
}
|
|
|
|
function printSummary(primaryResults, rerunResults) {
|
|
const rows = [...primaryResults, ...rerunResults];
|
|
console.log("\nBatched Chromium Summary");
|
|
console.log("pass\tshard\tstatus\ttests\tworkers\tduration\tartifact");
|
|
for (const row of rows) {
|
|
const status = row.code === 0 ? "passed" : "failed";
|
|
console.log(
|
|
`${row.pass}\t${row.shard}/${row.totalShards}\t${status}\t${row.start}-${row.end} (${row.tests})\t${row.workers}\t${formatDuration(row.durationMs)}\t${row.artifactNamespace}`
|
|
);
|
|
}
|
|
}
|
|
|
|
async function writeRunArtifacts({ totalTests, totalShards, chunks, primaryResults, rerunResults }) {
|
|
const failedPrimaryShards = primaryResults.filter((result) => result.code !== 0).map((result) => result.shard);
|
|
const failedRerunShards = rerunResults.filter((result) => result.code !== 0).map((result) => result.shard);
|
|
|
|
await fs.mkdir(outputDirectory, { recursive: true });
|
|
|
|
const runReport = {
|
|
generatedAt: new Date().toISOString(),
|
|
baseURL,
|
|
projects,
|
|
batchSize,
|
|
totalTests,
|
|
totalShards,
|
|
primaryWorkers: shardWorkers,
|
|
rerunFailed: args.rerunFailed,
|
|
forwardedArgs: args.forwardedArgs,
|
|
chunks: chunks.map((chunk) => ({
|
|
shard: chunk.shard,
|
|
start: chunk.start,
|
|
end: chunk.end,
|
|
tests: chunk.lines.length,
|
|
})),
|
|
primaryResults,
|
|
rerunResults,
|
|
failedPrimaryShards,
|
|
failedRerunShards,
|
|
};
|
|
|
|
await fs.writeFile(path.join(outputDirectory, "last-run.json"), JSON.stringify(runReport, null, 2), "utf8");
|
|
await fs.writeFile(path.join(outputDirectory, "failed-shards.json"), JSON.stringify(failedPrimaryShards, null, 2), "utf8");
|
|
|
|
const primaryRows = primaryResults
|
|
.map((result) => {
|
|
const status = result.code === 0 ? "passed" : "failed";
|
|
const color = result.code === 0 ? "#166534" : "#991b1b";
|
|
return `<tr>
|
|
<td>${result.shard}/${result.totalShards}</td>
|
|
<td>${result.start}-${result.end} (${result.tests})</td>
|
|
<td style="color:${color};font-weight:600">${status}</td>
|
|
<td>${result.workers}</td>
|
|
<td>${formatDuration(result.durationMs)}</td>
|
|
<td>${result.testListPath}</td>
|
|
<td><a href="../${result.artifactNamespace}/report/index.html">Open report</a></td>
|
|
</tr>`;
|
|
})
|
|
.join("\n");
|
|
|
|
const rerunRows =
|
|
rerunResults.length === 0
|
|
? "<tr><td colspan=\"7\">No rerun shards executed.</td></tr>"
|
|
: rerunResults
|
|
.map((result) => {
|
|
const status = result.code === 0 ? "passed" : "failed";
|
|
const color = result.code === 0 ? "#166534" : "#991b1b";
|
|
return `<tr>
|
|
<td>${result.shard}/${result.totalShards}</td>
|
|
<td>${result.start}-${result.end} (${result.tests})</td>
|
|
<td style="color:${color};font-weight:600">${status}</td>
|
|
<td>${result.workers}</td>
|
|
<td>${formatDuration(result.durationMs)}</td>
|
|
<td>${result.testListPath}</td>
|
|
<td><a href="../${result.artifactNamespace}/report/index.html">Open report</a></td>
|
|
</tr>`;
|
|
})
|
|
.join("\n");
|
|
|
|
const html = `<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>Batched Chromium Playwright Report Index</title>
|
|
<style>
|
|
body { font-family: Segoe UI, Arial, sans-serif; margin: 32px; color: #111827; }
|
|
h1, h2 { margin-bottom: 8px; }
|
|
p { color: #4b5563; }
|
|
table { border-collapse: collapse; width: 100%; margin: 16px 0 28px; }
|
|
th, td { border: 1px solid #d1d5db; padding: 8px 10px; text-align: left; }
|
|
th { background: #f3f4f6; }
|
|
a { color: #1d4ed8; text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
code { background: #f3f4f6; padding: 2px 6px; border-radius: 4px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Batched Chromium Playwright Reports</h1>
|
|
<p>Total tests: <strong>${totalTests}</strong> | Batch size: <strong>${batchSize}</strong> | Shards: <strong>${totalShards}</strong></p>
|
|
<p>JSON summary: <code>output/playwright/batched-chromium/last-run.json</code></p>
|
|
|
|
<h2>Primary pass</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Shard</th>
|
|
<th>Test range</th>
|
|
<th>Status</th>
|
|
<th>Workers</th>
|
|
<th>Duration</th>
|
|
<th>Test list</th>
|
|
<th>Report</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${primaryRows}
|
|
</tbody>
|
|
</table>
|
|
|
|
<h2>Rerun pass</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Shard</th>
|
|
<th>Test range</th>
|
|
<th>Status</th>
|
|
<th>Workers</th>
|
|
<th>Duration</th>
|
|
<th>Test list</th>
|
|
<th>Report</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${rerunRows}
|
|
</tbody>
|
|
</table>
|
|
</body>
|
|
</html>`;
|
|
|
|
await fs.writeFile(path.join(outputDirectory, "report-index.html"), html, "utf8");
|
|
}
|
|
|
|
async function shutdown(signal) {
|
|
if (isShuttingDown) {
|
|
return;
|
|
}
|
|
|
|
isShuttingDown = true;
|
|
console.error(`Received ${signal}. Stopping active Playwright chunk and dev server...`);
|
|
|
|
await Promise.all([
|
|
killProcessTree(activePlaywrightChild?.pid),
|
|
killProcessTree(activeDevServerProcess?.pid),
|
|
]);
|
|
|
|
activePlaywrightChild = null;
|
|
activeDevServerProcess = null;
|
|
}
|
|
|
|
async function main() {
|
|
if (args.help) {
|
|
printUsage();
|
|
return;
|
|
}
|
|
|
|
validateForwardedArgs(args.forwardedArgs);
|
|
await fs.access(playwrightCliPath);
|
|
|
|
const preflight = await preflightCollectTests(args.forwardedArgs);
|
|
const chunks = chunkTests(preflight.listedTests, batchSize);
|
|
const totalShards = chunks.length;
|
|
|
|
console.log(
|
|
`Batched Chromium preflight: ${preflight.totalTests} tests across ${projects.join(", ")} -> ${totalShards} shards at ${batchSize} tests/chunk.`
|
|
);
|
|
|
|
if (args.dryRun) {
|
|
await fs.mkdir(outputDirectory, { recursive: true });
|
|
const dryRunFile = path.join(outputDirectory, "dry-run.json");
|
|
await fs.writeFile(
|
|
dryRunFile,
|
|
JSON.stringify(
|
|
{
|
|
generatedAt: new Date().toISOString(),
|
|
baseURL,
|
|
projects,
|
|
batchSize,
|
|
shardWorkers,
|
|
totalTests: preflight.totalTests,
|
|
totalShards,
|
|
rerunFailed: args.rerunFailed,
|
|
forwardedArgs: args.forwardedArgs,
|
|
shards: chunks.map((chunk) => ({
|
|
shard: chunk.shard,
|
|
start: chunk.start,
|
|
end: chunk.end,
|
|
tests: chunk.lines.length,
|
|
})),
|
|
},
|
|
null,
|
|
2
|
|
),
|
|
"utf8"
|
|
);
|
|
console.log(`Dry run complete. Summary written to ${path.relative(workingDirectory, dryRunFile)}.`);
|
|
return;
|
|
}
|
|
|
|
await startDevServer();
|
|
|
|
const primaryResults = [];
|
|
for (const chunk of chunks) {
|
|
primaryResults.push(
|
|
await runShard({
|
|
chunk,
|
|
totalShards,
|
|
workers: shardWorkers,
|
|
passLabel: "primary",
|
|
forwardedArgs: args.forwardedArgs,
|
|
})
|
|
);
|
|
}
|
|
|
|
const failedPrimaryShards = primaryResults.filter((result) => result.code !== 0).map((result) => result.shard);
|
|
const rerunResults = [];
|
|
|
|
if (args.rerunFailed && failedPrimaryShards.length > 0) {
|
|
console.log(`\nRe-running failed chunks with workers=1: ${failedPrimaryShards.join(", ")}`);
|
|
for (const shard of failedPrimaryShards) {
|
|
const chunk = chunks[shard - 1];
|
|
rerunResults.push(
|
|
await runShard({
|
|
chunk,
|
|
totalShards,
|
|
workers: 1,
|
|
passLabel: "rerun",
|
|
forwardedArgs: args.forwardedArgs,
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
printSummary(primaryResults, rerunResults);
|
|
await writeRunArtifacts({
|
|
totalTests: preflight.totalTests,
|
|
totalShards,
|
|
chunks,
|
|
primaryResults,
|
|
rerunResults,
|
|
});
|
|
await stopDevServer();
|
|
|
|
const failedRerunShards = rerunResults.filter((result) => result.code !== 0).map((result) => result.shard);
|
|
|
|
if (failedPrimaryShards.length === 0) {
|
|
console.log("\nAll batched Chromium chunks passed.");
|
|
return;
|
|
}
|
|
|
|
console.error(`\nPrimary failed shards: ${failedPrimaryShards.join(", ")}`);
|
|
if (args.rerunFailed) {
|
|
if (failedRerunShards.length > 0) {
|
|
console.error(`Rerun failed shards: ${failedRerunShards.join(", ")}`);
|
|
} else {
|
|
console.error("All failed shards passed during rerun. Treating this as flaky and returning non-zero.");
|
|
}
|
|
}
|
|
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
await main().catch(async (error) => {
|
|
console.error(error instanceof Error ? error.message : error);
|
|
await stopDevServer();
|
|
process.exitCode = 1;
|
|
});
|