// 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, loadavg } 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). They all run in BROWSER_SHARDS dedicated shards, * never next to the light tests, and those shards start first: eight browsers at once (one per shard) on a machine * other sessions already load to 100+ made the walks 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', 'tests/glyph-ligatures.test.ts', 'tests/feature-fit.test.ts', ]; /** Browser tests of one file at the same time (bun test --max-concurrency for the browser shards; 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 = 2; /** Shards for the HEAVY suites (STOREBOUND_CI_BROWSER_SHARDS=n overrides). */ export function browserShardCount(env = process.env): number { const n = Number(env.STOREBOUND_CI_BROWSER_SHARDS); return Number.isInteger(n) && n > 0 ? n : 1; } /** * The local pre-push gate on a busy shared machine: with the 1-minute load average above 2x the cores, the browser * suites (HEAVY) and the desktop boot check (real Chrome) are deferred to the Forgejo CI on the VDS, which always runs * them (CI=full; a red Forgejo CI on main stops releases). CI=full forces them locally. */ export function deferBrowser(env = process.env, load1 = loadavg()[0] ?? 0, cores = cpus().length): boolean { return env.CI !== 'full' && load1 > 2 * cores; } /** 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 { try { return JSON.parse(readFileSync(file, 'utf8')) as Record; } catch { return {}; } } export function writeTimings(t: Record, 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, fresh: Record): Record { 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 of each file). */ export function junitTimes(xml: string): Record { const out: Record = {}; for (const m of xml.matchAll(/]*?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, 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. The heavy files go to `browser` dedicated shards (first * in the result, so they start first) and never share one with a light file while n leaves room; light files fill the * least loaded of the others. Empty shards are dropped. */ export function balance(files: string[], n: number, costOf: (f: string) => number, heavy: string[] = HEAVY, browser = 1): string[][] { const isHeavy = (f: string) => heavy.includes(f); const sorted = [...files].sort((a, b) => costOf(b) - costOf(a) || a.localeCompare(b)); const heavyFiles = sorted.filter(isHeavy), light = sorted.filter(f => !isHeavy(f)); const total = Math.max(1, n); const nb = heavyFiles.length ? Math.min(Math.max(1, browser), total) : 0; const mk = (k: number) => Array.from({ length: k }, () => ({ files: [] as string[], load: 0 })); const lpt = (fs: string[], shards: ReturnType) => { for (const f of fs) { const s = shards.reduce((a, b) => (b.load < a.load ? b : a)); s.files.push(f); s.load += costOf(f); } }; const heavyShards = mk(nb), lightShards = total > nb ? mk(total - nb) : heavyShards; lpt(heavyFiles, heavyShards); lpt(light, lightShards); return [...heavyShards, ...(lightShards === heavyShards ? [] : lightShards)].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; } }), HEAVY, browserShardCount()); } // ---- 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(); 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(); 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 => { const depth = new Map([[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)); }