Files
iptvnator/tools/coverage/e2e-shard-reports.mjs
T
4grayandClaude Fable 5.1 497b6076fa ci(e2e): shard the Electron Playwright suite per OS (#1700)
Run the sequential Electron E2E suite as three Playwright shards per OS
(one runner each) and summarize all shards of an OS in one follow-up job.
The semantic summary script accepts a directory of shard reports, merges
them and refuses to write when a shard is missing, duplicated or
malformed, or when an explicit input does not exist.

Slowest shard per OS in the final run: ubuntu 12.5 min (was 26),
macOS 13.7 min (was 34), Windows 24.5 min (was 35).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 21:49:07 +02:00

149 lines
5.4 KiB
JavaScript

import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
/**
* Helpers for reading one or more Playwright JSON reports (`results.json`).
*
* The Electron E2E workflow runs the suite as several Playwright shards, one
* per runner. Each shard writes its own `results.json` that carries
* `config.shard = { current, total }` and only that shard's tests. A semantic
* summary is only honest when every shard is present exactly once, so callers
* verify the set before merging.
*/
export const playwrightReportFileName = 'results.json';
/** Recursively lists every `results.json` below `rootDir`, sorted by path. */
export function findPlaywrightJsonReports(rootDir) {
if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) {
return [];
}
const found = [];
for (const entry of readdirSync(rootDir)) {
const fullPath = path.join(rootDir, entry);
if (statSync(fullPath).isDirectory()) {
found.push(...findPlaywrightJsonReports(fullPath));
} else if (entry === playwrightReportFileName) {
found.push(fullPath);
}
}
return found.sort();
}
/**
* Parses each report and extracts its shard descriptor: `null` when the run
* was not sharded (`config.shard` absent or `null`). A descriptor that is
* present but malformed is kept as `malformedShard` so verification rejects
* it instead of mistaking a partial run for a complete unsharded one.
*/
export function loadPlaywrightReports(reportPaths) {
return reportPaths.map((reportPath) => {
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
const shard = report.config?.shard ?? null;
const wellFormed =
shard !== null &&
Number.isInteger(shard.current) &&
Number.isInteger(shard.total);
return {
path: reportPath,
report,
shard: wellFormed ? { current: shard.current, total: shard.total } : null,
malformedShard: shard !== null && !wellFormed,
};
});
}
/**
* Checks that the loaded reports form exactly one complete run: either a
* single unsharded report, or every shard `1..total` exactly once.
*/
export function verifyShardReports(reports) {
const problems = [];
if (reports.length === 0) {
problems.push('no Playwright JSON reports were provided');
return { ok: false, problems };
}
const malformed = reports.filter((entry) => entry.malformedShard);
if (malformed.length > 0) {
problems.push(`malformed config.shard descriptor: ${describePaths(malformed)}`);
}
const unsharded = reports.filter(
(entry) => entry.shard === null && !entry.malformedShard
);
const sharded = reports.filter((entry) => entry.shard !== null);
if (unsharded.length > 0 && sharded.length > 0) {
problems.push(
`mixed sharded and unsharded reports: ${describePaths(unsharded)} carry no shard descriptor`
);
}
if (unsharded.length > 1) {
problems.push(
`${unsharded.length} unsharded reports would count every test more than once: ${describePaths(unsharded)}`
);
}
if (sharded.length > 0) {
const totals = new Set(sharded.map((entry) => entry.shard.total));
if (totals.size > 1) {
problems.push(
`shard totals disagree: ${Array.from(totals).sort().join(', ')}`
);
} else {
const [total] = totals;
const seen = new Map();
for (const entry of sharded) {
const list = seen.get(entry.shard.current) ?? [];
list.push(entry);
seen.set(entry.shard.current, list);
}
const missing = [];
for (let index = 1; index <= total; index += 1) {
if (!seen.has(index)) {
missing.push(`${index}/${total}`);
}
}
if (missing.length > 0) {
problems.push(`missing shards: ${missing.join(', ')}`);
}
for (const [current, entries] of seen) {
if (entries.length > 1) {
problems.push(
`shard ${current}/${total} appears ${entries.length} times: ${describePaths(entries)}`
);
}
if (current < 1 || current > total) {
problems.push(`shard ${current}/${total} is outside 1..${total}`);
}
}
}
}
return { ok: problems.length === 0, problems };
}
/** Human-readable label for the summary, e.g. `3/3 shards (1/3, 2/3, 3/3)`. */
export function describeShardReports(reports) {
if (reports.length === 0) {
return 'none';
}
const sharded = reports.filter((entry) => entry.shard !== null);
if (sharded.length === 0) {
return reports.length === 1
? '1 report (unsharded)'
: `${reports.length} reports (unsharded)`;
}
const total = Math.max(...sharded.map((entry) => entry.shard.total));
const labels = sharded
.map((entry) => entry.shard)
.sort((left, right) => left.current - right.current)
.map((shard) => `${shard.current}/${shard.total}`);
return `${sharded.length}/${total} shards (${labels.join(', ')})`;
}
function describePaths(entries) {
return entries.map((entry) => entry.path).join(', ');
}