mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
271 lines
7.9 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|