#!/usr/bin/env node // Rebalances the test:e2e:ci-smoke: spec lists in package.json from the // per-test durations Playwright's list reporter prints in recent successful // Test runs on main. Usage: // node scripts/balance-smoke-shards.mjs [--runs id,id] [--count 6] [--repo owner/name] [--write] import { execFile } from 'node:child_process'; import { readFileSync, writeFileSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const PACKAGE_JSON = resolve(ROOT, 'package.json'); const COMBINED_SCRIPT = 'test:e2e:ci-smoke'; const SHARD_SCRIPT = /^test:e2e:ci-smoke:(\d+)$/; const SHARD_JOB = /^variant-smoke \((\d+)\)$/; const SPEC_TOKEN = /^e2e\/\S+\.spec\.[cm]?[jt]sx?$/; const UNIT_MS = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }; // One finished test result from the list reporter, after the optional // Actions timestamp: " ✓ 12 [chromium] › e2e/x.spec.ts:59:3 › title (28.2s)". // A retried test prints one line per attempt, so failed attempts count too. const RESULT_LINE = /^(?:\d{4}-\d\d-\d\dT\S+Z )?\s+[✓✘]\s+\d+\s+\[[^\]]+\]\s+›\s+(e2e\/\S+?\.spec\.[cm]?[jt]sx?):\d+:\d+\s+›.*\((\d+(?:\.\d+)?)(ms|s|m|h|d)\)\s*$/; export function stripAnsi(text) { return text.replace(/\x1B\[[0-9;?]*[ -/]*[@-~]/g, ''); } export function parseDurationMs(value, unit) { return Math.round(Number(value) * UNIT_MS[unit]); } export function specSecondsFromLog(log) { const totals = new Map(); for (const line of stripAnsi(log).split(/\r?\n/)) { const match = RESULT_LINE.exec(line); if (!match) continue; const [, spec, value, unit] = match; totals.set(spec, (totals.get(spec) ?? 0) + parseDurationMs(value, unit)); } return totals; } export function median(values) { if (values.length === 0) throw new Error('median of no values'); const sorted = [...values].sort((a, b) => a - b); const mid = Math.floor(sorted.length / 2); return sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2); } // runs: one Map per run. Specs with no measurement get the median // of the measured specs' medians, flagged so the output can say so. export function estimateSpecs(specs, runs) { const measured = new Map(); for (const spec of specs) { const samples = runs.filter((run) => run.has(spec)).map((run) => run.get(spec)); if (samples.length) measured.set(spec, { ms: median(samples), samples, estimated: false }); } const fallback = measured.size ? median([...measured.values()].map((entry) => entry.ms)) : 1000; return new Map( specs.map((spec) => [spec, measured.get(spec) ?? { ms: fallback, samples: [], estimated: true }]), ); } export function shardLoads(shards, msBySpec) { return shards.map((shard) => shard.reduce((sum, spec) => sum + msBySpec.get(spec), 0)); } // Lexicographic order on loads sorted descending: lowering the largest shard // wins first, and ties on the max are broken by the next largest. function compareLoadVectors(a, b) { const x = [...a].sort((p, q) => q - p); const y = [...b].sort((p, q) => q - p); for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return x[i] - y[i]; return 0; } function longestFirst(specs, msBySpec) { return [...specs].sort((a, b) => msBySpec.get(b) - msBySpec.get(a) || a.localeCompare(b)); } function placeLongestFirst(shards, specs, msBySpec) { const loads = shardLoads(shards, msBySpec); for (const spec of longestFirst(specs, msBySpec)) { const target = loads.indexOf(Math.min(...loads)); shards[target].push(spec); loads[target] += msBySpec.get(spec); } return shards; } // Repeats the single move or pairwise swap that most improves the load // vector until none does or the largest shard is within goodEnoughMs. Each // accepted step strictly decreases the vector, so this ends. function improve(start, msBySpec, goodEnoughMs) { let shards = start.map((shard) => [...shard].sort()); for (;;) { let best = null; let bestLoads = shardLoads(shards, msBySpec); if (Math.max(...bestLoads) <= goodEnoughMs) return shards; const consider = (candidate) => { const loads = shardLoads(candidate, msBySpec); if (compareLoadVectors(loads, bestLoads) < 0) { best = candidate; bestLoads = loads; } }; for (let from = 0; from < shards.length; from++) { for (const spec of shards[from]) { for (let to = 0; to < shards.length; to++) { if (to === from || shards[from].length === 1) continue; const next = shards.map((shard) => [...shard]); next[from] = next[from].filter((s) => s !== spec); next[to] = [...next[to], spec].sort(); consider(next); } for (let other = from + 1; other < shards.length; other++) { for (const swapWith of shards[other]) { const next = shards.map((shard) => [...shard]); next[from] = [...next[from].filter((s) => s !== spec), swapWith].sort(); next[other] = [...next[other].filter((s) => s !== swapWith), spec].sort(); consider(next); } } } } if (!best) return shards; shards = best; } } function assertPartition(shards, specs, label) { const flat = shards.flat(); const expected = [...specs].sort(); const actual = [...flat].sort(); if (new Set(flat).size !== flat.length || JSON.stringify(actual) !== JSON.stringify(expected)) { throw new Error(`${label}: shards do not partition the smoke spec set exactly`); } if (shards.some((shard) => shard.length === 0)) throw new Error(`${label}: a shard is empty`); } // A spec's median still moves several percent between six-run windows, so a // split within this fraction of the best possible max is as good as optimal. const GOOD_ENOUGH_FRACTION = 0.01; // Packs specs into shardCount lists. Two starts are improved: longest // processing time first, and the current split. The current split wins unless // the other start lowers the max further than it can, so a drift moves few specs. export function packShards(specs, msBySpec, shardCount, current = []) { if (specs.length < shardCount) throw new Error(`need at least ${shardCount} specs, have ${specs.length}`); const inventory = new Set(specs); const durations = specs.map((spec) => msBySpec.get(spec)); const lowerBound = Math.max(durations.reduce((a, b) => a + b, 0) / shardCount, ...durations); const goodEnoughMs = lowerBound * (1 + GOOD_ENOUGH_FRACTION); const fromLpt = improve( placeLongestFirst(Array.from({ length: shardCount }, () => []), specs, msBySpec), msBySpec, goodEnoughMs, ); const kept = Array.from({ length: shardCount }, (_, i) => (current[i] ?? []).filter((s) => inventory.has(s))); const placed = new Set(kept.flat()); const fromCurrent = improve( placeLongestFirst(kept, specs.filter((s) => !placed.has(s)), msBySpec), msBySpec, goodEnoughMs, ); const maxOf = (shards) => Math.max(...shardLoads(shards, msBySpec)); const currentIsEnough = maxOf(fromCurrent) <= Math.max(goodEnoughMs, maxOf(fromLpt)); const shards = (currentIsEnough ? fromCurrent : fromLpt).map((shard) => [...shard].sort()); assertPartition(shards, specs, 'packShards'); return shards; } export function readSmokeScripts(scripts) { const combined = scripts[COMBINED_SCRIPT]; if (!combined) throw new Error(`package.json has no ${COMBINED_SCRIPT} script`); const specs = combined.split(/\s+/).filter((token) => SPEC_TOKEN.test(token)); const shardNames = Object.keys(scripts) .filter((name) => SHARD_SCRIPT.test(name)) .sort((a, b) => Number(SHARD_SCRIPT.exec(a)[1]) - Number(SHARD_SCRIPT.exec(b)[1])); const shards = shardNames.map((name) => scripts[name].split(/\s+/).filter((token) => SPEC_TOKEN.test(token))); const unknown = shards.flat().filter((spec) => !specs.includes(spec)); if (unknown.length) throw new Error(`shard scripts name specs missing from ${COMBINED_SCRIPT}: ${unknown.join(' ')}`); return { specs, shardNames, shards }; } // Rewrites only the shard script values in the package.json text, keeping each // script's non-spec prefix and suffix, the key order and the file formatting. export function rewriteShardScripts(packageText, shardNames, shards) { const scripts = JSON.parse(packageText).scripts; let text = packageText; shardNames.forEach((name, i) => { const tokens = scripts[name].split(' '); const first = tokens.findIndex((token) => SPEC_TOKEN.test(token)); const last = tokens.findLastIndex((token) => SPEC_TOKEN.test(token)); if (first < 0) throw new Error(`${name} names no spec paths`); if (tokens.slice(first, last + 1).some((token) => !SPEC_TOKEN.test(token))) { throw new Error(`${name} interleaves options with spec paths; rewrite it by hand`); } const value = [...tokens.slice(0, first), ...shards[i], ...tokens.slice(last + 1)].join(' '); const before = `${JSON.stringify(name)}: ${JSON.stringify(scripts[name])}`; if (text.split(before).length !== 2) throw new Error(`could not locate ${name} in package.json`); text = text.replace(before, () => `${JSON.stringify(name)}: ${JSON.stringify(value)}`); }); return text; } function parseArgs(argv) { const args = { count: 6, repo: 'koala73/worldmonitor', runs: null, write: false }; for (let i = 0; i < argv.length; i++) { const flag = argv[i]; if (flag === '--write') args.write = true; else if (flag === '--runs') args.runs = argv[++i].split(',').map((id) => id.trim()).filter(Boolean); else if (flag === '--count') args.count = Number(argv[++i]); else if (flag === '--repo') args.repo = argv[++i]; else throw new Error(`unknown argument ${flag}`); } if (!Number.isInteger(args.count) || args.count < 1) throw new Error('--count must be a positive integer'); return args; } async function gh(args) { const { stdout } = await execFileAsync('gh', args, { maxBuffer: 256 * 1024 * 1024 }); return stdout; } async function smokeJobs(repo, runId, shardCount) { const out = await gh([ 'api', '--paginate', `repos/${repo}/actions/runs/${runId}/jobs`, '--jq', '.jobs[] | [.id, .name, .conclusion] | @tsv', ]); const jobs = out.trim().split('\n').filter(Boolean).map((line) => line.split('\t')) .filter(([, name]) => SHARD_JOB.test(name)) .map(([id, name, conclusion]) => ({ id, shard: Number(SHARD_JOB.exec(name)[1]), conclusion })); const ok = jobs.filter((job) => job.conclusion === 'success'); return ok.length === shardCount ? ok : null; } async function measureRun(repo, runId, shardCount) { const jobs = await smokeJobs(repo, runId, shardCount); if (!jobs) return null; const run = new Map(); for (const job of jobs) { const log = await gh(['api', '--allow-escape-sequences', `repos/${repo}/actions/jobs/${job.id}/logs`]); const perSpec = specSecondsFromLog(log); if (perSpec.size === 0) throw new Error(`run ${runId} shard ${job.shard}: no list-reporter result lines parsed`); for (const [spec, ms] of perSpec) run.set(spec, (run.get(spec) ?? 0) + ms); } return run; } async function collectRuns(args, shardCount) { const candidates = args.runs ?? JSON.parse(await gh([ 'run', 'list', '-R', args.repo, '-w', 'Test', '-b', 'main', '--event', 'push', '--status', 'success', '-L', String(args.count * 4), '--json', 'databaseId', ])).map((run) => String(run.databaseId)); const used = []; for (const runId of candidates) { if (!args.runs && used.length === args.count) break; const run = await measureRun(args.repo, runId, shardCount); if (run) used.push({ runId, run }); else if (args.runs) throw new Error(`run ${runId} has no ${shardCount} successful variant-smoke shard jobs`); else console.error(`skip run ${runId}: variant-smoke shards did not all run`); } if (used.length === 0) throw new Error('no usable runs'); return used; } const seconds = (ms) => (ms / 1000).toFixed(1); function describe(label, shards, msBySpec) { const loads = shardLoads(shards, msBySpec); const total = loads.reduce((a, b) => a + b, 0); console.log(`${label}: ${loads.map(seconds).join(' / ')} test-seconds (max ${seconds(Math.max(...loads))} of ${seconds(total)})`); } async function main() { const args = parseArgs(process.argv.slice(2)); const packageText = readFileSync(PACKAGE_JSON, 'utf8'); const { specs, shardNames, shards: current } = readSmokeScripts(JSON.parse(packageText).scripts); const used = await collectRuns(args, shardNames.length); const estimates = estimateSpecs(specs, used.map((entry) => entry.run)); const msBySpec = new Map([...estimates].map(([spec, entry]) => [spec, entry.ms])); console.log(`runs (${used.length}): ${used.map((entry) => entry.runId).join(',')}`); for (const { runId, run } of used) { const observed = current.map((shard) => shard.reduce((sum, spec) => sum + (run.get(spec) ?? 0), 0)); console.log(` run ${runId} measured on the current split: ${observed.map(seconds).join(' / ')}`); } console.log('\nper-spec median test-seconds (min-max over runs):'); for (const spec of longestFirst(specs, msBySpec)) { const { ms, samples, estimated } = estimates.get(spec); const spread = samples.length ? `${seconds(Math.min(...samples))}-${seconds(Math.max(...samples))}, n=${samples.length}` : 'unmeasured'; console.log(` ${seconds(ms).padStart(7)} ${spec} (${spread})${estimated ? ' ESTIMATE: no measurement, using the median spec' : ''}`); } const next = packShards(specs, msBySpec, shardNames.length, current); console.log(''); describe('before', current, msBySpec); describe('after ', next, msBySpec); next.forEach((shard, i) => console.log(` ${shardNames[i]}: ${shard.join(' ')}`)); if (args.write) { writeFileSync(PACKAGE_JSON, rewriteShardScripts(packageText, shardNames, next)); const written = readSmokeScripts(JSON.parse(readFileSync(PACKAGE_JSON, 'utf8')).scripts); assertPartition(written.shards, specs, 'package.json after --write'); console.log(`\nwrote ${shardNames.join(', ')} to package.json`); } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { main().catch((error) => { console.error(error.message); process.exit(1); }); }