Files
pleno-vue/scripts/run-playwright-batched-chromium.mjs
Jeppe B 1729443cc3 Resolve frontend Qodana critical and high findings (#176)
Resolve recommended-profile Critical and High findings, update vulnerable dependencies, restore invoice queue E2E authentication setup, and clear the remaining frontend Qodana findings.
2026-07-17 06:22:51 +02:00

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;
});