mirror of https://github.com/garrytan/gstack.git
133 lines
5.2 KiB
TypeScript
133 lines
5.2 KiB
TypeScript
import { describe, test, expect } from 'bun:test';
|
|
import { spawnSync } from 'child_process';
|
|
import * as path from 'path';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const HOOK_DIR = path.join(ROOT, 'hosts', 'claude', 'hooks');
|
|
const HELPER = path.join(HOOK_DIR, 'spawn-bin.ts');
|
|
|
|
/** Every hook entrypoint, excluding the helper itself. */
|
|
function hookFiles(): string[] {
|
|
return fs
|
|
.readdirSync(HOOK_DIR)
|
|
.filter((f) => f.endsWith('.ts') && f !== 'spawn-bin.ts')
|
|
.map((f) => path.join(HOOK_DIR, f));
|
|
}
|
|
|
|
describe('claude hooks: Windows path + bin-spawn invariants', () => {
|
|
test('spawn-bin helper exists and exports the resolution surface', () => {
|
|
expect(fs.existsSync(HELPER)).toBe(true);
|
|
const src = fs.readFileSync(HELPER, 'utf-8');
|
|
expect(src).toContain('export function repoRoot');
|
|
expect(src).toContain('export function binPath');
|
|
expect(src).toContain('export function runBin');
|
|
expect(src).toContain('fileURLToPath');
|
|
});
|
|
|
|
// `new URL(import.meta.url).pathname` yields `/C:/Users/...` on Windows;
|
|
// path.resolve then rebases it onto the drive root as `C:\C:\Users\...`,
|
|
// so every subsequent spawn/read hits ENOENT. fileURLToPath is the fix.
|
|
test('no hook uses URL.pathname to locate itself on disk', () => {
|
|
const offending: string[] = [];
|
|
for (const file of [...hookFiles(), HELPER]) {
|
|
const src = fs.readFileSync(file, 'utf-8');
|
|
src.split('\n').forEach((line, idx) => {
|
|
if (line.trim().startsWith('*') || line.trim().startsWith('//')) return;
|
|
if (/new URL\(import\.meta\.url\)\.pathname/.test(line)) {
|
|
offending.push(`${path.basename(file)}:${idx + 1}`);
|
|
}
|
|
});
|
|
}
|
|
expect(offending).toEqual([]);
|
|
});
|
|
|
|
// bin/gstack-* are extensionless bash scripts. Windows has no shebang
|
|
// support, so they must be handed to bash — which runBin() does.
|
|
test('no hook spawns a bin directly; all route through runBin', () => {
|
|
const offending: string[] = [];
|
|
for (const file of hookFiles()) {
|
|
const src = fs.readFileSync(file, 'utf-8');
|
|
src.split('\n').forEach((line, idx) => {
|
|
if (line.trim().startsWith('*') || line.trim().startsWith('//')) return;
|
|
if (/\bspawnSync\s*\(/.test(line)) {
|
|
offending.push(`${path.basename(file)}:${idx + 1}`);
|
|
}
|
|
});
|
|
}
|
|
expect(offending).toEqual([]);
|
|
});
|
|
|
|
test('repoRoot resolves to the install root; binPath finds a real bin', async () => {
|
|
const { repoRoot, binPath } = await import(HELPER);
|
|
expect(fs.existsSync(path.join(repoRoot(), 'bin'))).toBe(true);
|
|
expect(fs.existsSync(path.join(repoRoot(), 'scripts', 'question-registry.ts'))).toBe(true);
|
|
expect(fs.existsSync(binPath('gstack-question-log'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
// Behavioral proof: drive question-log-hook exactly the way Claude Code does
|
|
// (hook JSON on stdin) against an isolated GSTACK_STATE_ROOT, and assert the
|
|
// event actually lands. Pre-fix this wrote nothing on Windows and appended to
|
|
// hook-errors.log instead, silently, on every question.
|
|
describe('question-log-hook: end-to-end capture', () => {
|
|
test('an AskUserQuestion fire is written to question-log.jsonl', () => {
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-hook-'));
|
|
try {
|
|
const payload = {
|
|
session_id: 'test-session',
|
|
hook_event_name: 'PostToolUse',
|
|
tool_name: 'AskUserQuestion',
|
|
tool_use_id: 'toolu_hook_e2e',
|
|
cwd: tmp,
|
|
tool_input: {
|
|
questions: [
|
|
{
|
|
question: 'Ship it?',
|
|
options: [{ label: 'Ship now (recommended)' }, { label: 'Hold' }],
|
|
},
|
|
],
|
|
},
|
|
tool_response: { answers: [{ option_label: 'Ship now' }] },
|
|
};
|
|
|
|
const res = spawnSync('bun', [path.join(HOOK_DIR, 'question-log-hook.ts')], {
|
|
input: JSON.stringify(payload),
|
|
encoding: 'utf-8',
|
|
timeout: 20000,
|
|
cwd: tmp,
|
|
env: {
|
|
...process.env,
|
|
GSTACK_STATE_ROOT: tmp,
|
|
GSTACK_QUESTION_LOG_NO_DERIVE: '1',
|
|
},
|
|
});
|
|
expect(res.status).toBe(0);
|
|
|
|
// Slug depends on cwd, so find the log rather than guessing the project dir.
|
|
const projects = path.join(tmp, 'projects');
|
|
expect(fs.existsSync(projects)).toBe(true);
|
|
const logs = fs
|
|
.readdirSync(projects)
|
|
.map((slug) => path.join(projects, slug, 'question-log.jsonl'))
|
|
.filter((p) => fs.existsSync(p));
|
|
expect(logs.length).toBe(1);
|
|
|
|
const event = JSON.parse(fs.readFileSync(logs[0], 'utf-8').trim().split('\n')[0]);
|
|
expect(event.source).toBe('hook');
|
|
expect(event.user_choice).toBe('Ship now');
|
|
expect(event.recommended).toBe('Ship now');
|
|
expect(event.followed_recommendation).toBe(true);
|
|
expect(event.tool_use_id).toBe('toolu_hook_e2e');
|
|
|
|
// The failure mode this guards against was silent: the hook exited 0
|
|
// while only ever appending to the error log.
|
|
const errLog = path.join(tmp, 'hook-errors.log');
|
|
expect(fs.existsSync(errLog) ? fs.readFileSync(errLog, 'utf-8') : '').toBe('');
|
|
} finally {
|
|
fs.rmSync(tmp, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|