mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 08:54:04 +01:00
* test: turn git auto-maintenance off in free fixtures, with a runner backstop git 2.29+ starts a detached `git maintenance run --auto` after every commit; it creates and deletes .git/objects/maintenance.lock after the commit returns. Diagnosed on47d639a(git 2.55.0): - gstack-docs-candidate (ledger 37742176952/16): 4/150 replicated snapshots saw the .git listing change, every one `-objects/maintenance.lock`; 0/150 with the keys. repo() now sets maintenance.auto=false and gc.auto=0. - plan-design-sdk-fixture (ledger 37807608021/6): under commit load 18/150 runs failed the collector check; in every one Bun's rmSync(recursive, force) returned without throwing and left the scratch repo behind. 0/100 with the keys in the fixture's replacement git env, which never sees the runner's. - qa-functional-observer (ledger 37800857251/4): under load 6/200 observer setups threw ENOENT lstat .git/objects/maintenance.lock; 0/400 with the keys in the inherited env. The observer and its fixture (22 paid cases) are not edited; the runner backstop covers them. runFreeShard appends the two keys after any inherited GIT_CONFIG_* pairs of each shard child (a malformed inherited count is left for git to reject). A GIT_TRACE commit shows 3 maintenance lines with defaults and 0 in the runner child env; the new test pins that and the env merge cases. Failure messages now name the changed .git entries and surviving fixture directories; no assertion changed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: synchronize the zombie publication-lock test on a framed PID line Ledger 38153371796/3 and 38161906980/3: line 69 `existsSync(ready)` failed. The test polled 300 x 10 ms for the Python parent's readiness file, then another 300 x 10 ms of /proc for state Z; under shard load the fixed count expired. existsSync also raced the write (an empty file read as pid 0). The parent now waits for its child with os.waitid(P_PID, WEXITED|WNOWAIT), which leaves it unreaped in state Z, and prints the PID with flush=True. The test reads exactly one newline-framed line from the parent's stdout under the test's own (unchanged) 10 s timeout, rejects a malformed or non-positive PID, and makes one /proc read that must show Z. Early exit, a malformed line or a timeout reports the parent's exit status and stderr tail; the owned parent is always killed in cleanup. Intentional assertion replacement: the readiness-file existence check and the polling loop are replaced by the framed-line requirement (a positive PID) and one /proc read; `expect(state).toBe('Z')` and every lock-recovery assertion are unchanged. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: stop the next-version smoke test from calling live GitHub Ledger 37804770972/9: "CLI runs against real repo and emits parseable JSON" failed with `JSON Parse error: Unexpected EOF` after 30032 ms. The smoke test ran the real CLI against live `gh`; bin/gstack-next-version's per-PR `gh api` calls run one after another (spawnSync inside its async workers), so 27 open PRs passed the 30 s spawn timeout and stdout was empty. Measured with a delayed fake gh: 8 PRs x 0.5 s = 4108 ms; 27 PRs x 1.2 s = 32637 ms. The CLI defect is filed as a P1 follow-up, not fixed here. The test keeps the real repository and the real CLI and puts a named fake gh first on PATH. It logs every argv, answers exactly the calls the CLI makes (auth status only when origin is not github.com, pr view exit 1, canned pr list, repo view and contents with one same-owner PR claiming 1.6.4.0) and exits 2 with a FAKE_GH_UNHANDLED marker for anything else; glab exits 1. The test now checks the exit code with stderr before JSON.parse, the exact call set, no marker, no glab call, host github, offline false, no fallback, no warnings, the canned claim, and version 1.6.5.0. Every existing assertion is kept; the spawn timeout is unchanged. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(test-health): report the Windows wall over PR runs and name the seed's real prerequisite free-wall read windows-free-tests.yml with `branch: 'main'`, but that workflow runs only on pull requests and workflow_dispatch, so the "main" runs it averaged were dispatch duration recordings. It now uses listWorkflowRuns' existing `event: 'pull_request'` filter and prints the population inline: `windows-free-tests (successful PR runs) 7.1 min (23 runs)`, or `no successful PR runs in window`. The free-tests half is byte-identical. The CEO-10 fix text stops offering a "CI recording" (none exists for the Linux seed) and names UBICLOUD_API_KEY; test:ubicloud --record-durations copies the seed back into the checkout, and contributors without the key leave the refresh to a maintainer. docs/TESTING_INTERNALS.md drops "or a CI recording" in the same change. Intentional pin change: test/test-health-report.test.ts free-wall value `windows-free-tests 0.0 min (1 runs)` -> `windows-free-tests (successful PR runs) 0.0 min (1 runs)`; the fixture now carries a PR run and an excluded dispatch run, plus a new case for the empty and mixed populations. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(timeline-read): reject missing and unparseable --since/--branch values `--since` and `--branch` read an unbound $2 under `set -u` and crashed with exit 1 when given no value, and an unparseable --since ("yesterday", "2 weeks") silently returned unfiltered history because the Bun regex was unanchored and fell through. Unknown arguments were silently ignored. --since is now parsed once in bash (no `date -d`, no bash-4 features) into a cutoff in epoch ms, and the Bun regex is deleted. Accepted forms: "<N> minute|hour|day|week|month[s] ago" (case-insensitive, as before), "<N>h|d|w" (the form gstack-global-discover documents) and ISO dates YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ (UTC). In #1723's shape, before any read: --since must be a relative time like "7 days ago" (got no value); accepted: ... --since must be a relative time like "7 days ago" (got 'yesterday'); accepted: ... --branch must be a branch name (got flag '--limit') with "got an empty value" for ''. Unknown arguments exit 2 with the usage line; --help/-h prints it and exits 0. Tests extend the --limit table in test/timeline.test.ts and run every case on empty and populated history, plus each accepted --since form filtering old events; the quoted-branch and no-filter tests are unchanged. Behavior change: unrecognized arguments (previously ignored) now exit 2. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: record the due audit follow-ups with verdicts, and file wave-2 follow-ups docs/test-audit-2026-10.md gains a dated 2026-10-15 section from a no-spend `bun run test:health --since-days 7`, with the 2026-10-12 figures from #3154 kept as history and a verdict per target: full-fallback 45.9% (open; the audit's next step is touchfile mappings for bin/, lib/, hosts/, make-pdf/), spend per merged PR $73.52 (verdict unavailable: the pre-change baseline, recomputed with collectHealth at now=2026-10-04 behind an upper-bound client, holds no trial-outcomes artifacts because the recorder shipped with the audit), cancelled slice-minutes 1,545/week, push-to-verdict p50 11.4 min (missed against 3-5 min), flaky-pass 6.1% (missed; this wave repairs it). Census reliability is not due until 2026-10-26; the interim figure is recorded and the repair stays with #3153/#3200. TODOS.md: one appended dated section (P1 gstack-next-version serial fetch with the 4160 ms measurement, P1 standing seed recorder, P1 full-fallback/spend, P1 dated flake follow-ups, P2 createQAFunctionalFixture maintenance keys, P2 diagnostic-mode HOME/state-root artifacts). The timeline-read bullet was deleted with its fix. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore(release): tentative stamp v1.91.105.0 with its CHANGELOG entry Stamp-at-merge policy (.gstack/ship-policy.json): the branch carries a tentative PATCH stamp and its CHANGELOG section directly under `# Changelog`; the queue restamps at merge time. Base47d639ais 1.91.101.0, but the live slot scan shows 1.91.102.0 (#3190), 1.91.103.0 (#3200) and 1.91.104.0 (#3209) claimed, so this branch takes 1.91.105.0. Written with `gstack-restamp --version 1.91.105.0 --base main --expect-base47d639a--allow-repo-commands` (VERSION, package.json's npm translation, the agents digest via the policy's release tool, and the CHANGELOG header). * test(seed): refresh the free-suite duration seed from a Ubicloud recording (#3154) Recorded with `bun run test:ubicloud --record-durations` (standard-16, eu-central-h1) on this branch, which equals origin/main47d639aplus this wave; the recorder exited 0 and wrote 1219 durations (1055 before, 164 new, none removed; serial 2439 s -> 3202 s). `test:health` CEO-10 now reports 0 unseeded files. Two earlier recordings did not count: one failed on an unrelated fixed-sleep race in test/plan-seed-submission.test.ts (filed P2, not edited here), the other failed free-seed-ratchet because it ran against the first attempt's pulled seed. No file was excluded. free-seed-ratchet: three files are over the 60 s ceiling in this recording and are allowlisted with a split todo (setup-install-registry 82 s, host-renders-prune 64 s, setup-codex-scope 60.4 s); the split follow-up and the plan-seed-submission race are filed in the appended TODOS section. The ratchet test now names scripts/free-test-seed-allowlist.json literally (a pin on SEED_ALLOWLIST_FILE): pregate's lanes check maps data files to tests through string literals, and the allowlist had no lane (PREGATE_NO_LANE). Additive assertion; nothing weakened. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: give the git-maintenance backstop test a Windows-safe empty global config windows-free-shard (2) on #3212 (run 38832498861) failed two cases in test/test-free-shards-git-maintenance.test.ts, both serially reproduced by the retry pass: Git for Windows rejects GIT_CONFIG_GLOBAL=os.devNull (`fatal: unable to access '\\.\nul': Invalid argument`), so the malformed-count case saw that error instead of git's GIT_CONFIG_COUNT rejection, and the traced commit's `git init` exited nonzero. Point GIT_CONFIG_GLOBAL at a file that does not exist in the scratch HOME, as test/ceo-plan-mode-fixture.test.ts does; git reads a missing global config as empty on every platform. No assertion changed. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
155 lines
8.7 KiB
TypeScript
155 lines
8.7 KiB
TypeScript
/**
|
|
* Free-suite growth ratchet (CEO-10) and the free lane's health signals.
|
|
* The committed duration seed may not hold a file over 60 s unless the
|
|
* shrink-only allowlist names it; an added allowlist entry needs a "todo"
|
|
* (an annotated exception, printed below). The shrink check compares with the
|
|
* merge-base copy: GSTACK_FREE_SEED_BASE (set by free-tests.yml's plan job,
|
|
* which has full history) or `git merge-base HEAD origin/main` locally.
|
|
*/
|
|
import { describe, expect, test } from 'bun:test';
|
|
import { spawnSync } from 'node:child_process';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import {
|
|
ciHealthSummary,
|
|
overrunWarning,
|
|
parseSeedAllowlist,
|
|
readBaseSeedAllowlist,
|
|
SEED_ALLOWLIST_FILE,
|
|
SEED_DOCS_ANCHOR,
|
|
SEED_REFRESH_COMMAND,
|
|
seedRatchet,
|
|
shardOverruns,
|
|
unseededWarning,
|
|
} from '../scripts/lib/free-ci-health';
|
|
import { FREE_TEST_DURATIONS_FILE, type FreeCiPlan, type FreeCiResult, type FreeShardOutcome } from '../scripts/test-free-shards';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const git = (cwd: string, ...args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000 });
|
|
|
|
describe('seed ceiling and shrink-only allowlist', () => {
|
|
const durations = { 'test/slow.test.ts': 70_000, 'test/fast.test.ts': 2_000, 'test/listed.test.ts': 90_000 };
|
|
const listed = { file: 'test/listed.test.ts', reason: 'real setup per host' };
|
|
|
|
test('a seeded file over 60 s that the allowlist lacks fails with the split fix', () => {
|
|
const { violations } = seedRatchet({ durations, allowlist: [listed], baseAllowlist: [listed] });
|
|
expect(violations).toHaveLength(1);
|
|
expect(violations[0]).toContain('test/slow.test.ts is seeded at 70s, over the 60s ceiling');
|
|
expect(violations[0]).toContain(SEED_REFRESH_COMMAND);
|
|
expect(violations[0]).toContain(SEED_DOCS_ANCHOR);
|
|
});
|
|
|
|
test('an entry no longer over the ceiling, or gone from the seed, must be deleted', () => {
|
|
const { violations } = seedRatchet({
|
|
durations: { 'test/listed.test.ts': 40_000 },
|
|
allowlist: [listed, { file: 'test/deleted.test.ts', reason: 'gone' }],
|
|
baseAllowlist: null,
|
|
});
|
|
expect(violations.map(line => line.split(',')[0])).toEqual([
|
|
`${SEED_ALLOWLIST_FILE} lists test/listed.test.ts`,
|
|
`${SEED_ALLOWLIST_FILE} lists test/deleted.test.ts`,
|
|
]);
|
|
expect(violations[0]).toContain('Fix: delete its entry');
|
|
});
|
|
|
|
test('shrink-only: an entry the merge-base lacks fails unless it is an annotated exception', () => {
|
|
const slow = { file: 'test/slow.test.ts', reason: 'one real browser per case' };
|
|
const added = seedRatchet({ durations, allowlist: [listed, slow], baseAllowlist: [listed] });
|
|
expect(added.violations).toHaveLength(1);
|
|
expect(added.violations[0]).toContain('adds test/slow.test.ts, but the allowlist is shrink-only');
|
|
expect(added.violations[0]).toContain('add a "todo"');
|
|
|
|
const annotated = seedRatchet({ durations, allowlist: [listed, { ...slow, todo: 'TODOS.md: split by browser fixture' }], baseAllowlist: [listed] });
|
|
expect(annotated.violations).toEqual([]);
|
|
expect(annotated.exceptions).toEqual(['annotated exception: test/slow.test.ts (reason: one real browser per case; todo: TODOS.md: split by browser fixture)']);
|
|
});
|
|
|
|
test('a merge-base without the allowlist file counts as the first allowlist', () => {
|
|
const slow = { file: 'test/slow.test.ts', reason: 'first entry' };
|
|
expect(seedRatchet({ durations, allowlist: [listed, slow], baseAllowlist: null })).toEqual({ violations: [], exceptions: [] });
|
|
});
|
|
|
|
test('the allowlist parser rejects entries without a file or reason', () => {
|
|
expect(() => parseSeedAllowlist('{"entries":[{"file":"test/a.test.ts"}]}')).toThrow('entries[0] needs a non-empty "file" and "reason"');
|
|
expect(() => parseSeedAllowlist('{"entries":[{"file":"test/a.test.ts","reason":"r","todo":" "}]}')).toThrow('"todo" must be a non-empty string');
|
|
expect(() => parseSeedAllowlist('[]')).toThrow('expected {"entries": [...]}');
|
|
});
|
|
|
|
test('the merge-base reader distinguishes a pre-allowlist base, an allowlist base and an unresolvable base', () => {
|
|
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-seed-ratchet-'));
|
|
try {
|
|
expect(git(repo, 'init', '-q').status).toBe(0);
|
|
const commit = (message: string) => expect(git(repo, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', message, '--allow-empty').status).toBe(0);
|
|
commit('before');
|
|
expect(readBaseSeedAllowlist(repo, 'HEAD')).toEqual({ status: 'ok', entries: null });
|
|
fs.mkdirSync(path.join(repo, 'scripts'));
|
|
fs.writeFileSync(path.join(repo, SEED_ALLOWLIST_FILE), JSON.stringify({ entries: [listed] }));
|
|
expect(git(repo, 'add', '.').status).toBe(0);
|
|
commit('allowlist');
|
|
expect(readBaseSeedAllowlist(repo, 'HEAD')).toEqual({ status: 'ok', entries: [listed] });
|
|
expect(readBaseSeedAllowlist(repo, 'no-such-ref')).toEqual({ status: 'unavailable', reason: 'cannot resolve merge-base no-such-ref' });
|
|
} finally {
|
|
fs.rmSync(repo, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('the committed seed holds the ceiling and the allowlist only shrinks', () => {
|
|
expect(SEED_ALLOWLIST_FILE).toBe('scripts/free-test-seed-allowlist.json');
|
|
const seed = JSON.parse(fs.readFileSync(path.join(ROOT, FREE_TEST_DURATIONS_FILE), 'utf8')) as { durations: Record<string, number> };
|
|
const allowlist = parseSeedAllowlist(fs.readFileSync(path.join(ROOT, SEED_ALLOWLIST_FILE), 'utf8'));
|
|
const explicitBase = process.env.GSTACK_FREE_SEED_BASE;
|
|
const base = explicitBase ?? git(ROOT, 'merge-base', 'HEAD', 'origin/main').stdout.trim();
|
|
const read = base ? readBaseSeedAllowlist(ROOT, base) : { status: 'unavailable' as const, reason: 'no origin/main merge-base in this checkout' };
|
|
if (read.status === 'unavailable' && explicitBase) throw new Error(`GSTACK_FREE_SEED_BASE is set but ${read.reason}`);
|
|
if (read.status === 'unavailable') console.log(`[seed-ratchet] shrink-only check not run here (${read.reason}); free-tests.yml's plan job runs it with full history.`);
|
|
const { violations, exceptions } = seedRatchet({
|
|
durations: seed.durations,
|
|
allowlist,
|
|
baseAllowlist: read.status === 'ok' ? read.entries : allowlist,
|
|
});
|
|
for (const line of exceptions) console.log(`[seed-ratchet] ${line}`);
|
|
if (violations.length) throw new Error(`Free seed ratchet:\n- ${violations.join('\n- ')}`);
|
|
});
|
|
});
|
|
|
|
describe('free lane CI warnings', () => {
|
|
test('unseeded files warn only past the limit of five, naming the refresh command and anchor', () => {
|
|
expect(unseededWarning(['a', 'b', 'c', 'd', 'e'])).toBeNull();
|
|
const warning = unseededWarning(['a', 'b', 'c', 'd', 'e', 'f'])!;
|
|
expect(warning).toStartWith('WARNING: 6 free test files have no recorded duration (limit 5)');
|
|
expect(warning).toContain(`Fix: run \`${SEED_REFRESH_COMMAND}\``);
|
|
expect(warning).toContain(SEED_DOCS_ANCHOR);
|
|
});
|
|
|
|
test('a shard overrun needs 1.5x its prediction and at least 30 s of excess', () => {
|
|
const overruns = shardOverruns([
|
|
{ shard: 1, predictedMs: 100_000, elapsedMs: 160_000 },
|
|
{ shard: 2, predictedMs: 100_000, elapsedMs: 149_000 },
|
|
{ shard: 3, predictedMs: 10_000, elapsedMs: 30_000 },
|
|
]);
|
|
expect(overruns.map(overrun => overrun.shard)).toEqual([1]);
|
|
expect(overrunWarning(overruns)).toContain('shard 1 took 160s vs 100s predicted');
|
|
expect(overrunWarning(overruns)).toContain(SEED_REFRESH_COMMAND);
|
|
expect(overrunWarning([])).toBeNull();
|
|
});
|
|
|
|
test('the --ci-verify summary lists shards that outran the seed, counting the first attempt only', () => {
|
|
const outcome = (shard: number, files: string[], failingFiles: string[], elapsedMs: number): FreeShardOutcome =>
|
|
({ shard, files, failingFiles, elapsedMs, status: failingFiles.length ? 'failed' : 'passed' }) as FreeShardOutcome;
|
|
const plan = { version: 1, revision: 'r', id: 'p', shards: [
|
|
{ shard: 1, files: ['test/a.test.ts', 'test/b.test.ts'], predictedMs: 100_000 },
|
|
{ shard: 2, files: ['test/c.test.ts'], predictedMs: 100_000 },
|
|
] } satisfies FreeCiPlan;
|
|
const results: FreeCiResult[] = [
|
|
{ planId: 'p', revision: 'r', outcome: outcome(2, ['test/c.test.ts'], [], 200_000), retry: null },
|
|
{ planId: 'p', revision: 'r', outcome: outcome(1, plan.shards[0].files, ['test/b.test.ts'], 90_000), retry: outcome(1, ['test/b.test.ts'], [], 5_000) },
|
|
];
|
|
const summary = ciHealthSummary(plan, results);
|
|
expect(summary).toHaveLength(1);
|
|
expect(summary[0]).toContain('shard 2 took 200s vs 100s predicted');
|
|
expect(summary[0]).not.toContain('shard 1');
|
|
expect(ciHealthSummary(plan, [results[0]].map(r => ({ ...r, outcome: { ...r.outcome, elapsedMs: 100_000 } })))).toEqual([]);
|
|
});
|
|
});
|