mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
spawnSync/execSync/Bun.spawnSync BLOCK the main thread, so bun's in-process per-test timeout can never fire while one waits — a hung child (stdin read, network probe, dead daemon) wedges the whole shard until the runner's external wall-clock SIGKILL. This exact class reached main: free-tests run 33262077256, test/gstack-memory-ingest.test.ts (normally 2.3s) held shard 2 at the 360s wall while its five siblings finished in ~65s. Mechanical sweep in two waves (12 + 4 fan-out agents, every edit verified against its call site): default timeout: 30_000 (matches the free runner's per-test budget), 120_000 for genuinely slow ops (installs, builds, playwright, provider CLIs), helper wrappers fixed ONCE where call sites route through them. Sites that only LOOK like calls (string fixtures, grep needles, comments) were skipped with reasons — the enforcement commit that follows marks them exempt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
161 lines
5.8 KiB
TypeScript
161 lines
5.8 KiB
TypeScript
/**
|
|
* GSTACK_STATE_ROOT override — verifies the 3 plan-tune bins honor
|
|
* GSTACK_STATE_ROOT as a higher-priority override over GSTACK_HOME.
|
|
*
|
|
* Surfaced by plan-tune cathedral D16 (Codex outside voice): tests can't
|
|
* isolate from real ~/.gstack today because the bins ignore STATE_ROOT.
|
|
* Without this override, the cathedral's E2E + integration tests would
|
|
* silently pollute the user's real profile.
|
|
*
|
|
* Contract:
|
|
* - GSTACK_STATE_ROOT set → bins write under STATE_ROOT (HOME ignored).
|
|
* - Only GSTACK_HOME set → bins write under HOME (existing behavior).
|
|
* - Neither set → falls back to $HOME/.gstack (existing behavior).
|
|
* - Both set → STATE_ROOT wins.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { spawnSync } from 'child_process';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const BIN_LOG = path.join(ROOT, 'bin', 'gstack-question-log');
|
|
const BIN_PREF = path.join(ROOT, 'bin', 'gstack-question-preference');
|
|
const BIN_DEV = path.join(ROOT, 'bin', 'gstack-developer-profile');
|
|
|
|
let stateRoot: string;
|
|
let homeRoot: string;
|
|
|
|
beforeEach(() => {
|
|
stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-state-'));
|
|
homeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-home-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(stateRoot, { recursive: true, force: true });
|
|
fs.rmSync(homeRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
function runBin(
|
|
bin: string,
|
|
args: string[],
|
|
env: Record<string, string | undefined>,
|
|
): { stdout: string; stderr: string; status: number } {
|
|
const cleaned: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries({ ...process.env, ...env })) {
|
|
if (v !== undefined) cleaned[k] = v;
|
|
}
|
|
// Strip these from process.env so the override matrix is clean.
|
|
if (env.GSTACK_STATE_ROOT === undefined) delete cleaned.GSTACK_STATE_ROOT;
|
|
if (env.GSTACK_HOME === undefined) delete cleaned.GSTACK_HOME;
|
|
const res = spawnSync(bin, args, {
|
|
env: cleaned,
|
|
encoding: 'utf-8',
|
|
cwd: ROOT,
|
|
timeout: 30_000,
|
|
});
|
|
return {
|
|
stdout: res.stdout ?? '',
|
|
stderr: res.stderr ?? '',
|
|
status: res.status ?? -1,
|
|
};
|
|
}
|
|
|
|
const SAMPLE_LOG = {
|
|
skill: 'plan-tune',
|
|
question_id: 'state-root-test',
|
|
question_summary: 'Test STATE_ROOT honoring',
|
|
category: 'clarification',
|
|
door_type: 'two-way',
|
|
options_count: 2,
|
|
user_choice: 'a',
|
|
recommended: 'a',
|
|
session_id: 'state-root-test-session',
|
|
};
|
|
|
|
describe('gstack-question-log honors GSTACK_STATE_ROOT', () => {
|
|
test('STATE_ROOT set, HOME unset → writes under STATE_ROOT', () => {
|
|
const r = runBin(BIN_LOG, [JSON.stringify(SAMPLE_LOG)], {
|
|
GSTACK_STATE_ROOT: stateRoot,
|
|
GSTACK_HOME: undefined,
|
|
});
|
|
expect(r.status).toBe(0);
|
|
// The slug is derived from cwd; just check at least one log file exists.
|
|
const projectDirs = fs.readdirSync(path.join(stateRoot, 'projects'));
|
|
expect(projectDirs.length).toBeGreaterThanOrEqual(1);
|
|
const logPath = path.join(stateRoot, 'projects', projectDirs[0], 'question-log.jsonl');
|
|
expect(fs.existsSync(logPath)).toBe(true);
|
|
});
|
|
|
|
test('STATE_ROOT wins over HOME when both set', () => {
|
|
const r = runBin(BIN_LOG, [JSON.stringify(SAMPLE_LOG)], {
|
|
GSTACK_STATE_ROOT: stateRoot,
|
|
GSTACK_HOME: homeRoot,
|
|
});
|
|
expect(r.status).toBe(0);
|
|
// STATE_ROOT must have the file.
|
|
const stateProjects = fs.readdirSync(path.join(stateRoot, 'projects'));
|
|
expect(stateProjects.length).toBeGreaterThanOrEqual(1);
|
|
// HOME must NOT have a projects dir (or it must be empty).
|
|
const homeProjectsPath = path.join(homeRoot, 'projects');
|
|
if (fs.existsSync(homeProjectsPath)) {
|
|
const homeProjects = fs.readdirSync(homeProjectsPath);
|
|
expect(homeProjects.length).toBe(0);
|
|
}
|
|
});
|
|
|
|
test('only HOME set → preserves existing behavior (writes under HOME)', () => {
|
|
const r = runBin(BIN_LOG, [JSON.stringify(SAMPLE_LOG)], {
|
|
GSTACK_STATE_ROOT: undefined,
|
|
GSTACK_HOME: homeRoot,
|
|
});
|
|
expect(r.status).toBe(0);
|
|
const homeProjects = fs.readdirSync(path.join(homeRoot, 'projects'));
|
|
expect(homeProjects.length).toBeGreaterThanOrEqual(1);
|
|
// STATE_ROOT must NOT have anything.
|
|
const stateProjectsPath = path.join(stateRoot, 'projects');
|
|
if (fs.existsSync(stateProjectsPath)) {
|
|
expect(fs.readdirSync(stateProjectsPath).length).toBe(0);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('gstack-question-preference honors GSTACK_STATE_ROOT', () => {
|
|
test('STATE_ROOT set → preferences file lives under STATE_ROOT', () => {
|
|
const write = runBin(
|
|
BIN_PREF,
|
|
[
|
|
'--write',
|
|
JSON.stringify({
|
|
question_id: 'state-root-pref-test',
|
|
preference: 'never-ask',
|
|
source: 'plan-tune',
|
|
}),
|
|
],
|
|
{ GSTACK_STATE_ROOT: stateRoot, GSTACK_HOME: undefined },
|
|
);
|
|
expect(write.status).toBe(0);
|
|
const projectDirs = fs.readdirSync(path.join(stateRoot, 'projects'));
|
|
expect(projectDirs.length).toBeGreaterThanOrEqual(1);
|
|
const prefPath = path.join(stateRoot, 'projects', projectDirs[0], 'question-preferences.json');
|
|
expect(fs.existsSync(prefPath)).toBe(true);
|
|
const prefs = JSON.parse(fs.readFileSync(prefPath, 'utf-8'));
|
|
expect(prefs['state-root-pref-test']).toBe('never-ask');
|
|
});
|
|
});
|
|
|
|
describe('gstack-developer-profile honors GSTACK_STATE_ROOT', () => {
|
|
test('STATE_ROOT set → profile file lives under STATE_ROOT, not HOME', () => {
|
|
// --read creates a stub profile if missing.
|
|
const r = runBin(BIN_DEV, ['--read'], {
|
|
GSTACK_STATE_ROOT: stateRoot,
|
|
GSTACK_HOME: homeRoot,
|
|
});
|
|
expect(r.status).toBe(0);
|
|
expect(fs.existsSync(path.join(stateRoot, 'developer-profile.json'))).toBe(true);
|
|
expect(fs.existsSync(path.join(homeRoot, 'developer-profile.json'))).toBe(false);
|
|
});
|
|
});
|