gstack/browse/test/server-no-import-side-effec...

107 lines
4.3 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
/**
* Guard the core refactor invariant: importing browse/src/server.ts must NOT
* auto-start. Before this PR, the module called `start().catch(...)` at module
* load time, which made the file impossible for embedders (gbrowser phoenix
* overlay) to import without spawning a daemon. The fix wraps that kickoff in
* `if (import.meta.main)` so the side effects only run when the module is the
* process entry point.
*
* Approach: spawn a fresh Bun subprocess that imports the module and emits a
* structured snapshot (initial vs post-import process state). Parent asserts
* that no listeners were bound, no Bun.serve started, and no SIGINT handlers
* were registered. The subprocess uses HOME=tmp + GSTACK_HOME=tmp so any
* accidental state-dir write lands in a place we can verify is empty.
*/
describe('server.ts module import has no auto-start side effects', () => {
test('importing server.ts does not bind Bun.serve, register signal handlers, or write state', async () => {
const tmpHome = path.join(os.tmpdir(), `browse-no-sfx-${Date.now()}-${process.pid}`);
fs.mkdirSync(tmpHome, { recursive: true });
const tmpGstack = path.join(tmpHome, '.gstack');
const childScript = `
const sigintBefore = process.listenerCount('SIGINT');
const sigtermBefore = process.listenerCount('SIGTERM');
const uncaughtBefore = process.listenerCount('uncaughtException');
// Snapshot any keys that look like our state path.
const fs = require('fs');
const path = require('path');
await import(${JSON.stringify(path.resolve(import.meta.dir, '../src/server.ts'))});
// After import, sleep a tick so any setTimeout(0)-style init can run.
await new Promise(r => setTimeout(r, 50));
const sigintAfter = process.listenerCount('SIGINT');
const sigtermAfter = process.listenerCount('SIGTERM');
const uncaughtAfter = process.listenerCount('uncaughtException');
// Check that the gstack home directory wasn't populated as a side effect.
let gstackPopulated = false;
try {
const entries = fs.readdirSync(${JSON.stringify(tmpGstack)});
gstackPopulated = entries.length > 0;
} catch {
// Doesn't exist — that's the win we want.
}
console.log(JSON.stringify({
sigintBefore, sigintAfter,
sigtermBefore, sigtermAfter,
uncaughtBefore, uncaughtAfter,
gstackPopulated,
}));
// Force exit so any background intervals don't keep this child alive
// (the test framework would see a hang otherwise — which itself is a
// signal that side effects DID run).
process.exit(0);
`;
const proc = Bun.spawn(['bun', '-e', childScript], {
env: {
...process.env,
HOME: tmpHome,
GSTACK_HOME: tmpGstack,
// Empty so the AUTH_TOKEN env path doesn't deterministically set a token.
AUTH_TOKEN: '',
// Force a stub state file so resolveConfig() at module load (if it
// happens) won't crawl the host's real .gstack/.
BROWSE_STATE_FILE: path.join(tmpGstack, 'browse.json'),
},
stdout: 'pipe',
stderr: 'pipe',
});
const stdout = await new Response(proc.stdout).text();
const stderr = await new Response(proc.stderr).text();
await proc.exited;
// The last JSON line in stdout is our snapshot.
const jsonLine = stdout.trim().split('\n').filter(l => l.startsWith('{')).pop();
expect(jsonLine, `child stderr: ${stderr}`).toBeDefined();
const snapshot = JSON.parse(jsonLine!);
// No new signal handlers registered (gated on import.meta.main, which
// is false in the subprocess because `bun -e` is the entry point).
expect(snapshot.sigintAfter).toBe(snapshot.sigintBefore);
expect(snapshot.sigtermAfter).toBe(snapshot.sigtermBefore);
expect(snapshot.uncaughtAfter).toBe(snapshot.uncaughtBefore);
// gstack home should remain empty — initRegistry/initAuditLog/etc. side
// effects from module load are acceptable (they happen at module level),
// but only insofar as they don't bind listeners or write project state.
// The presence/absence test here proves we didn't bind Bun.serve (which
// would also try to write the state file).
expect(snapshot.gstackPopulated).toBe(false);
// Cleanup
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* best effort */ }
}, 30_000);
});