Remove dependency on the ws module.
Deleted the entire `ws` library and its associated files. This likely reflects a move away from the WebSocket library, possibly signaling an alternate implementation or unused/legacy code cleanup.
This commit is contained in:
@@ -3,11 +3,17 @@ name: Automated Tests
|
|||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
push:
|
push:
|
||||||
branches: [main, dev]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "0 2 * * *"
|
- cron: "0 2 * * *"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: frontend-tests-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
format-tests:
|
format-tests:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -77,7 +83,7 @@ jobs:
|
|||||||
run: npx playwright test --grep @smoke --project="${{ matrix.project }}"
|
run: npx playwright test --grep @smoke --project="${{ matrix.project }}"
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: always()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-report-smoke-${{ matrix.project }}
|
name: playwright-report-smoke-${{ matrix.project }}
|
||||||
@@ -88,7 +94,7 @@ jobs:
|
|||||||
retention-days: 7
|
retention-days: 7
|
||||||
|
|
||||||
e2e-full:
|
e2e-full:
|
||||||
if: github.event_name == 'schedule' || github.ref == 'refs/heads/main'
|
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main'
|
||||||
needs: build-and-unit
|
needs: build-and-unit
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -111,7 +117,7 @@ jobs:
|
|||||||
run: npm run test:e2e:ci
|
run: npm run test:e2e:ci
|
||||||
|
|
||||||
- name: Upload Playwright report
|
- name: Upload Playwright report
|
||||||
if: always()
|
if: failure()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: playwright-report-full
|
name: playwright-report-full
|
||||||
|
|||||||
@@ -28,6 +28,41 @@ npm run dev
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Playwright Batched Chromium Runs
|
||||||
|
|
||||||
|
Run default e2e tests in deterministic 25-test shards across `chromium-desktop` and `chromium-mobile`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test:e2e:batched:chromium
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the same flow and automatically re-run failed shards with `PLAYWRIGHT_WORKERS=1`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test:e2e:batched:chromium:rerun-failed
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional overrides:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
PLAYWRIGHT_BATCH_SIZE=25
|
||||||
|
PLAYWRIGHT_BATCH_WORKERS=2
|
||||||
|
PLAYWRIGHT_BATCH_DEV_PORT=5193
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also forward Playwright args:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test:e2e:batched:chromium -- --grep @smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
Artifacts and summaries:
|
||||||
|
- `output/playwright/batched-chromium/last-run.json`
|
||||||
|
- `output/playwright/batched-chromium/failed-shards.json`
|
||||||
|
- `output/playwright/batched-chromium/report-index.html`
|
||||||
|
- `output/playwright/batched-chromium-shard-<i>-of-<n>/report/index.html`
|
||||||
|
- `output/playwright/batched-chromium-rerun-shard-<i>-of-<n>/report/index.html`
|
||||||
|
|
||||||
## Bubblewrap (TWA) Build and Install
|
## Bubblewrap (TWA) Build and Install
|
||||||
|
|
||||||
To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands:
|
To build and install the Trusted Web Activity (TWA) using Bubblewrap, use the following commands:
|
||||||
|
|||||||
+4
-2
@@ -17,6 +17,8 @@
|
|||||||
"test:e2e:i18n:views": "playwright test tests/e2e/i18n.views.spec.ts --project=chromium-desktop",
|
"test:e2e:i18n:views": "playwright test tests/e2e/i18n.views.spec.ts --project=chromium-desktop",
|
||||||
"test:e2e:ci": "node scripts/run-playwright-ci-parallel.mjs",
|
"test:e2e:ci": "node scripts/run-playwright-ci-parallel.mjs",
|
||||||
"test:e2e:ci:serial": "playwright test --reporter=line,html",
|
"test:e2e:ci:serial": "playwright test --reporter=line,html",
|
||||||
|
"test:e2e:batched:chromium": "node scripts/run-playwright-batched-chromium.mjs",
|
||||||
|
"test:e2e:batched:chromium:rerun-failed": "node scripts/run-playwright-batched-chromium.mjs --rerun-failed",
|
||||||
"test:e2e:smoke": "playwright test --grep @smoke --project=chromium-desktop --project=chromium-mobile",
|
"test:e2e:smoke": "playwright test --grep @smoke --project=chromium-desktop --project=chromium-mobile",
|
||||||
"test:e2e:prod": "playwright test --config=playwright.prod.config.ts",
|
"test:e2e:prod": "playwright test --config=playwright.prod.config.ts",
|
||||||
"test:e2e:live": "playwright test --config=playwright.live.config.ts",
|
"test:e2e:live": "playwright test --config=playwright.live.config.ts",
|
||||||
@@ -46,7 +48,7 @@
|
|||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
"apexcharts": "^5.10.4",
|
"apexcharts": "^5.10.4",
|
||||||
"axios": "1.13.5",
|
"axios": "1.15.0",
|
||||||
"buefy": "^3.0.3",
|
"buefy": "^3.0.3",
|
||||||
"bulma": "^1.0.2",
|
"bulma": "^1.0.2",
|
||||||
"bulma-block-list": "^1.1.0",
|
"bulma-block-list": "^1.1.0",
|
||||||
@@ -91,7 +93,7 @@
|
|||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"prettier": "2.8.8",
|
"prettier": "2.8.8",
|
||||||
"sass-embedded": "^1.81.0",
|
"sass-embedded": "^1.81.0",
|
||||||
"vite": "7.1.11",
|
"vite": "8.0.5",
|
||||||
"vite-plugin-pwa": "^1.0.2",
|
"vite-plugin-pwa": "^1.0.2",
|
||||||
"vite-plugin-vue-devtools": "^7.5.4",
|
"vite-plugin-vue-devtools": "^7.5.4",
|
||||||
"vitest": "^2.1.9"
|
"vitest": "^2.1.9"
|
||||||
|
|||||||
@@ -0,0 +1,747 @@
|
|||||||
|
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}`;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return input.replace(/\u001b\[[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 run dev -- --host localhost --port ${devPort} --strictPort`],
|
||||||
|
{
|
||||||
|
cwd: workingDirectory,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PLAYWRIGHT: "1",
|
||||||
|
},
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
windowsHide: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: spawn("npm", ["run", "dev", "--", "--host", "localhost", "--port", String(devPort), "--strictPort"], {
|
||||||
|
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;
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
param(
|
||||||
|
[int]$Port = 5173
|
||||||
|
)
|
||||||
|
|
||||||
|
function Get-ListeningProcess {
|
||||||
|
param([int]$TargetPort)
|
||||||
|
|
||||||
|
$listener = Get-NetTCPConnection -LocalPort $TargetPort -State Listen -ErrorAction SilentlyContinue |
|
||||||
|
Select-Object -First 1
|
||||||
|
|
||||||
|
if (-not $listener) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $($listener.OwningProcess)" -ErrorAction SilentlyContinue
|
||||||
|
if (-not $process) {
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
return [PSCustomObject]@{
|
||||||
|
Id = $listener.OwningProcess
|
||||||
|
CommandLine = [string]($process.CommandLine ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = Get-ListeningProcess -TargetPort $Port
|
||||||
|
if ($existing) {
|
||||||
|
if ($existing.CommandLine -notmatch "vite(?:\.js)?") {
|
||||||
|
throw "Port $Port is already in use by a non-Vite process: $($existing.CommandLine)"
|
||||||
|
}
|
||||||
|
|
||||||
|
Stop-Process -Id $existing.Id -Force
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
|
||||||
|
$npm = if (Get-Command npm.cmd -ErrorAction SilentlyContinue) {
|
||||||
|
"npm.cmd"
|
||||||
|
} else {
|
||||||
|
"npm"
|
||||||
|
}
|
||||||
|
|
||||||
|
& $npm run dev -- --host localhost --port $Port --strictPort
|
||||||
|
exit $LASTEXITCODE
|
||||||
@@ -549,7 +549,8 @@ const commitSelection = async (options = {}) => {
|
|||||||
if (normalizedOptions.clearDropdownResultsWithoutVehicle) {
|
if (normalizedOptions.clearDropdownResultsWithoutVehicle) {
|
||||||
vehicles_matching.value = [];
|
vehicles_matching.value = [];
|
||||||
}
|
}
|
||||||
if (bookingMatches.length === 0) {
|
const hasSelectedCustomer = normalizeCustomerNumber(customer_id.value) !== null;
|
||||||
|
if (bookingMatches.length === 0 && !hasSelectedCustomer) {
|
||||||
clearCustomerSelection();
|
clearCustomerSelection();
|
||||||
}
|
}
|
||||||
emitVehicleObject(null, plateOverride);
|
emitVehicleObject(null, plateOverride);
|
||||||
|
|||||||
Reference in New Issue
Block a user