210 lines
10 KiB
TypeScript
210 lines
10 KiB
TypeScript
// The planning half of tools/ci.ts: which test files exist, how they are split into shards, which tests a change
|
|
// touches (ci:quick) and whether the desktop Rust host needs to run. Pure functions plus small file / git reads;
|
|
// tests/ci-plan.test.ts covers them.
|
|
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
import { cpus } from 'node:os';
|
|
import { dirname, join, normalize, relative } from 'node:path';
|
|
|
|
export const ROOT = join(import.meta.dir, '..');
|
|
/** Recorded per-file test durations (seconds), written after every run. Gitignored (node_modules/). */
|
|
export const TIMINGS = join(ROOT, 'node_modules/.cache/storebound-ci/test-timings.json');
|
|
|
|
/** Suites that drive a real Chromium (or a heavy child process): each gets a shard of its own, never another heavy
|
|
* suite next to it, so they don't fight for CPU and time out. */
|
|
export const HEAVY = [
|
|
'tests/console-fill.test.ts', 'tests/site-overflow.test.ts', 'tests/local-fonts.test.ts', 'tests/glyphs.test.ts',
|
|
'tests/capture-web.test.ts', 'tests/console-session.test.ts', 'tests/console-persist.test.ts', 'tests/console-flow.test.ts',
|
|
];
|
|
|
|
/** Browser tests of one file at the same time (bun test --max-concurrency for the shards with a HEAVY file; the
|
|
* tests opt in with testScope().concurrent, tests/helpers/browser.ts): enough to overlap their waits, few enough that
|
|
* a loaded machine does not stretch each one towards its timeout. */
|
|
export const BROWSER_CONCURRENCY = 4;
|
|
|
|
/** Changes that can affect every test: run the whole suite. */
|
|
export const GLOBAL = /^(package\.json|bun\.lock|bunfig\.toml|tsconfig\.json|tests\/helpers\/preload\.ts)$/;
|
|
|
|
const SKIP_DIRS = new Set(['node_modules', '.git', '.scratch', 'dist', 'target', 'fixtures', '.claude']);
|
|
|
|
/** Every *.test.ts / *.test.tsx `bun test` would pick up, relative to the root, sorted. */
|
|
export function listTests(root = ROOT): string[] {
|
|
const out: string[] = [];
|
|
const walk = (dir: string) => {
|
|
for (const e of readdirSync(join(root, dir), { withFileTypes: true })) {
|
|
const p = dir ? `${dir}/${e.name}` : e.name;
|
|
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name) && !e.name.startsWith('.')) walk(p); }
|
|
else if (/\.test\.tsx?$/.test(e.name)) out.push(p);
|
|
}
|
|
};
|
|
walk('');
|
|
return out.sort();
|
|
}
|
|
|
|
/** Shard count: CPU cores - 1, at least 1, at most 8 (STOREBOUND_CI_SHARDS overrides). */
|
|
export function shardCount(env = process.env, cores = cpus().length): number {
|
|
const n = Number(env.STOREBOUND_CI_SHARDS);
|
|
if (Number.isInteger(n) && n > 0) return n;
|
|
return Math.max(1, Math.min(8, cores - 1));
|
|
}
|
|
|
|
export function readTimings(file = TIMINGS): Record<string, number> {
|
|
try { return JSON.parse(readFileSync(file, 'utf8')) as Record<string, number>; } catch { return {}; }
|
|
}
|
|
|
|
export function writeTimings(t: Record<string, number>, file = TIMINGS): void {
|
|
try { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, `${JSON.stringify(t, null, 1)}\n`); } catch { /* cache only */ }
|
|
}
|
|
|
|
/**
|
|
* The recorded time of each file after a run: a new file takes its measurement; a known one moves halfway towards it
|
|
* and never above it. The machine is shared, so one run under heavy load (other sessions' CI, cargo) must not make a
|
|
* file look several times slower for the next split, while a file that really got faster or slower still converges.
|
|
*/
|
|
export function mergeTimings(old: Record<string, number>, fresh: Record<string, number>): Record<string, number> {
|
|
const out = { ...old };
|
|
for (const [f, t] of Object.entries(fresh)) out[f] = old[f] === undefined ? t : Math.min(t, (old[f]! + t) / 2);
|
|
return out;
|
|
}
|
|
|
|
/** Per-file seconds from bun's JUnit report (the top-level <testsuite> of each file). */
|
|
export function junitTimes(xml: string): Record<string, number> {
|
|
const out: Record<string, number> = {};
|
|
for (const m of xml.matchAll(/<testsuite name="([^"]+)" file="([^"]+)"[^>]*?time="([\d.]+)"/g)) {
|
|
if (m[1] === m[2]) out[m[2]!] = Number(m[3]);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Estimated cost of a file: its recorded time, else its size (about 1 s per 40 KB, 0.2 s minimum). */
|
|
export function cost(file: string, timings: Record<string, number>, size: (f: string) => number): number {
|
|
return timings[file] ?? Math.max(0.2, size(file) / 40_000);
|
|
}
|
|
|
|
/**
|
|
* Longest-processing-time-first split into at most n shards. Heavy files each open a shard of their own and never
|
|
* share it with another heavy file; light files fill the least loaded shard. Empty shards are dropped.
|
|
*/
|
|
export function balance(files: string[], n: number, costOf: (f: string) => number, heavy: string[] = HEAVY): string[][] {
|
|
const shards = Array.from({ length: Math.max(1, n) }, () => ({ files: [] as string[], load: 0, heavy: false }));
|
|
const sorted = [...files].sort((a, b) => costOf(b) - costOf(a) || a.localeCompare(b));
|
|
const isHeavy = (f: string) => heavy.includes(f);
|
|
for (const f of sorted.filter(isHeavy)) {
|
|
const free = shards.filter(s => !s.heavy);
|
|
const s = (free.length ? free : shards).reduce((a, b) => (b.load < a.load ? b : a));
|
|
s.files.push(f); s.load += costOf(f); s.heavy = true;
|
|
}
|
|
for (const f of sorted.filter(f => !isHeavy(f))) {
|
|
const s = shards.reduce((a, b) => (b.load < a.load ? b : a));
|
|
s.files.push(f); s.load += costOf(f);
|
|
}
|
|
return shards.filter(s => s.files.length).map(s => s.files);
|
|
}
|
|
|
|
export function planShards(files: string[], n = shardCount(), root = ROOT): string[][] {
|
|
const t = readTimings();
|
|
return balance(files, n, f => cost(f, t, p => { try { return statSync(join(root, p)).size; } catch { return 0; } }));
|
|
}
|
|
|
|
// ---- which tests a change touches ----
|
|
|
|
const IMPORT_RE = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+)['"](\.{1,2}\/[^'"]+)['"]/gm;
|
|
const JOIN_RE = /join\(\s*(?:import\.meta\.dir|__dirname|ROOT|root|REPO)\s*,((?:\s*'[^']*'\s*,?)+)\)/g;
|
|
|
|
function resolveFile(p: string): string | null {
|
|
for (const c of [p, `${p}.ts`, `${p}.tsx`, join(p, 'index.ts'), join(p, 'index.tsx')]) {
|
|
try { if (statSync(c).isFile()) return c; } catch { /* next */ }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Repo files a source file depends on: relative imports, plus `join(import.meta.dir, '..', 'cli', 'index.ts')`
|
|
* style paths (a test that spawns the CLI depends on it). */
|
|
export function depsOf(file: string, root = ROOT, src = readFileSync(join(root, file), 'utf8')): string[] {
|
|
const dir = dirname(join(root, file));
|
|
const out = new Set<string>();
|
|
for (const m of src.matchAll(IMPORT_RE)) {
|
|
const r = resolveFile(normalize(join(dir, m[1]!)));
|
|
if (r) out.add(relative(root, r));
|
|
}
|
|
for (const m of src.matchAll(JOIN_RE)) {
|
|
const parts = [...m[1]!.matchAll(/'([^']*)'/g)].map(x => x[1]!);
|
|
for (const base of [dir, root]) {
|
|
const r = resolveFile(normalize(join(base, ...parts)));
|
|
if (r && r.startsWith(root)) { out.add(relative(root, r)); break; }
|
|
}
|
|
}
|
|
return [...out];
|
|
}
|
|
|
|
/**
|
|
* Tests related to the changed files: a changed test itself, every test that imports a changed file (transitively),
|
|
* and every test that names a changed non-code file (fixture, page, skill) by its file name or its fixture folder.
|
|
* A change to a GLOBAL file returns every test. Files in `near` (the browser suites, by default none) count as
|
|
* related only through imports at most NEAR_DEPTH steps away: nearly every module reaches cli/lib/config.ts and the
|
|
* store clients, and ci:quick should not start a browser for every change there (the full ci runs them).
|
|
*/
|
|
export const NEAR_DEPTH = 2;
|
|
export function relatedTests(changed: string[], tests: string[], root = ROOT, near: string[] = []): string[] {
|
|
if (changed.some(f => GLOBAL.test(f))) return tests;
|
|
const changedSet = new Set(changed);
|
|
const direct = new Map<string, string[]>();
|
|
const depsCached = (f: string) => {
|
|
let d = direct.get(f);
|
|
if (!d) { try { d = depsOf(f, root); } catch { d = []; } direct.set(f, d); }
|
|
return d;
|
|
};
|
|
/** Every file a test reaches, with its import distance. */
|
|
const closure = (f: string): Map<string, number> => {
|
|
const depth = new Map<string, number>([[f, 0]]);
|
|
const queue = [f];
|
|
while (queue.length) {
|
|
const cur = queue.shift()!;
|
|
if (!/\.(ts|tsx)$/.test(cur)) continue;
|
|
for (const d of depsCached(cur)) if (!depth.has(d)) { depth.set(d, depth.get(cur)! + 1); queue.push(d); }
|
|
}
|
|
return depth;
|
|
};
|
|
const names = changed.filter(f => !/\.(ts|tsx)$/.test(f) || f.includes('/fixtures/')).flatMap(f => {
|
|
const base = f.split('/').pop()!;
|
|
const fx = /(?:^|\/)fixtures\/([^/]+)/.exec(f)?.[1];
|
|
return [base, ...(fx ? [`fixtures/${fx}`, `'${fx}'`] : [])].filter(s => s.length > 3);
|
|
});
|
|
return tests.filter(t => {
|
|
if (changedSet.has(t)) return true;
|
|
const all = closure(t);
|
|
const max = near.includes(t) ? NEAR_DEPTH : Infinity;
|
|
if (changed.some(c => all.has(c) && all.get(c)! <= max)) return true;
|
|
if (!names.length) return false;
|
|
const srcs = [...all.keys()].filter(d => d.startsWith('tests/') || d.startsWith('apps/desktop/tests/')).map(d => {
|
|
try { return readFileSync(join(root, d), 'utf8'); } catch { return ''; }
|
|
}).join('\n');
|
|
return names.some(n => srcs.includes(n));
|
|
});
|
|
}
|
|
|
|
// ---- git ----
|
|
|
|
function git(args: string[], root = ROOT): string | null {
|
|
const r = Bun.spawnSync(['git', ...args], { cwd: root, stdout: 'pipe', stderr: 'ignore' });
|
|
return r.exitCode === 0 ? r.stdout.toString() : null;
|
|
}
|
|
|
|
/** Files changed against origin/main (committed since the merge base, staged, unstaged and untracked), or null
|
|
* when that cannot be told (no git, no origin/main). */
|
|
export function changedFiles(base = process.env.STOREBOUND_CI_BASE ?? 'origin/main', root = ROOT): string[] | null {
|
|
const mb = git(['merge-base', base, 'HEAD'], root)?.trim();
|
|
if (!mb) return null;
|
|
const lists = [git(['diff', '--name-only', mb], root), git(['ls-files', '--others', '--exclude-standard'], root)];
|
|
if (lists.some(l => l === null)) return null;
|
|
return [...new Set(lists.join('\n').split('\n').map(s => s.trim()).filter(Boolean))].sort();
|
|
}
|
|
|
|
/** Desktop Rust host inputs: the crate, and the sidecar build its build script needs (cargo test / clippy never run
|
|
* the sidecar itself, so a CLI change does not need them; tools/ci.ts rebuilds the sidecar on CLI changes). */
|
|
export const RUST_INPUTS = /^(apps\/desktop\/src-tauri\/|tools\/build-sidecars\.ts$)/;
|
|
|
|
/** Run cargo test / clippy? Always with CI=full or when the change set is unknown; else only when a Rust input changed. */
|
|
export function rustNeeded(changed: string[] | null, env = process.env): boolean {
|
|
if (env.CI === 'full' || changed === null) return true;
|
|
return changed.some(f => RUST_INPUTS.test(f));
|
|
}
|