Files
gbrain/test/scripts/run-unit-parallel.test.ts

157 lines
6.7 KiB
TypeScript

/**
* Regression tests (a) + (d) for scripts/run-unit-parallel.sh:
* (a) Exit-code propagation: a failing test in any shard MUST cause the
* wrapper to exit non-zero. The hardest contract to silently break
* in a fan-out wrapper (`for ... &; wait` returns the LAST child's
* status, not any failure's).
* (d) Failure-log contract: when any test fails, the wrapper writes
* extracted failure block(s) to .context/test-failures.log with
* `--- shard $i:` prefixes, and prints a loud stderr banner with
* the absolute path. Empty log ⇔ exit 0.
*
* The wrapper takes ~1.5 minutes against the real test suite. To keep
* this regression test fast and hermetic, we point it at a tiny tempdir
* containing one passing and one failing test, override the discovery
* roots via env-vars, and run with --shards=2.
*
* NOT covered here: the heartbeat (timing-sensitive, not load-bearing
* for correctness) and timeout / WEDGED markers (require synthesizing a
* hung test which is fragile across machines). Those rely on the live
* smoke tests captured in CHANGELOG measurements.
*/
import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
import { execFileSync, spawnSync } from 'child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, copyFileSync, chmodSync } from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
const REPO_ROOT = resolve(import.meta.dir, '..', '..');
const PARALLEL_SH_SRC = resolve(REPO_ROOT, 'scripts/run-unit-parallel.sh');
const SHARD_SH_SRC = resolve(REPO_ROOT, 'scripts/run-unit-shard.sh');
const SERIAL_SH_SRC = resolve(REPO_ROOT, 'scripts/run-serial-tests.sh');
let TMPROOT: string;
beforeAll(() => {
// Build a tiny repo-shaped tempdir with the wrapper scripts copied in
// and 4 fixture test files (3 pass, 1 fail). The wrapper's `find test`
// expression will pick them up via cwd.
TMPROOT = mkdtempSync(join(tmpdir(), 'gbrain-parallel-test-'));
mkdirSync(join(TMPROOT, 'scripts'), { recursive: true });
mkdirSync(join(TMPROOT, 'test'), { recursive: true });
copyFileSync(PARALLEL_SH_SRC, join(TMPROOT, 'scripts', 'run-unit-parallel.sh'));
copyFileSync(SHARD_SH_SRC, join(TMPROOT, 'scripts', 'run-unit-shard.sh'));
copyFileSync(SERIAL_SH_SRC, join(TMPROOT, 'scripts', 'run-serial-tests.sh'));
chmodSync(join(TMPROOT, 'scripts', 'run-unit-parallel.sh'), 0o755);
chmodSync(join(TMPROOT, 'scripts', 'run-unit-shard.sh'), 0o755);
chmodSync(join(TMPROOT, 'scripts', 'run-serial-tests.sh'), 0o755);
// 3 passing + 1 failing test file. Round-robin sharding will land
// them across 2 shards so we exercise the multi-shard merge path.
const passing = `import { describe, it, expect } from 'bun:test';
describe('passing', () => {
it('arithmetic works', () => { expect(1 + 1).toBe(2); });
});`;
const failing = `import { describe, it, expect } from 'bun:test';
describe('failing-on-purpose', () => {
it('expects 1 to equal 2 (this should fail)', () => { expect(1).toBe(2); });
});`;
writeFileSync(join(TMPROOT, 'test', 'a-pass.test.ts'), passing);
writeFileSync(join(TMPROOT, 'test', 'b-pass.test.ts'), passing);
writeFileSync(join(TMPROOT, 'test', 'c-pass.test.ts'), passing);
writeFileSync(join(TMPROOT, 'test', 'd-fail.test.ts'), failing);
});
afterAll(() => {
if (TMPROOT) rmSync(TMPROOT, { recursive: true, force: true });
});
function runWrapper(extraArgs: string[] = []): { code: number; stdout: string; stderr: string } {
const result = spawnSync(
'bash',
[join(TMPROOT, 'scripts', 'run-unit-parallel.sh'), '--shards', '2', ...extraArgs],
{ cwd: TMPROOT, encoding: 'utf-8', env: { ...process.env } },
);
return {
code: result.status ?? -1,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
describe('run-unit-parallel.sh exit-code propagation (a)', () => {
it('exits non-zero when any shard contains a failing test', () => {
const r = runWrapper();
expect(r.code).not.toBe(0);
});
it('exits zero when all shards pass (after removing the failing fixture)', () => {
rmSync(join(TMPROOT, 'test', 'd-fail.test.ts'));
try {
const r = runWrapper();
expect(r.code).toBe(0);
} finally {
// Restore the failing fixture for any downstream tests in the same
// describe block (afterAll cleans the whole tempdir; this is belt-
// and-suspenders).
const failing = `import { describe, it, expect } from 'bun:test';
describe('failing-on-purpose', () => {
it('expects 1 to equal 2', () => { expect(1).toBe(2); });
});`;
writeFileSync(join(TMPROOT, 'test', 'd-fail.test.ts'), failing);
}
});
});
describe('run-unit-parallel.sh failure-log contract (d)', () => {
it('writes failures to .context/test-failures.log with --- shard prefix on failure', () => {
const r = runWrapper();
expect(r.code).not.toBe(0);
const failureLog = join(TMPROOT, '.context/test-failures.log');
expect(existsSync(failureLog)).toBe(true);
const contents = readFileSync(failureLog, 'utf-8');
expect(contents.length).toBeGreaterThan(0);
expect(contents).toMatch(/--- shard \d+:/);
expect(contents).toContain('failing-on-purpose');
});
it('prints loud stderr banner with absolute failure-log path on failure', () => {
const r = runWrapper();
expect(r.code).not.toBe(0);
expect(r.stderr).toContain('TEST FAILURES');
// Banner includes the absolute path so users can `cat` it directly.
expect(r.stderr).toContain(join(TMPROOT, '.context', 'test-failures.log'));
});
it('clears .context/test-failures.log to empty when all shards pass', () => {
// Pre-seed a stale failure log to prove it gets cleared.
mkdirSync(join(TMPROOT, '.context'), { recursive: true });
writeFileSync(join(TMPROOT, '.context', 'test-failures.log'), 'STALE\n');
rmSync(join(TMPROOT, 'test', 'd-fail.test.ts'));
try {
const r = runWrapper();
expect(r.code).toBe(0);
const contents = readFileSync(join(TMPROOT, '.context', 'test-failures.log'), 'utf-8');
expect(contents).toBe('');
} finally {
const failing = `import { describe, it, expect } from 'bun:test';
describe('failing-on-purpose', () => {
it('expects 1 to equal 2', () => { expect(1).toBe(2); });
});`;
writeFileSync(join(TMPROOT, 'test', 'd-fail.test.ts'), failing);
}
});
it('writes per-shard summary lines to .context/test-summary.txt', () => {
runWrapper();
const summary = readFileSync(join(TMPROOT, '.context', 'test-summary.txt'), 'utf-8');
// Format: `shard 1/2: pass=N fail=N skip=N rc=N`
expect(summary).toMatch(/shard 1\/2: pass=\d+ fail=\d+ skip=\d+ rc=\d+/);
expect(summary).toMatch(/shard 2\/2: pass=\d+ fail=\d+ skip=\d+ rc=\d+/);
});
});