import { readdir } from "node:fs/promises"; import { resolve } from "node:path"; import { OBSERVED_TEST_DURATIONS_MS, UNKNOWN_TEST_DURATION_MS } from "./ci-shard-durations"; /** CI e2e targets whose files can be distributed across independent runners. */ export type CiTarget = "cloud" | "local" | "selfhost"; /** The supported targets in stable display order. */ export const CI_TARGETS: readonly CiTarget[] = ["cloud", "selfhost", "local"]; /** * Shard counts sized so the recorded test workload plus runner setup stays * close to two minutes without weakening coverage or enabling retries. */ export const CI_SHARD_COUNTS: Readonly> = { cloud: 16, selfhost: 10, local: 2, }; /** A test file and its estimated runtime used by the shard planner. */ export interface WeightedTestFile { readonly path: string; readonly durationMs: number; } /** A complete one-based shard assignment and its estimated total runtime. */ export interface CiShardAssignment { readonly index: number; readonly estimatedDurationMs: number; readonly files: readonly string[]; } /** A parsed request for one target shard. */ export interface CiShardRequest { readonly target: CiTarget; readonly index: number; readonly count: number; } /** A safe failure returned when command-line shard input cannot be parsed. */ export class CiShardInputError extends Error { readonly _tag = "CiShardInputError"; /** Creates an input error containing only safe command syntax information. */ constructor(message: string) { super(message); this.name = "CiShardInputError"; } } /** The result of parsing an external shard request. */ export type ParseCiShardRequestResult = | { readonly ok: true; readonly value: CiShardRequest } | { readonly ok: false; readonly error: CiShardInputError }; const testDirectories: Readonly> = { cloud: ["scenarios", "cloud"], selfhost: ["scenarios", "selfhost"], local: ["local"], }; // A file this long already consumes most of the two-minute budget after // dependency linking, target startup, and Vitest global setup. Keep it alone // instead of balancing unrelated work onto it. const ISOLATED_TEST_DURATION_MS = 35_000; const comparePaths = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0; const parseTarget = (input: string): CiTarget | undefined => CI_TARGETS.find((target) => target === input); const parseIndex = (input: string): number | undefined => { if (!/^\d+$/.test(input)) return undefined; const value = Number(input); return Number.isSafeInteger(value) && value > 0 ? value : undefined; }; const discoverTestFilesInDirectory = async ( e2eRoot: string, relativeDirectory: string, ): Promise => { const entries = await readdir(resolve(e2eRoot, relativeDirectory), { withFileTypes: true, }); const discovered = await Promise.all( entries.map(async (entry): Promise => { const relativePath = `${relativeDirectory}/${entry.name}`; if (entry.isDirectory()) { return discoverTestFilesInDirectory(e2eRoot, relativePath); } return entry.isFile() && entry.name.endsWith(".test.ts") ? [relativePath] : []; }), ); return discovered.flat(); }; /** * Parses `[target, oneBasedIndex]`, deriving the target's fixed shard count. * Invalid input is returned as a typed value and never reaches the planner. */ export const parseCiShardRequest = (args: readonly string[]): ParseCiShardRequestResult => { if (args.length !== 2) { return { ok: false, error: new CiShardInputError( "usage: bun scripts/run-ci-shard.ts ", ), }; } const target = parseTarget(args[0]); if (target === undefined) { return { ok: false, error: new CiShardInputError(`unsupported e2e target: ${args[0]}`), }; } const index = parseIndex(args[1]); const count = CI_SHARD_COUNTS[target]; if (index === undefined || index > count) { return { ok: false, error: new CiShardInputError( `shard index for ${target} must be an integer from 1 through ${count}`, ), }; } return { ok: true, value: { target, index, count } }; }; /** * Discovers the complete set of test files owned by a target, relative to the * supplied e2e root. Results are deduplicated and byte-order sorted. */ export const discoverTargetTestFiles = async ( target: CiTarget, e2eRoot: string, ): Promise => { const discovered = await Promise.all( testDirectories[target].map((directory) => discoverTestFilesInDirectory(e2eRoot, directory)), ); const files = new Set(discovered.flat()); return [...files].sort(comparePaths); }; /** * Returns the observed duration for a known slow file or a conservative * default for new and historically fast files. */ export const durationForTestFile = (target: CiTarget, path: string): number => OBSERVED_TEST_DURATIONS_MS[target][path] ?? UNKNOWN_TEST_DURATION_MS; /** * Plans every shard with deterministic longest-processing-time bin packing. * * @throws Error for internal defects such as duplicate files, invalid weights, * or more shards than files. External CLI values must be parsed first. */ export const planCiShards = ( files: readonly WeightedTestFile[], shardCount: number, ): readonly CiShardAssignment[] => { if (!Number.isSafeInteger(shardCount) || shardCount <= 0) { throw new Error("shard count must be a positive safe integer"); } if (files.length < shardCount) { throw new Error("every configured shard must receive at least one test file"); } const paths = files.map((file) => file.path); if (new Set(paths).size !== paths.length) { throw new Error("test file paths must be unique"); } for (const file of files) { if (file.path.length === 0 || !Number.isFinite(file.durationMs) || file.durationMs <= 0) { throw new Error("test files must have a path and positive finite duration"); } } const bins = Array.from({ length: shardCount }, (_, index) => ({ index: index + 1, estimatedDurationMs: 0, files: [] as string[], isolated: false, })); const longestFirst = [...files].sort( (left, right) => right.durationMs - left.durationMs || comparePaths(left.path, right.path), ); for (const file of longestFirst) { const mustIsolate = file.durationMs >= ISOLATED_TEST_DURATION_MS; const candidates = bins.filter((bin) => (mustIsolate ? bin.files.length === 0 : !bin.isolated)); const firstCandidate = candidates[0]; if (firstCandidate === undefined) { throw new Error("configured shard count cannot isolate every long-running test file"); } let lightest = firstCandidate; for (const candidate of candidates.slice(1)) { if (candidate.estimatedDurationMs < lightest.estimatedDurationMs) { lightest = candidate; } } lightest.files.push(file.path); lightest.estimatedDurationMs += file.durationMs; lightest.isolated = mustIsolate; } return bins.map((bin) => ({ index: bin.index, estimatedDurationMs: bin.estimatedDurationMs, files: [...bin.files].sort(comparePaths), })); }; /** Discovers and plans all shards for one configured target. */ export const planTargetShards = async ( target: CiTarget, e2eRoot: string, ): Promise => { const paths = await discoverTargetTestFiles(target, e2eRoot); const weighted = paths.map((path) => ({ path, durationMs: durationForTestFile(target, path), })); return planCiShards(weighted, CI_SHARD_COUNTS[target]); };