diff --git a/tests/ci-plan.test.ts b/tests/ci-plan.test.ts index d6e7def..451fc33 100644 --- a/tests/ci-plan.test.ts +++ b/tests/ci-plan.test.ts @@ -1,5 +1,14 @@ import { describe, expect, test } from 'bun:test'; -import { balance, depsOf, GLOBAL, HEAVY, junitTimes, listTests, mergeTimings, relatedTests, rustNeeded, shardCount } from '../tools/ci-plan'; +import { balance, deferBrowser, depsOf, GLOBAL, HEAVY, junitTimes, listTests, mergeTimings, relatedTests, rustNeeded, shardCount } from '../tools/ci-plan'; + +describe('tools/ci-plan: browser suites deferred on a busy machine', () => { + test('deferred only when the 1-minute load is above 2x the cores, never with CI=full', () => { + expect(deferBrowser({}, 21, 10)).toBe(true); + expect(deferBrowser({}, 20, 10)).toBe(false); + expect(deferBrowser({ CI: 'full' }, 500, 10)).toBe(false); + expect(deferBrowser({ CI: 'true' }, 500, 10)).toBe(true); + }); +}); describe('tools/ci-plan: test shards', () => { test('shard count: cores - 1, between 1 and 8, env override', () => { diff --git a/tools/ci-plan.ts b/tools/ci-plan.ts index d7c7f8c..0707d4b 100644 --- a/tools/ci-plan.ts +++ b/tools/ci-plan.ts @@ -2,7 +2,7 @@ // 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 { cpus, loadavg } from 'node:os'; import { dirname, join, normalize, relative } from 'node:path'; export const ROOT = join(import.meta.dir, '..'); @@ -28,6 +28,15 @@ export function browserShardCount(env = process.env): number { 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)$/; diff --git a/tools/ci.ts b/tools/ci.ts index c801bf6..7625857 100644 --- a/tools/ci.ts +++ b/tools/ci.ts @@ -18,11 +18,11 @@ // apps/desktop/src-tauri or the sidecar build changed against origin/main, or always with CI=full; // STOREBOUND_CI_RUST=0 skips it on a machine without Rust. import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; -import { homedir, tmpdir } from 'node:os'; +import { cpus, homedir, loadavg, tmpdir } from 'node:os'; import { join } from 'node:path'; import { hostTarget, OUT_DIR, outName } from './build-sidecars'; import { loadFactor, loadNote, RETRY_MARK, scaled } from '../tests/helpers/load'; -import { BROWSER_CONCURRENCY, changedFiles, HEAVY, junitTimes, listTests, mergeTimings, planShards, readTimings, relatedTests, ROOT, rustNeeded, writeTimings } from './ci-plan'; +import { BROWSER_CONCURRENCY, changedFiles, deferBrowser, HEAVY, junitTimes, listTests, mergeTimings, planShards, readTimings, relatedTests, ROOT, rustNeeded, writeTimings } from './ci-plan'; interface Task { name: string; cmd: string[]; cwd?: string; env?: Record; after?: string[]; junit?: string } @@ -30,7 +30,10 @@ const quick = process.argv.includes('--quick'); const tauri = join(ROOT, 'apps/desktop/src-tauri'); const cache = join(ROOT, 'node_modules/.cache/storebound-ci'); const changed = changedFiles(); -const tests = listTests(); +const deferred = deferBrowser(); +const allTests = listTests(); +const tests = deferred ? allTests.filter(f => !HEAVY.includes(f)) : allTests; +if (deferred) console.log(`ci: browser suites deferred to Forgejo CI (load ${loadavg()[0]!.toFixed(0)} > 2 x ${cpus().length} cores): ${allTests.filter(f => HEAVY.includes(f)).join(' ')} and the desktop boot check. CI=full runs them here.`); const touches = (re: RegExp) => changed === null || changed.some(f => re.test(f)); const tasks: Task[] = []; const add = (t: Task) => tasks.push(t); @@ -99,7 +102,7 @@ if (quick) { add({ name: 'desktop bundle check', cmd: ['bun', 'test', './apps/desktop/tests/webview.test.ts'], after: ['desktop build'] }); // the built window boots (release CSP, Tauri's frozen Object.prototype, mocked host + real sidecar) on an empty, a // D1 (folders gone) and a full D3 saved state, through every screen: no uncaught error, never an empty window - add({ name: 'desktop boot check', cmd: ['bun', 'tools/desktop-boot-check.ts'], after: ['desktop build'] }); + if (!deferred) add({ name: 'desktop boot check', cmd: ['bun', 'tools/desktop-boot-check.ts'], after: ['desktop build'] }); // the CLI compiled into the desktop sidecar (bun build --compile, scanned for this machine's paths): rebuilt when // its inputs changed, with CI=full, or when missing; tauri's build script needs it for cargo test / clippy const hostBin = join(OUT_DIR, outName(hostTarget())); @@ -196,5 +199,6 @@ for (const r of [...runs.values()].sort((a, b) => a.start - b.start)) { } for (const t of pending) console.log(` ${t.name.padEnd(width)} - not started`); if (retried.length) console.log(`\nretried under load (${retried.length}; each timed out once while load รท cores was above 2, then ran again):\n${retried.map(r => ` ${r}`).join('\n')}`); +if (deferred) console.log(`\nci: browser suites deferred to Forgejo CI (load ${loadavg()[0]!.toFixed(0)}); check its run on main`); if (failed) { console.error(`\nci: ${failed} failed`); process.exit(1); } console.log(`\n${quick ? 'ci:quick' : 'ci'}: all green`);