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>
373 lines
12 KiB
TypeScript
373 lines
12 KiB
TypeScript
/**
|
|
* PostToolUse hook (plan-tune cathedral T5) — unit tests.
|
|
*
|
|
* Feeds the hook synthetic Claude Code hook payloads via stdin and asserts
|
|
* the resulting question-log.jsonl reflects the right schema. Covers:
|
|
* - Marker-first question_id (D18 progressive markers)
|
|
* - Hash fallback when no marker
|
|
* - source=hook tagging
|
|
* - source=auq-other when free_text present
|
|
* - Dedup on (source, tool_use_id) composite (D3)
|
|
* - Hook exits 0 even on malformed input (never blocks user session)
|
|
* - mcp__*__AskUserQuestion matcher acceptance
|
|
* - "(recommended)" label parse → recommended field populated
|
|
* - Refuse-on-ambiguous: two (recommended) labels → recommended omitted
|
|
*/
|
|
|
|
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 HOOK = path.join(ROOT, 'hosts', 'claude', 'hooks', 'question-log-hook');
|
|
|
|
let stateRoot: string;
|
|
|
|
beforeEach(() => {
|
|
stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hooklog-'));
|
|
// Pre-create slug-resolved project dir so the bin's gstack-slug doesn't
|
|
// recompute every time.
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(stateRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
function runHook(stdin: object): { stdout: string; stderr: string; status: number } {
|
|
const env: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(process.env)) {
|
|
if (v !== undefined) env[k] = v;
|
|
}
|
|
env.GSTACK_STATE_ROOT = stateRoot;
|
|
delete env.GSTACK_HOME;
|
|
env.GSTACK_QUESTION_LOG_NO_DERIVE = '1';
|
|
const res = spawnSync(HOOK, [], {
|
|
env,
|
|
input: JSON.stringify(stdin),
|
|
encoding: 'utf-8',
|
|
cwd: ROOT,
|
|
timeout: 30_000,
|
|
});
|
|
return {
|
|
stdout: res.stdout ?? '',
|
|
stderr: res.stderr ?? '',
|
|
status: res.status ?? -1,
|
|
};
|
|
}
|
|
|
|
function readLog(): Array<Record<string, unknown>> {
|
|
const projectDirs = fs.existsSync(path.join(stateRoot, 'projects'))
|
|
? fs.readdirSync(path.join(stateRoot, 'projects'))
|
|
: [];
|
|
const all: Array<Record<string, unknown>> = [];
|
|
for (const d of projectDirs) {
|
|
const f = path.join(stateRoot, 'projects', d, 'question-log.jsonl');
|
|
if (!fs.existsSync(f)) continue;
|
|
const lines = fs.readFileSync(f, 'utf-8').trim().split('\n').filter(Boolean);
|
|
for (const l of lines) {
|
|
try {
|
|
all.push(JSON.parse(l));
|
|
} catch {
|
|
// skip malformed
|
|
}
|
|
}
|
|
}
|
|
return all;
|
|
}
|
|
|
|
// ----------------------------------------------------------------------
|
|
// Native AskUserQuestion capture
|
|
// ----------------------------------------------------------------------
|
|
|
|
describe('PostToolUse hook (native AskUserQuestion)', () => {
|
|
test('captures one event per question with source=hook and tool_use_id', () => {
|
|
const r = runHook({
|
|
session_id: 'sess1',
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'AskUserQuestion',
|
|
tool_use_id: 'tu-1',
|
|
tool_input: {
|
|
questions: [
|
|
{
|
|
question: 'D1 — Test capture\nRecommendation: A',
|
|
options: ['A) Accept (recommended)', 'B) Reject'],
|
|
multiSelect: false,
|
|
},
|
|
],
|
|
},
|
|
tool_response: {
|
|
answers: [{ option_label: 'A) Accept (recommended)' }],
|
|
},
|
|
cwd: ROOT,
|
|
});
|
|
expect(r.status).toBe(0);
|
|
const events = readLog();
|
|
expect(events.length).toBe(1);
|
|
expect(events[0].source).toBe('hook');
|
|
expect(events[0].tool_use_id).toBe('tu-1');
|
|
expect(events[0].session_id).toBe('sess1');
|
|
expect(typeof events[0].question_id).toBe('string');
|
|
expect((events[0].question_id as string).startsWith('hook-')).toBe(true);
|
|
expect(events[0].user_choice).toContain('Accept');
|
|
// Recommended parsed from (recommended) label
|
|
expect(events[0].recommended).toContain('Accept');
|
|
});
|
|
|
|
test('marker-first question_id when <gstack-qid:foo> present', () => {
|
|
runHook({
|
|
session_id: 'sess2',
|
|
tool_name: 'AskUserQuestion',
|
|
tool_use_id: 'tu-2',
|
|
tool_input: {
|
|
questions: [
|
|
{
|
|
question: 'D2 — Marker test <gstack-qid:ship-test-failure-triage>\nRecommendation: A',
|
|
options: ['A) Fix now (recommended)', 'B) Investigate', 'C) Ack and ship'],
|
|
},
|
|
],
|
|
},
|
|
tool_response: { answers: [{ option_label: 'A) Fix now (recommended)' }] },
|
|
cwd: ROOT,
|
|
});
|
|
const events = readLog();
|
|
expect(events.length).toBe(1);
|
|
expect(events[0].question_id).toBe('ship-test-failure-triage');
|
|
// Marker stripped from summary
|
|
expect((events[0].question_summary as string).includes('<gstack-qid:')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------
|
|
// Shape D: current native AUQ result — answers object keyed by question text
|
|
// ----------------------------------------------------------------------
|
|
|
|
describe('PostToolUse hook (Shape D: answers object keyed by question text)', () => {
|
|
test('captures user_choice from the answers map, stripping the (Recommended) suffix', () => {
|
|
const qText = 'D-shape test: apply the edits?';
|
|
runHook({
|
|
session_id: 'sessD1',
|
|
tool_name: 'AskUserQuestion',
|
|
tool_use_id: 'tu-d1',
|
|
tool_input: {
|
|
questions: [
|
|
{ question: qText, options: [{ label: 'Full revision (Recommended)' }, { label: 'Hold' }] },
|
|
],
|
|
},
|
|
tool_response: { answers: { [qText]: 'Full revision (Recommended)' } },
|
|
cwd: ROOT,
|
|
});
|
|
const events = readLog();
|
|
expect(events.length).toBe(1);
|
|
expect(events[0].user_choice).toBe('Full revision');
|
|
expect(events[0].recommended).toBe('Full revision');
|
|
expect(events[0].followed_recommendation).toBe(true);
|
|
});
|
|
|
|
test('free-text answer (not an option label) → source=auq-other with free_text', () => {
|
|
const qText = 'D-shape free text test';
|
|
runHook({
|
|
session_id: 'sessD2',
|
|
tool_name: 'AskUserQuestion',
|
|
tool_use_id: 'tu-d2',
|
|
tool_input: {
|
|
questions: [{ question: qText, options: [{ label: 'Alpha' }, { label: 'Beta' }] }],
|
|
},
|
|
tool_response: { answers: { [qText]: 'I cannot see what the proposal is' } },
|
|
cwd: ROOT,
|
|
});
|
|
const events = readLog();
|
|
expect(events.length).toBe(1);
|
|
expect(events[0].source).toBe('auq-other');
|
|
expect(events[0].free_text).toContain('cannot see');
|
|
});
|
|
|
|
test('annotations notes are captured as free_text alongside a selected option', () => {
|
|
const qText = 'D-shape annotations test';
|
|
runHook({
|
|
session_id: 'sessD3',
|
|
tool_name: 'AskUserQuestion',
|
|
tool_use_id: 'tu-d3',
|
|
tool_input: {
|
|
questions: [{ question: qText, options: [{ label: 'Alpha' }, { label: 'Beta' }] }],
|
|
},
|
|
tool_response: {
|
|
answers: { [qText]: 'Alpha' },
|
|
annotations: { [qText]: { notes: 'but only after the demo' } },
|
|
},
|
|
cwd: ROOT,
|
|
});
|
|
const events = readLog();
|
|
expect(events.length).toBe(1);
|
|
expect(events[0].user_choice).toBe('Alpha');
|
|
expect(events[0].free_text).toContain('after the demo');
|
|
});
|
|
|
|
test('empty tool_response → user_choice __unknown__ and NO followed_recommendation', () => {
|
|
runHook({
|
|
session_id: 'sessD4',
|
|
tool_name: 'AskUserQuestion',
|
|
tool_use_id: 'tu-d4',
|
|
tool_input: {
|
|
questions: [
|
|
{ question: 'D-shape unknown test', options: [{ label: 'Alpha (Recommended)' }, { label: 'Beta' }] },
|
|
],
|
|
},
|
|
cwd: ROOT,
|
|
});
|
|
const events = readLog();
|
|
expect(events.length).toBe(1);
|
|
expect(events[0].user_choice).toBe('__unknown__');
|
|
expect(events[0].recommended).toBe('Alpha');
|
|
expect(events[0].followed_recommendation).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------
|
|
// MCP AskUserQuestion variant (Conductor)
|
|
// ----------------------------------------------------------------------
|
|
|
|
describe('PostToolUse hook (mcp__*__AskUserQuestion variant)', () => {
|
|
test('accepts mcp__conductor__AskUserQuestion tool_name', () => {
|
|
const r = runHook({
|
|
session_id: 'sess3',
|
|
tool_name: 'mcp__conductor__AskUserQuestion',
|
|
tool_use_id: 'tu-3',
|
|
tool_input: {
|
|
questions: [{ question: 'Test', options: ['A', 'B'] }],
|
|
},
|
|
tool_response: { answers: [{ option_label: 'A' }] },
|
|
cwd: ROOT,
|
|
});
|
|
expect(r.status).toBe(0);
|
|
expect(readLog().length).toBe(1);
|
|
});
|
|
|
|
test('ignores unrelated tool_name (defensive)', () => {
|
|
const r = runHook({
|
|
session_id: 'sess4',
|
|
tool_name: 'Bash',
|
|
tool_use_id: 'tu-4',
|
|
tool_input: {},
|
|
cwd: ROOT,
|
|
});
|
|
expect(r.status).toBe(0);
|
|
expect(readLog().length).toBe(0);
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------
|
|
// Free-text capture (Layer 8 dream cycle)
|
|
// ----------------------------------------------------------------------
|
|
|
|
describe('PostToolUse hook (free-text "Other" responses)', () => {
|
|
test('source=auq-other and free_text populated when user types free text', () => {
|
|
runHook({
|
|
session_id: 'sess5',
|
|
tool_name: 'AskUserQuestion',
|
|
tool_use_id: 'tu-5',
|
|
tool_input: {
|
|
questions: [{ question: 'D5 — Other test', options: ['A', 'B'] }],
|
|
},
|
|
tool_response: {
|
|
answers: [
|
|
{
|
|
option_label: 'Other',
|
|
free_text: 'I always include tests with new features',
|
|
},
|
|
],
|
|
},
|
|
cwd: ROOT,
|
|
});
|
|
const events = readLog();
|
|
expect(events.length).toBe(1);
|
|
expect(events[0].source).toBe('auq-other');
|
|
expect(events[0].free_text).toContain('always include tests');
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------
|
|
// Dedup
|
|
// ----------------------------------------------------------------------
|
|
|
|
describe('PostToolUse hook (dedup on source + tool_use_id)', () => {
|
|
test('second fire with same (source, tool_use_id) is dropped', () => {
|
|
const payload = {
|
|
session_id: 'sess6',
|
|
tool_name: 'AskUserQuestion',
|
|
tool_use_id: 'tu-6',
|
|
tool_input: { questions: [{ question: 'Dedup test', options: ['A'] }] },
|
|
tool_response: { answers: [{ option_label: 'A' }] },
|
|
cwd: ROOT,
|
|
};
|
|
runHook(payload);
|
|
runHook(payload);
|
|
expect(readLog().length).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------
|
|
// Refuse-on-ambiguous (D2 safety)
|
|
// ----------------------------------------------------------------------
|
|
|
|
describe('PostToolUse hook (recommended parser safety)', () => {
|
|
test('two (recommended) labels → recommended field omitted', () => {
|
|
runHook({
|
|
session_id: 'sess7',
|
|
tool_name: 'AskUserQuestion',
|
|
tool_use_id: 'tu-7',
|
|
tool_input: {
|
|
questions: [
|
|
{
|
|
question: 'Ambiguous test',
|
|
options: ['A) Foo (recommended)', 'B) Bar (recommended)'],
|
|
},
|
|
],
|
|
},
|
|
tool_response: { answers: [{ option_label: 'A) Foo (recommended)' }] },
|
|
cwd: ROOT,
|
|
});
|
|
const events = readLog();
|
|
expect(events.length).toBe(1);
|
|
expect(events[0].recommended).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ----------------------------------------------------------------------
|
|
// Crash safety
|
|
// ----------------------------------------------------------------------
|
|
|
|
describe('PostToolUse hook (crash safety)', () => {
|
|
test('exits 0 on empty stdin', () => {
|
|
const env: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(process.env)) {
|
|
if (v !== undefined) env[k] = v;
|
|
}
|
|
env.GSTACK_STATE_ROOT = stateRoot;
|
|
env.GSTACK_QUESTION_LOG_NO_DERIVE = '1';
|
|
const res = spawnSync(HOOK, [], { env, input: '', encoding: 'utf-8', timeout: 30_000 });
|
|
expect(res.status).toBe(0);
|
|
});
|
|
|
|
test('exits 0 on malformed JSON', () => {
|
|
const env: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(process.env)) {
|
|
if (v !== undefined) env[k] = v;
|
|
}
|
|
env.GSTACK_STATE_ROOT = stateRoot;
|
|
env.GSTACK_QUESTION_LOG_NO_DERIVE = '1';
|
|
const res = spawnSync(HOOK, [], {
|
|
env,
|
|
input: 'not json',
|
|
encoding: 'utf-8',
|
|
timeout: 30_000,
|
|
});
|
|
expect(res.status).toBe(0);
|
|
// Error logged to hook-errors.log
|
|
const errLog = path.join(stateRoot, 'hook-errors.log');
|
|
expect(fs.existsSync(errLog)).toBe(true);
|
|
expect(fs.readFileSync(errLog, 'utf-8')).toContain('stdin parse failed');
|
|
});
|
|
});
|