mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-15 01:15:29 +02:00
test: phantom-hooks heal coverage — incident facsimile, per-item safety, lock, canonical tripwires
- gstack-settings-hook-schema-aware: 16 new cases — identity re-point (tag
restore), foreign-basename rejection, mixed-entry per-item safety for
add-event/remove-source/--all, prune-stale modes incl. bash-prefix +
Windows-backslash + spaced-path idempotence, duplicate collapse preferring
the tagged twin, plan_tune_hooks:no split, backup-on-change no-churn,
fail-closed corrupt-JSON for every mutator, stale-lock takeover,
fresh-foreign-lock skip, two-writer concurrency smoke, and an INCIDENT
FACSIMILE replaying the exact 2026-08-17 production damage (6/3/2 entries,
mixed tags, live-ephemeral Stop) healing to 2/1/1 canonical.
- NEW setup-hook-canonical-paths: static tripwires — canonical-only resolver
(no $SOURCE_GSTACK_DIR anywhere in it), heal-before-guards ordering,
unsuppressed heal output, ${VAR:-0} counter idiom, shared-prelude
concatenation at every bun call site, KNOWN_HOOKS completeness vs setup's
registrations, uninstall cleanup-before-deletion ordering, defect-class
warning present.
- setup-plan-tune-hooks-noninteractive: PT_EXPLICIT pins + `gstack-config
has` provenance + has-subcommand behavior (env-resolution, malformed keys).
- auq-error-fallback-hook: registration + both-teardown wiring (previously
untested).
- uninstall: behavioral ordering test running the INSTALLED copy from inside
the root it deletes.
- setup-windows-fallback / gstack-config-key-locale: pins updated for the new
HOOK_CMD shape and the third C-locale validator.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f0560738af
commit
05e6f1cfe5
@@ -349,3 +349,432 @@ describe('list-sources', () => {
|
||||
expect(r.stdout).toMatch(/no settings file/);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Phantom-hooks heal surface (v1.67.2): KNOWN_HOOKS identity table,
|
||||
// per-item mutation, prune-stale, mutation lock, fail-closed parse.
|
||||
//
|
||||
// Ownership is intrinsic (basename + relpath suffix + event/matcher against
|
||||
// the fixed table) because Claude Code strips the _gstack_source key when it
|
||||
// rewrites settings.json — tag-only dedupe is what let every Conductor
|
||||
// worktree append a fresh dead entry.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const AUQ_MATCHER = '(AskUserQuestion|mcp__.*__AskUserQuestion)';
|
||||
const HOOK_NAMES = [
|
||||
'question-log-hook',
|
||||
'question-preference-hook',
|
||||
'auq-error-fallback-hook',
|
||||
'timeline-stop-hook',
|
||||
];
|
||||
|
||||
/** run() with hermetic gstack-config state (prune-stale consults plan_tune_hooks). */
|
||||
function runIso(args: string[], extraEnv: Record<string, string> = {}) {
|
||||
try {
|
||||
const stdout = execSync([SETTINGS_HOOK, ...args].map((s) => `'${s}'`).join(' '), {
|
||||
env: {
|
||||
...process.env,
|
||||
GSTACK_SETTINGS_FILE: settingsFile,
|
||||
GSTACK_STATE_ROOT: tmpDir,
|
||||
...extraEnv,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
});
|
||||
return { stdout, stderr: '', exitCode: 0 };
|
||||
} catch (e: any) {
|
||||
return { stdout: e.stdout || '', stderr: e.stderr || '', exitCode: e.status ?? 1 };
|
||||
}
|
||||
}
|
||||
|
||||
/** A fake stable install with executable hooks, under `base`. */
|
||||
function mkCanon(base: string, name = 'canon'): string {
|
||||
const canon = path.join(base, name);
|
||||
fs.mkdirSync(path.join(canon, 'hosts', 'claude', 'hooks'), { recursive: true });
|
||||
fs.mkdirSync(path.join(canon, 'bin'), { recursive: true });
|
||||
for (const h of HOOK_NAMES) {
|
||||
const p = path.join(canon, 'hosts', 'claude', 'hooks', h);
|
||||
fs.writeFileSync(p, '#!/bin/sh\n');
|
||||
fs.chmodSync(p, 0o755);
|
||||
}
|
||||
const su = path.join(canon, 'bin', 'gstack-session-update');
|
||||
fs.writeFileSync(su, '#!/bin/sh\n');
|
||||
fs.chmodSync(su, 0o755);
|
||||
return canon;
|
||||
}
|
||||
|
||||
function hookEntry(cmd: string, matcher?: string, src?: string, extraItems: any[] = []) {
|
||||
const e: any = { hooks: [...extraItems, { type: 'command', command: cmd, timeout: 5 }] };
|
||||
if (matcher) e.matcher = matcher;
|
||||
if (src) e._gstack_source = src;
|
||||
return e;
|
||||
}
|
||||
|
||||
function backups(): string[] {
|
||||
return fs.readdirSync(tmpDir).filter((f) => f.startsWith('settings.json.bak.'));
|
||||
}
|
||||
|
||||
describe('add-event: per-item identity re-point', () => {
|
||||
test('tag-stripped stale worktree path is re-pointed in place, tag restored', () => {
|
||||
const canon = mkCanon(tmpDir);
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: { PostToolUse: [hookEntry('/dead/wt/hosts/claude/hooks/question-log-hook', AUQ_MATCHER)] },
|
||||
}, null, 2));
|
||||
runIso([
|
||||
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
|
||||
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
|
||||
'--source', 'plan-tune-cathedral', '--timeout', '5',
|
||||
]);
|
||||
const s = settings();
|
||||
expect(s.hooks.PostToolUse).toHaveLength(1);
|
||||
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe(`${canon}/hosts/claude/hooks/question-log-hook`);
|
||||
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('plan-tune-cathedral');
|
||||
});
|
||||
|
||||
test('foreign path with a gstack basename is NOT claimed (wrong relpath suffix)', () => {
|
||||
const canon = mkCanon(tmpDir);
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: { PostToolUse: [hookEntry('/home/u/myhooks/question-log-hook', AUQ_MATCHER)] },
|
||||
}, null, 2));
|
||||
runIso([
|
||||
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
|
||||
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
|
||||
'--source', 'plan-tune-cathedral',
|
||||
]);
|
||||
const s = settings();
|
||||
expect(s.hooks.PostToolUse).toHaveLength(2);
|
||||
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('/home/u/myhooks/question-log-hook');
|
||||
});
|
||||
|
||||
test('mixed entry: only the gstack item (index > 0) is replaced; the user item survives', () => {
|
||||
const canon = mkCanon(tmpDir);
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
PostToolUse: [hookEntry(
|
||||
'/dead/wt/hosts/claude/hooks/question-log-hook', AUQ_MATCHER, undefined,
|
||||
[{ type: 'command', command: '/Users/me/my-own-hook' }],
|
||||
)],
|
||||
},
|
||||
}, null, 2));
|
||||
runIso([
|
||||
'add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER,
|
||||
'--command', `${canon}/hosts/claude/hooks/question-log-hook`,
|
||||
'--source', 'plan-tune-cathedral',
|
||||
]);
|
||||
const s = settings();
|
||||
expect(s.hooks.PostToolUse).toHaveLength(1);
|
||||
const items = s.hooks.PostToolUse[0].hooks;
|
||||
expect(items).toHaveLength(2);
|
||||
expect(items[0].command).toBe('/Users/me/my-own-hook');
|
||||
expect(items[1].command).toBe(`${canon}/hosts/claude/hooks/question-log-hook`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove-source: per-item', () => {
|
||||
test('mixed tagged entry: gstack item removed, user item survives, tag dropped', () => {
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
PostToolUse: [hookEntry(
|
||||
'/x/hosts/claude/hooks/question-log-hook', AUQ_MATCHER, 'plan-tune-cathedral',
|
||||
[{ type: 'command', command: '/Users/me/my-own-hook' }],
|
||||
)],
|
||||
},
|
||||
}, null, 2));
|
||||
const r = runIso(['remove-source', '--source', 'plan-tune-cathedral']);
|
||||
expect(r.stdout).toMatch(/removed 1 hook/);
|
||||
const s = settings();
|
||||
expect(s.hooks.PostToolUse).toHaveLength(1);
|
||||
expect(s.hooks.PostToolUse[0].hooks).toHaveLength(1);
|
||||
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('/Users/me/my-own-hook');
|
||||
expect(s.hooks.PostToolUse[0]._gstack_source).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('prune-stale', () => {
|
||||
test('prunes dead gstack items; keeps live gstack and dead non-gstack', () => {
|
||||
const canon = mkCanon(tmpDir);
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
hookEntry(`${canon}/hosts/claude/hooks/question-log-hook`, AUQ_MATCHER), // live gstack
|
||||
hookEntry('/dead/wt/hosts/claude/hooks/auq-error-fallback-hook', AUQ_MATCHER), // dead gstack
|
||||
hookEntry('/dead/user/own-hook', AUQ_MATCHER), // dead NON-gstack
|
||||
],
|
||||
},
|
||||
}, null, 2));
|
||||
const r = runIso(['prune-stale']);
|
||||
expect(r.stdout).toMatch(/removed 1 gstack hook entries/);
|
||||
const s = settings();
|
||||
expect(s.hooks.PostToolUse).toHaveLength(2);
|
||||
const cmds = s.hooks.PostToolUse.map((e: any) => e.hooks[0].command);
|
||||
expect(cmds).toContain(`${canon}/hosts/claude/hooks/question-log-hook`);
|
||||
expect(cmds).toContain('/dead/user/own-hook');
|
||||
});
|
||||
|
||||
test('no-op run writes no backup and leaves the file byte-identical', () => {
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: { PreToolUse: [hookEntry('/Users/me/my-own-hook', 'Bash')] },
|
||||
}, null, 2) + '\n');
|
||||
const before = fs.readFileSync(settingsFile, 'utf-8');
|
||||
const r = runIso(['prune-stale']);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
|
||||
expect(backups()).toHaveLength(0);
|
||||
expect(fs.existsSync(path.join(tmpDir, 'settings.json.bak-latest'))).toBe(false);
|
||||
});
|
||||
|
||||
test('--repoint re-points dead AND live items, preserves bash prefix, restores tags', () => {
|
||||
const canon = mkCanon(tmpDir);
|
||||
const live = mkCanon(tmpDir, 'live-worktree');
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
Stop: [hookEntry(`${live}/hosts/claude/hooks/timeline-stop-hook`)], // LIVE but ephemeral
|
||||
PostToolUse: [hookEntry('bash /dead/wt/hosts/claude/hooks/question-log-hook', AUQ_MATCHER)],
|
||||
},
|
||||
}, null, 2));
|
||||
const r = runIso(['prune-stale', '--repoint', canon]);
|
||||
expect(r.stdout).toMatch(/repointed 2/);
|
||||
const s = settings();
|
||||
expect(s.hooks.Stop[0].hooks[0].command).toBe(`${canon}/hosts/claude/hooks/timeline-stop-hook`);
|
||||
expect(s.hooks.Stop[0]._gstack_source).toBe('gstack-timeline-stop');
|
||||
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe(`bash ${canon}/hosts/claude/hooks/question-log-hook`);
|
||||
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('plan-tune-cathedral');
|
||||
});
|
||||
|
||||
test('--repoint collapses exact duplicates preferring the tagged twin', () => {
|
||||
const canon = mkCanon(tmpDir);
|
||||
const cmd = `${canon}/hosts/claude/hooks/question-log-hook`;
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
hookEntry('/dead/a/hosts/claude/hooks/question-log-hook', AUQ_MATCHER),
|
||||
hookEntry(cmd, AUQ_MATCHER, 'plan-tune-cathedral'),
|
||||
],
|
||||
},
|
||||
}, null, 2));
|
||||
runIso(['prune-stale', '--repoint', canon]);
|
||||
const s = settings();
|
||||
expect(s.hooks.PostToolUse).toHaveLength(1);
|
||||
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe(cmd);
|
||||
expect(s.hooks.PostToolUse[0]._gstack_source).toBe('plan-tune-cathedral');
|
||||
});
|
||||
|
||||
test('--repoint never ADDS entries (repair, not registration)', () => {
|
||||
const canon = mkCanon(tmpDir);
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({ theme: 'dark' }, null, 2) + '\n');
|
||||
const before = fs.readFileSync(settingsFile, 'utf-8');
|
||||
runIso(['prune-stale', '--repoint', canon]);
|
||||
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
|
||||
});
|
||||
|
||||
test('Windows backslash path is classified as gstack-owned and pruned when dead', () => {
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
PostToolUse: [hookEntry('C:\\dead\\wt\\hosts\\claude\\hooks\\question-log-hook', AUQ_MATCHER)],
|
||||
},
|
||||
}, null, 2));
|
||||
const r = runIso(['prune-stale']);
|
||||
expect(r.stdout).toMatch(/removed 1/);
|
||||
expect(settings().hooks).toBeUndefined();
|
||||
});
|
||||
|
||||
test('spaced canonical root produces a quoted command that stays owned (idempotent)', () => {
|
||||
const spacedBase = path.join(tmpDir, 'My Claude');
|
||||
fs.mkdirSync(spacedBase, { recursive: true });
|
||||
const canon = mkCanon(spacedBase);
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: { Stop: [hookEntry('/dead/wt/hosts/claude/hooks/timeline-stop-hook')] },
|
||||
}, null, 2));
|
||||
runIso(['prune-stale', '--repoint', canon]);
|
||||
const s = settings();
|
||||
expect(s.hooks.Stop[0].hooks[0].command).toBe(`"${canon}/hosts/claude/hooks/timeline-stop-hook"`);
|
||||
// Second run: the quoted command is still recognized as ours — no churn.
|
||||
const before = fs.readFileSync(settingsFile, 'utf-8');
|
||||
const r2 = runIso(['prune-stale', '--repoint', canon]);
|
||||
expect(r2.stdout).toMatch(/removed 0 gstack hook entries \(repointed 0\)/);
|
||||
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
|
||||
});
|
||||
|
||||
test('--all removes live untagged gstack items, spares user hooks and mixed-entry user items', () => {
|
||||
const canon = mkCanon(tmpDir);
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
PostToolUse: [hookEntry(`${canon}/hosts/claude/hooks/question-log-hook`, AUQ_MATCHER)],
|
||||
Stop: [hookEntry(
|
||||
`${canon}/hosts/claude/hooks/timeline-stop-hook`, undefined, 'gstack-timeline-stop',
|
||||
[{ type: 'command', command: '/Users/me/custom-stop-hook' }],
|
||||
)],
|
||||
PreCompact: [hookEntry('/Users/me/my-own-hook')],
|
||||
},
|
||||
}, null, 2));
|
||||
const r = runIso(['prune-stale', '--all']);
|
||||
expect(r.stdout).toMatch(/removed 2/);
|
||||
const s = settings();
|
||||
expect(s.hooks.PostToolUse).toBeUndefined();
|
||||
expect(s.hooks.Stop[0].hooks).toHaveLength(1);
|
||||
expect(s.hooks.Stop[0].hooks[0].command).toBe('/Users/me/custom-stop-hook');
|
||||
expect(s.hooks.Stop[0]._gstack_source).toBeUndefined();
|
||||
expect(s.hooks.PreCompact[0].hooks[0].command).toBe('/Users/me/my-own-hook');
|
||||
});
|
||||
|
||||
test('--all removes tagged single-item legacy strays (no table match)', () => {
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: { Stop: [hookEntry('/old/install/bin/gstack-verify-gate', undefined, 'gstack-verify-gate')] },
|
||||
}, null, 2));
|
||||
const r = runIso(['prune-stale', '--all']);
|
||||
expect(r.stdout).toMatch(/removed 1/);
|
||||
expect(settings().hooks).toBeUndefined();
|
||||
});
|
||||
|
||||
test('--all and --repoint are mutually exclusive', () => {
|
||||
const r = runIso(['prune-stale', '--all', '--repoint', '/x']);
|
||||
expect(r.exitCode).not.toBe(0);
|
||||
expect(r.stderr).toMatch(/mutually exclusive/);
|
||||
});
|
||||
|
||||
test('explicit plan_tune_hooks:no — dead plan-tune pruned, live plan-tune NOT re-pointed, Stop still re-pointed', () => {
|
||||
const canon = mkCanon(tmpDir);
|
||||
const live = mkCanon(tmpDir, 'live-worktree');
|
||||
execSync(`'${path.join(ROOT, 'bin', 'gstack-config')}' set plan_tune_hooks no`, {
|
||||
env: { ...process.env, GSTACK_STATE_ROOT: tmpDir },
|
||||
});
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
PostToolUse: [
|
||||
hookEntry('/dead/wt/hosts/claude/hooks/question-log-hook', AUQ_MATCHER), // dead plan-tune
|
||||
hookEntry(`${live}/hosts/claude/hooks/auq-error-fallback-hook`, AUQ_MATCHER), // LIVE plan-tune
|
||||
],
|
||||
Stop: [hookEntry('/dead/wt/hosts/claude/hooks/timeline-stop-hook')],
|
||||
},
|
||||
}, null, 2));
|
||||
runIso(['prune-stale', '--repoint', canon]);
|
||||
const s = settings();
|
||||
expect(s.hooks.PostToolUse).toHaveLength(1);
|
||||
// Live plan-tune hook left exactly where it was (no re-activation without consent).
|
||||
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe(`${live}/hosts/claude/hooks/auq-error-fallback-hook`);
|
||||
// Stop hook is not part of the opt-out — re-pointed to canonical.
|
||||
expect(s.hooks.Stop[0].hooks[0].command).toBe(`${canon}/hosts/claude/hooks/timeline-stop-hook`);
|
||||
});
|
||||
|
||||
test('incident facsimile: the exact live-damage shape heals to canonical', () => {
|
||||
// Replays the 2026-08-17 production state: 6 PostToolUse / 3 PreToolUse /
|
||||
// 2 Stop entries; 6 dead (deleted worktrees), tags stripped on some, one
|
||||
// live-but-ephemeral Stop hook, plus a user hook that must survive.
|
||||
const canon = mkCanon(tmpDir);
|
||||
const cebu = mkCanon(tmpDir, 'cebu-v4');
|
||||
const dead = (n: string) => `/dead/biarritz-v3/hosts/claude/hooks/${n}`;
|
||||
const dead2 = (n: string) => `/dead/taipei-v2/hosts/claude/hooks/${n}`;
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({
|
||||
hooks: {
|
||||
SessionStart: [hookEntry(`${canon}/bin/gstack-session-update`)],
|
||||
PostToolUse: [
|
||||
hookEntry(`${canon}/hosts/claude/hooks/auq-error-fallback-hook`, AUQ_MATCHER),
|
||||
hookEntry(`${canon}/hosts/claude/hooks/question-log-hook`, AUQ_MATCHER),
|
||||
hookEntry(dead('question-log-hook'), AUQ_MATCHER),
|
||||
hookEntry(dead('auq-error-fallback-hook'), AUQ_MATCHER),
|
||||
hookEntry(dead2('question-log-hook'), AUQ_MATCHER, 'plan-tune-cathedral'),
|
||||
hookEntry(dead2('auq-error-fallback-hook'), AUQ_MATCHER, 'auq-error-fallback'),
|
||||
],
|
||||
PreToolUse: [
|
||||
hookEntry(`${canon}/hosts/claude/hooks/question-preference-hook`, AUQ_MATCHER),
|
||||
hookEntry(dead('question-preference-hook'), AUQ_MATCHER),
|
||||
hookEntry(dead2('question-preference-hook'), AUQ_MATCHER, 'plan-tune-cathedral'),
|
||||
],
|
||||
Stop: [
|
||||
hookEntry(`${cebu}/hosts/claude/hooks/timeline-stop-hook`),
|
||||
hookEntry(dead2('timeline-stop-hook'), undefined, 'gstack-timeline-stop'),
|
||||
],
|
||||
PreCompact: [hookEntry('/Users/me/my-own-hook')],
|
||||
},
|
||||
}, null, 2));
|
||||
const r = runIso(['prune-stale', '--repoint', canon]);
|
||||
expect(r.exitCode).toBe(0);
|
||||
const s = settings();
|
||||
expect(s.hooks.SessionStart).toHaveLength(1);
|
||||
expect(s.hooks.PostToolUse).toHaveLength(2);
|
||||
expect(s.hooks.PreToolUse).toHaveLength(1);
|
||||
expect(s.hooks.Stop).toHaveLength(1);
|
||||
expect(s.hooks.PreCompact[0].hooks[0].command).toBe('/Users/me/my-own-hook');
|
||||
for (const ev of ['SessionStart', 'PostToolUse', 'PreToolUse', 'Stop']) {
|
||||
for (const e of s.hooks[ev]) {
|
||||
expect(e._gstack_source).toBeDefined();
|
||||
for (const it of e.hooks) expect(it.command.startsWith(canon)).toBe(true);
|
||||
}
|
||||
}
|
||||
const postSources = s.hooks.PostToolUse.map((e: any) => e._gstack_source).sort();
|
||||
expect(postSources).toEqual(['auq-error-fallback', 'plan-tune-cathedral']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fail-closed parse (pre-existing data-loss fix)', () => {
|
||||
const MUTATORS: string[][] = [
|
||||
['add', '/x/bin/gstack-session-update'],
|
||||
['remove', '/x/bin/gstack-session-update'],
|
||||
['add-event', '--event', 'Stop', '--command', '/x', '--source', 's'],
|
||||
['remove-source', '--source', 'plan-tune-cathedral'],
|
||||
['prune-stale'],
|
||||
];
|
||||
test('every mutator refuses to touch a corrupt settings.json', () => {
|
||||
for (const args of MUTATORS) {
|
||||
fs.writeFileSync(settingsFile, '{definitely not json');
|
||||
const r = runIso(args);
|
||||
expect(r.exitCode).not.toBe(0);
|
||||
expect(r.stderr).toMatch(/refusing to mutate/);
|
||||
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe('{definitely not json');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutation lock', () => {
|
||||
test('stale lock (old mtime) is taken over; mutation proceeds', () => {
|
||||
const lockDir = `${settingsFile}.lock`;
|
||||
fs.mkdirSync(lockDir);
|
||||
fs.writeFileSync(path.join(lockDir, 'owner'), 'dead-process');
|
||||
const old = new Date(Date.now() - 120_000);
|
||||
fs.utimesSync(lockDir, old, old);
|
||||
const r = runIso(['add-event', '--event', 'Stop', '--command', '/x/hosts/claude/hooks/timeline-stop-hook', '--source', 'gstack-timeline-stop']);
|
||||
expect(r.exitCode).toBe(0);
|
||||
expect(settings().hooks.Stop).toHaveLength(1);
|
||||
expect(fs.existsSync(lockDir)).toBe(false); // released after the mutation
|
||||
});
|
||||
|
||||
test('fresh foreign lock: mutation skipped with a warning, file untouched', () => {
|
||||
fs.writeFileSync(settingsFile, JSON.stringify({ theme: 'dark' }, null, 2) + '\n');
|
||||
const before = fs.readFileSync(settingsFile, 'utf-8');
|
||||
const lockDir = `${settingsFile}.lock`;
|
||||
fs.mkdirSync(lockDir);
|
||||
fs.writeFileSync(path.join(lockDir, 'owner'), 'another-live-process');
|
||||
// Capture stderr explicitly: on a zero exit, execSync passes stderr
|
||||
// through to the parent instead of returning it.
|
||||
const cmd = [SETTINGS_HOOK, 'add-event', '--event', 'Stop', '--command', '/x', '--source', 's']
|
||||
.map((s) => `'${s}'`).join(' ');
|
||||
const out = execSync(`${cmd} 2>&1`, {
|
||||
env: {
|
||||
...process.env,
|
||||
GSTACK_SETTINGS_FILE: settingsFile,
|
||||
GSTACK_STATE_ROOT: tmpDir,
|
||||
GSTACK_SETTINGS_LOCK_TIMEOUT_MS: '300',
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
});
|
||||
expect(out).toMatch(/could not acquire lock/);
|
||||
expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before);
|
||||
expect(fs.existsSync(lockDir)).toBe(true); // foreign lock NOT stolen
|
||||
});
|
||||
|
||||
test('two concurrent add-events both land (lock serializes; file stays valid JSON)', () => {
|
||||
const q = (args: string[]) =>
|
||||
[SETTINGS_HOOK, ...args].map((s) => `'${s}'`).join(' ');
|
||||
const a = q(['add-event', '--event', 'PreToolUse', '--matcher', AUQ_MATCHER, '--command', '/pre-hook', '--source', 'src-a']);
|
||||
const b = q(['add-event', '--event', 'PostToolUse', '--matcher', AUQ_MATCHER, '--command', '/post-hook', '--source', 'src-b']);
|
||||
execSync(`sh -c "${a} & ${b} & wait"`, {
|
||||
env: { ...process.env, GSTACK_SETTINGS_FILE: settingsFile, GSTACK_STATE_ROOT: tmpDir },
|
||||
encoding: 'utf-8',
|
||||
timeout: 20000,
|
||||
});
|
||||
const s = settings(); // throws if the file is corrupt
|
||||
expect(s.hooks.PreToolUse).toHaveLength(1);
|
||||
expect(s.hooks.PostToolUse).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user