CI green on a loaded shared machine: browser suites (now also glyph-ligatures and feature-fit, through the shared launch helper) in one dedicated shard started first at concurrency 2; timeouts scaled by load ÷ cores (tests/helpers/load.ts, max(1-, 5-min average), capped at 5x, logged) for browser tests, waits, launch/close, the other shards' default and the desktop boot check; one retry for a browser test that timed out while the factor was above 2, listed as 'retried under load' in the CI summary, never for an assertion failure; the six-type App Privacy walk no longer dumps every step (the --debug-dump check walks two types), wait timeouts print per-line times; play-edit-lock checks reads in flight instead of wall time; tests/load-retry.test.ts
This commit is contained in:
parent
21fa91afd1
commit
918146d4a6
13 changed files with 261 additions and 96 deletions
|
|
@ -9,17 +9,24 @@ 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. */
|
||||
/** 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 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;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** 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)$/;
|
||||
|
|
@ -81,28 +88,29 @@ export function cost(file: string, timings: Record<string, number>, size: (f: st
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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): 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));
|
||||
export function balance(files: string[], n: number, costOf: (f: string) => number, heavy: string[] = HEAVY, browser = 1): string[][] {
|
||||
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);
|
||||
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<typeof mk>) => {
|
||||
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; } }));
|
||||
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 ----
|
||||
|
|
|
|||
18
tools/ci.ts
18
tools/ci.ts
|
|
@ -6,8 +6,11 @@
|
|||
// mapped through imports) + scrub
|
||||
// The tests run as shards: N = CPU cores - 1 (max 8; STOREBOUND_CI_SHARDS=n overrides) parallel `bun test <files>`
|
||||
// processes, balanced by the durations recorded after earlier runs (node_modules/.cache/storebound-ci/, smoothed by
|
||||
// mergeTimings so one run on a loaded machine does not skew the split; falling back to file size). Browser suites
|
||||
// (tools/ci-plan.ts HEAVY) each get a shard of their own and run their opted-in tests BROWSER_CONCURRENCY at a time.
|
||||
// mergeTimings so one run on a loaded machine does not skew the split; falling back to file size). The browser suites
|
||||
// (tools/ci-plan.ts HEAVY) run in one dedicated shard, started first, their opted-in tests BROWSER_CONCURRENCY at a
|
||||
// time. The machine is shared: their timeouts scale with load ÷ cores (tests/helpers/load.ts, capped at 5x, the factor
|
||||
// is logged), and a browser test that fails by timing out under load (factor > 2) runs once more; the summary lists
|
||||
// those as "retried under load". An assertion failure is never retried.
|
||||
// `bun test` covers the CLI and the desktop webview (apps/desktop/tests), the site overflow check and the
|
||||
// no-hand-written-SVG guard; the desktop boot check (tools/desktop-boot-check.ts: the built window in headless
|
||||
// Chromium, needs playwright's browser or Google Chrome) runs after the desktop build.
|
||||
|
|
@ -18,6 +21,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|||
import { homedir, 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';
|
||||
|
||||
interface Task { name: string; cmd: string[]; cwd?: string; env?: Record<string, string>; after?: string[]; junit?: string }
|
||||
|
|
@ -32,6 +36,8 @@ const tasks: Task[] = [];
|
|||
const add = (t: Task) => tasks.push(t);
|
||||
const tsc = (name: string, project: string) => add({ name, cmd: ['bunx', 'tsc', '--noEmit', '--incremental', '-p', project,
|
||||
'--tsBuildInfoFile', join(cache, `${name.replace(/\W+/g, '-')}.tsbuildinfo`)] });
|
||||
const retried: string[] = [];
|
||||
console.log(`ci: ${loadNote(loadFactor())}`);
|
||||
const junitDir = mkdtempSync(join(tmpdir(), 'sb-ci-'));
|
||||
const node = Bun.which('node');
|
||||
|
||||
|
|
@ -48,8 +54,10 @@ function addTests(files: string[]) {
|
|||
// the built-bundle check in apps/desktop/tests/webview.test.ts runs as "desktop bundle check" once the build is
|
||||
// done; here it would read dist/ while vite rewrites it
|
||||
// browser suites share the machine with the other steps: a browser start or close can pass bun's 5 s default
|
||||
// browser tests that opt in (testScope().concurrent) run BROWSER_CONCURRENCY at a time within their file
|
||||
const timeout = shard.some(f => HEAVY.includes(f)) ? ['--timeout=30000', `--max-concurrency=${BROWSER_CONCURRENCY}`] : [];
|
||||
// (scaled by the load now); browser tests that opt in (testScope().concurrent) run BROWSER_CONCURRENCY at a time
|
||||
// every other shard: bun's 5 s default (tests and hooks without their own timeout), scaled the same way
|
||||
const f = loadFactor();
|
||||
const timeout = shard.some(x => HEAVY.includes(x)) ? [`--timeout=${scaled(30_000, f)}`, `--max-concurrency=${BROWSER_CONCURRENCY}`] : [`--timeout=${scaled(5_000, f)}`];
|
||||
add({ name: `tests ${i + 1}/${all.length}`, cmd: ['bun', 'test', ...timeout, '--reporter=junit', `--reporter-outfile=${junit}`, ...shard.map(f => `./${f}`)],
|
||||
env: { STOREBOUND_SKIP_DIST_CHECK: '1' }, junit });
|
||||
});
|
||||
|
|
@ -133,6 +141,7 @@ async function pump(stream: ReadableStream<Uint8Array>, name: string, out: NodeJ
|
|||
buf += dec.decode(value, { stream: true });
|
||||
const lines = buf.split('\n');
|
||||
buf = lines.pop()!;
|
||||
for (const l of lines) if (l.includes(RETRY_MARK)) retried.push(`${name}: ${l.slice(l.indexOf(RETRY_MARK) + RETRY_MARK.length).trim()}`);
|
||||
if (lines.length) out.write(lines.map(l => `${tag(name)} ${l}\n`).join(''));
|
||||
}
|
||||
if (buf && !(failed && failed !== name)) out.write(`${tag(name)} ${buf}\n`);
|
||||
|
|
@ -186,5 +195,6 @@ for (const r of [...runs.values()].sort((a, b) => a.start - b.start)) {
|
|||
console.log(` ${r.task.name.padEnd(width)} ${secs}s @${((r.start - t0) / 1000).toFixed(1).padStart(5)}s ${s}`);
|
||||
}
|
||||
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 (failed) { console.error(`\nci: ${failed} failed`); process.exit(1); }
|
||||
console.log(`\n${quick ? 'ci:quick' : 'ci'}: all green`);
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { extname, join, resolve } from 'node:path';
|
|||
import type { Browser, Page } from 'playwright';
|
||||
import { APPLE_STATUS_FIRST_REVIEW, APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, ownerDry, PLAY_STATUS, PLAY_STATUS_FIRST_200, PLAY_STATUS_FIRST_404 } from '../apps/desktop/src/dev/submit-fixtures';
|
||||
import { appleReleases, playReleases, releaseTrains } from '../cli/lib/portfolio';
|
||||
import { LOAD, loadNote, scaled } from '../tests/helpers/load';
|
||||
import { EXAMPLE_CLOUD_PROJECT, healthApiDisabled, SCAN_FAILED, SCAN_FAILED_LINE, SCAN_FINDINGS } from '../apps/desktop/src/dev/error-fixtures';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
|
|
@ -241,8 +242,10 @@ async function state(page: Page) {
|
|||
|
||||
/** A quiet window: no host call in flight and no DOM change for this long means the last action is done. */
|
||||
const QUIET_MS = 150;
|
||||
/** Upper bound of one wait for a condition (a loaded machine); a wait that runs out is a failure or a check as-is. */
|
||||
const WAIT_MS = 15_000;
|
||||
/** Upper bound of one wait for a condition, scaled by load ÷ cores (tests/helpers/load.ts: the machine is shared); a
|
||||
* wait that runs out is a failure or a check as-is. */
|
||||
const WAIT_MS = scaled(15_000);
|
||||
console.log(`desktop boot check: ${loadNote(LOAD)}`);
|
||||
|
||||
/**
|
||||
* Waits until the window is quiet (initScript's counters: no host call in flight, no DOM change for QUIET_MS): what a
|
||||
|
|
@ -596,7 +599,7 @@ async function runReleaseStateCase(browser: Browser, sidecar: Sidecar, page404:
|
|||
}
|
||||
|
||||
/** Longest wait for the background reader to read everything of every app (a loaded machine). */
|
||||
const READ_ALL_MS = 120_000;
|
||||
const READ_ALL_MS = scaled(120_000);
|
||||
|
||||
/**
|
||||
* Auto-read: the dev fixture state (src/dev/fixtures.ts: 2 app folders and the store-only apps of 5 accounts, at least 8
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue