Files
gbrain/test/scripts/check-test-isolation.test.ts
T
058fe69575 v0.26.7 test: isolation foundation (helpers + lint + quarantine) (#613)
* test: add withEnv helper + canonical PGLite block JSDoc

withEnv(overrides, fn) saves prior values, runs the callback, restores
via try/finally — including on throw. Handles delete via undefined
override. Nested calls compose. Cross-test safe; explicitly NOT
intra-file concurrent-safe (process.env is process-global).

7 unit cases covering sync, async, delete-key, delete-when-prior-unset,
restore-on-throw, nested compose, multi-key atomic restore.

reset-pglite.ts JSDoc extended with the canonical 4-line PGLite block
(beforeAll create + afterAll disconnect + beforeEach reset). The lint
script in the next commit enforces this exact shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: add check-test-isolation lint script + wire into verify

Grep-based lint enforcing 4 rules on non-serial unit test files:
  R1: no process.env mutations (use withEnv() or rename to *.serial.test.ts)
  R2: no mock.module() (rename to *.serial.test.ts)
  R3: new PGLiteEngine( only inside beforeAll() context
  R4: PGLiteEngine creators must pair with afterAll{disconnect}

Wired into 'bun run verify' and 'bun run check:all' (NOT 'bun run test'
which is the parallel runner script with no pre-check chain). Matches
the existing scripts/check-*.sh family shape (jsonb, progress, etc).

51 baseline violators captured in scripts/check-test-isolation.allowlist.
List MUST shrink over time — entries removed by v0.26.8 (env sweep) and
v0.26.9 (PGLite sweep). New files cannot be added.

CLAUDE.md ## Testing section extended with R1-R4 rules table, the
canonical 4-line PGLite block, withEnv pattern, and when-to-quarantine
guidance.

16 fixture-driven test cases for the lint: clean, R1 (5 patterns + 1
negative), R2, R3 (top-level vs in-beforeAll), R4 (missing disconnect),
*.serial.test.ts skip, test/e2e/ skip, allowlist (3 cases).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: quarantine cycle and embed mock.module test files

Both files use mock.module(...) at top level — leaks across files in
the same shard process. The check-test-isolation lint (R2) bans this
pattern in non-serial files; quarantine is the escape hatch.

Per v0.26.7 plan D5: prefer quarantine over DI on runCycle/runEmbed.
Production signatures stay frozen; tests run at --max-concurrency=1
in the serial post-pass (the existing pattern shipped in v0.26.4 for
brain-registry and reconcile-links).

Quarantine count: 2 → 4. Cap raised to 10 informational per D15.

Renames:
  test/core/cycle.test.ts → test/core/cycle.serial.test.ts
  test/embed.test.ts      → test/embed.serial.test.ts

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.26.7)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: post-ship documentation sync for v0.26.7

- README.md "Contributing" line: point to bun run test + bun run verify (parallel fast loop)
- CONTRIBUTING.md "Running tests": rewrite for the v0.26.4/v0.26.7 test surface (parallel runner, verify, slow/serial/e2e tiers)
- CONTRIBUTING.md adds "Writing tests that survive the parallel loop" section: R1-R4 lint, canonical PGLite block, withEnv pattern, when to quarantine
- llms-full.txt regenerated to pick up the README + CONTRIBUTING changes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 12:59:52 -07:00

271 lines
7.9 KiB
TypeScript

/**
* Fixture-driven unit tests for scripts/check-test-isolation.sh.
*
* Spawns the script in a tmpdir with hand-crafted fake test files and
* asserts the lint's exit code + violation messages match expectations.
* No env mutation, no mock.module, no PGLite — this test file is itself
* subject to the lint (it ships outside *.serial.test.ts and outside
* test/e2e/).
*/
import { describe, it, expect } from 'bun:test';
import { spawnSync } from 'child_process';
import { mkdtempSync, mkdirSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
const REPO_ROOT = resolve(import.meta.dir, '..', '..');
const LINT_SH = resolve(REPO_ROOT, 'scripts/check-test-isolation.sh');
interface FakeFile {
/** Path relative to the tmpdir's `test/` directory. */
path: string;
contents: string;
}
interface RunResult {
status: number;
stdout: string;
stderr: string;
}
function runLintIn(files: FakeFile[], allowlist: string[] = []): RunResult {
const dir = mkdtempSync(join(tmpdir(), 'lint-isolation-'));
mkdirSync(join(dir, 'test'), { recursive: true });
mkdirSync(join(dir, 'scripts'), { recursive: true });
for (const f of files) {
const full = join(dir, 'test', f.path);
mkdirSync(resolve(full, '..'), { recursive: true });
writeFileSync(full, f.contents);
}
// Empty allowlist file ensures the script reads OUR allowlist, not the
// real repo's, regardless of git toplevel resolution.
writeFileSync(
join(dir, 'scripts/check-test-isolation.allowlist'),
allowlist.length > 0 ? allowlist.join('\n') + '\n' : '',
);
const r = spawnSync('bash', [LINT_SH, 'test'], {
cwd: dir,
encoding: 'utf-8',
env: { ...process.env },
});
return { status: r.status ?? -1, stdout: r.stdout, stderr: r.stderr };
}
describe('check-test-isolation.sh', () => {
describe('clean files', () => {
it('returns 0 when no test files violate any rule', () => {
const r = runLintIn([
{
path: 'a.test.ts',
contents: `import { test, expect } from 'bun:test';\ntest('ok', () => expect(1).toBe(1));\n`,
},
]);
expect(r.status).toBe(0);
expect(r.stdout).toContain('check-test-isolation: OK');
});
});
describe('R1 — env mutation', () => {
it('flags process.env.X = assignment', () => {
const r = runLintIn([
{
path: 'env-write.test.ts',
contents: `process.env.OOPS = 'bad';\n`,
},
]);
expect(r.status).toBe(1);
expect(r.stdout).toContain('R1');
expect(r.stdout).toContain('env-write.test.ts');
});
it('flags process.env[bracket] = assignment', () => {
const r = runLintIn([
{
path: 'env-bracket.test.ts',
contents: `process.env['OOPS'] = 'bad';\n`,
},
]);
expect(r.status).toBe(1);
expect(r.stdout).toContain('R1');
});
it('flags delete process.env.X', () => {
const r = runLintIn([
{
path: 'env-delete.test.ts',
contents: `delete process.env.OOPS;\n`,
},
]);
expect(r.status).toBe(1);
expect(r.stdout).toContain('R1');
});
it('flags Object.assign(process.env, ...)', () => {
const r = runLintIn([
{
path: 'env-assign.test.ts',
contents: `Object.assign(process.env, { OOPS: 'bad' });\n`,
},
]);
expect(r.status).toBe(1);
expect(r.stdout).toContain('R1');
});
it('flags Reflect.set(process.env, ...)', () => {
const r = runLintIn([
{
path: 'env-reflect.test.ts',
contents: `Reflect.set(process.env, 'OOPS', 'bad');\n`,
},
]);
expect(r.status).toBe(1);
expect(r.stdout).toContain('R1');
});
it('does NOT flag a comparison like process.env.X === ...', () => {
const r = runLintIn([
{
path: 'env-read.test.ts',
contents: `if (process.env.X === 'y') {}\n`,
},
]);
expect(r.status).toBe(0);
});
});
describe('R2 — mock.module()', () => {
it('flags mock.module(...)', () => {
const r = runLintIn([
{
path: 'mocks.test.ts',
contents: `import { mock } from 'bun:test';\nmock.module('foo', () => ({}));\n`,
},
]);
expect(r.status).toBe(1);
expect(r.stdout).toContain('R2');
});
});
describe('R3 — new PGLiteEngine() outside beforeAll context', () => {
it('flags engine created at module top-level', () => {
const r = runLintIn([
{
path: 'pglite-toplevel.test.ts',
contents: `import { PGLiteEngine } from '../src/core/pglite-engine.ts';\nconst engine = new PGLiteEngine();\n`,
},
]);
expect(r.status).toBe(1);
expect(r.stdout).toContain('R3');
});
it('does NOT flag engine created within ~50 lines of a beforeAll', () => {
const r = runLintIn([
{
path: 'pglite-ok.test.ts',
contents:
`import { beforeAll, afterAll, test, expect } from 'bun:test';\n` +
`import { PGLiteEngine } from '../src/core/pglite-engine.ts';\n` +
`let engine: PGLiteEngine;\n` +
`beforeAll(async () => {\n` +
` engine = new PGLiteEngine();\n` +
` await engine.connect({});\n` +
`});\n` +
`afterAll(async () => { await engine.disconnect(); });\n` +
`test('x', () => expect(1).toBe(1));\n`,
},
]);
expect(r.status).toBe(0);
});
});
describe('R4 — afterAll/disconnect pairing', () => {
it('flags engine creation without afterAll{disconnect}', () => {
const r = runLintIn([
{
path: 'pglite-no-disconnect.test.ts',
contents:
`import { beforeAll, test, expect } from 'bun:test';\n` +
`import { PGLiteEngine } from '../src/core/pglite-engine.ts';\n` +
`let engine: PGLiteEngine;\n` +
`beforeAll(async () => { engine = new PGLiteEngine(); await engine.connect({}); });\n` +
`test('x', () => expect(1).toBe(1));\n`,
},
]);
expect(r.status).toBe(1);
expect(r.stdout).toContain('R4');
});
});
describe('scope', () => {
it('skips *.serial.test.ts files entirely', () => {
const r = runLintIn([
{
path: 'naughty.serial.test.ts',
contents: `process.env.OOPS = 'bad';\nimport { mock } from 'bun:test';\nmock.module('foo', () => ({}));\n`,
},
]);
expect(r.status).toBe(0);
});
it('skips test/e2e/ subtree', () => {
const r = runLintIn([
{
path: 'e2e/leak.test.ts',
contents: `process.env.OOPS = 'bad';\n`,
},
]);
expect(r.status).toBe(0);
});
});
describe('allowlist', () => {
it('skips files listed in the allowlist', () => {
const r = runLintIn(
[
{
path: 'legacy.test.ts',
contents: `process.env.OOPS = 'bad';\n`,
},
],
['test/legacy.test.ts'],
);
expect(r.status).toBe(0);
});
it('still flags files NOT in the allowlist when allowlist is non-empty', () => {
const r = runLintIn(
[
{
path: 'allowed.test.ts',
contents: `process.env.X = 'a';\n`,
},
{
path: 'fresh.test.ts',
contents: `process.env.Y = 'b';\n`,
},
],
['test/allowed.test.ts'],
);
expect(r.status).toBe(1);
expect(r.stdout).toContain('fresh.test.ts');
expect(r.stdout).not.toContain('allowed.test.ts');
});
it('treats # comments and blank lines in allowlist as no-ops', () => {
const r = runLintIn(
[
{
path: 'legacy.test.ts',
contents: `process.env.OOPS = 'bad';\n`,
},
],
['# legacy file', '', 'test/legacy.test.ts'],
);
expect(r.status).toBe(0);
});
});
});