import { execFileSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; // Rebuilds scripts/testing/vitest-shard-weights.json, the per-file seconds // scripts/testing/duration-sequencer.ts balances the CI shards with, one table // per platform: // // node scripts/testing/update-shard-weights.mjs --run [--run ...] // node scripts/testing/update-shard-weights.mjs [--platform linux|win32|darwin] ... // // --run reads a finished run's vitest logs through `gh`, each OS's shards for // that OS's table. A file's weight is its median across the inputs in whole // seconds, at least one. Every file over five seconds is kept, and every e2e // file, so that only a genuinely new e2e file falls back to the sequencer's e2e // default. Deleted files are dropped, and a platform no input sampled keeps // its table. const ROOT = fileURLToPath(new URL("../..", import.meta.url)); const OUTPUT = "scripts/testing/vitest-shard-weights.json"; const HEAVY_SECONDS = 5; const E2E = /\.e2e\.test\.[cm]?[jt]s$/; // The default reporter's line per file: "✓ server/a.test.ts (12 tests) 345ms". // A fully skipped file prints no time. `gh` prints escapes as a literal "^[". const FILE_LINE = /\s[✓×❯↓] ((?:[\w.@-]+\/)+[\w.@-]+\.test\.[cm]?[jt]s)(?: \([^)]*\))?(?: (\d+)ms)?\s*$/; // oxlint-disable-next-line no-control-regex -- strips colour codes from a CI log const COLOUR = /(?:\u001b|\^\[)\[[\d;]*m/g; export function parseVitestLog(text) { const seconds = new Map(); for (const line of text.split("\n")) { const match = FILE_LINE.exec(line.replace(COLOUR, "")); if (match) seconds.set(match[1], Number(match[2] ?? 0) / 1000); } return seconds; } export function parseVitestJson(text, root = ROOT) { const seconds = new Map(); for (const file of JSON.parse(text).testResults ?? []) { seconds.set(relative(root, file.name).split("\\").join("/"), (file.endTime - file.startTime) / 1000); } return seconds; } export function buildWeights(samples, exists) { const seen = new Map(); for (const sample of samples) { for (const [file, seconds] of sample) seen.set(file, [...(seen.get(file) ?? []), seconds]); } const weights = {}; for (const file of [...seen.keys()].sort()) { const values = seen.get(file).sort((a, b) => a - b); const middle = Math.floor(values.length / 2); const median = values.length % 2 ? values[middle] : (values[middle - 1] + values[middle]) / 2; if ((median >= HEAVY_SECONDS || E2E.test(file)) && exists(file)) weights[file] = Math.max(1, Math.round(median)); } return weights; } export const RUNNER_PLATFORMS = { "ubuntu-latest": "linux", "windows-latest": "win32", "macos-latest": "darwin" }; const VITEST_JOB = /^vitest \((ubuntu-latest|windows-latest|macos-latest), shard \d+\/\d+\)$/; /** A run's vitest jobs by platform: one sample per platform, its shards together covering that platform's files once. */ function runSamples(id) { const gh = (...args) => execFileSync("gh", args, { cwd: ROOT, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }); const samples = new Map(); for (const job of JSON.parse(gh("run", "view", id, "--json", "jobs")).jobs) { const runner = VITEST_JOB.exec(job.name)?.[1]; if (!runner) continue; const platform = RUNNER_PLATFORMS[runner]; const sample = samples.get(platform) ?? new Map(); for (const [file, seconds] of parseVitestLog(gh("run", "view", id, "--log", "--job", String(job.databaseId)))) sample.set(file, seconds); samples.set(platform, sample); } if (samples.size === 0) throw new Error(`run ${id} has no vitest jobs`); return samples; } /** The weights file with each sampled platform's table rebuilt and the others kept. */ export function mergePlatformWeights(existing, samplesByPlatform, exists) { const merged = Object.fromEntries(Object.entries(existing).filter(([platform]) => Object.values(RUNNER_PLATFORMS).includes(platform))); for (const [platform, samples] of Object.entries(samplesByPlatform)) { if (samples.length > 0) merged[platform] = buildWeights(samples, exists); } return Object.fromEntries(Object.keys(merged).sort().map((platform) => [platform, merged[platform]])); } if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { const usage = () => { console.error("usage: update-shard-weights.mjs --run ... | [--platform linux|win32|darwin] ..."); process.exit(2); }; const args = process.argv.slice(2); const samples = { linux: [], win32: [], darwin: [] }; let platform = Object.hasOwn(samples, process.platform) ? process.platform : "linux"; for (let i = 0; i < args.length; i++) { if (args[i] === "--run") for (const [sampled, sample] of runSamples(args[++i])) samples[sampled].push(sample); else if (args[i] === "--platform") platform = Object.hasOwn(samples, args[++i]) ? args[i] : usage(); else samples[platform].push((args[i].endsWith(".json") ? parseVitestJson : parseVitestLog)(readFileSync(args[i], "utf8"))); } if (Object.values(samples).every((list) => list.length === 0)) usage(); const existing = existsSync(resolve(ROOT, OUTPUT)) ? JSON.parse(readFileSync(resolve(ROOT, OUTPUT), "utf8")) : {}; const weights = mergePlatformWeights(existing, samples, (file) => existsSync(resolve(ROOT, file))); writeFileSync(resolve(ROOT, OUTPUT), `${JSON.stringify(weights, null, 2)}\n`); for (const [name, list] of Object.entries(samples)) { if (list.length > 0) console.log(`${OUTPUT}: ${name} ${Object.keys(weights[name]).length} files from ${list.length} samples`); } }