diff --git a/test/egress-receipt.test.ts b/test/egress-receipt.test.ts index 44329edfc..0f60e99ff 100644 --- a/test/egress-receipt.test.ts +++ b/test/egress-receipt.test.ts @@ -167,6 +167,20 @@ describe('egress receipt library', () => { expect(verifyLedger(home).ok).toBe(true); }); + test('lockBudgetMs 0 on a held lock tries once and fails closed in well under 100 ms; writeOutcome rejects garbage too', () => { + const ledger = egressLedgerPath(home); + const { id } = writeReceipt({ home, sink: 's', host: 'h', payloadClass: 'p', consent: 'c' }); + fs.mkdirSync(`${ledger}.lock`); + const t0 = Date.now(); + expect(() => writeReceipt({ home, sink: 's', host: 'h', payloadClass: 'p', consent: 'c', lockBudgetMs: 0 })).toThrow(/locked/); + expect(Date.now() - t0).toBeLessThan(100); + fs.rmdirSync(`${ledger}.lock`); + const lines = fs.readFileSync(ledger, 'utf8').trim().split('\n').length; + expect(() => writeOutcome({ home, receipt: id, status: 'x', lockBudgetMs: -5 })).toThrow(/lockBudgetMs/); + expect(() => writeOutcome({ home, receipt: id, status: 'x', lockBudgetMs: Number.POSITIVE_INFINITY })).toThrow(/lockBudgetMs/); + expect(fs.readFileSync(ledger, 'utf8').trim().split('\n').length).toBe(lines); // nothing appended + }); + test('lockBudgetMs rejects garbage before touching the ledger', () => { expect(() => writeReceipt({ home, sink: 's', host: 'h', payloadClass: 'p', consent: 'c', lockBudgetMs: -1 })).toThrow(/lockBudgetMs/); expect(() => writeReceipt({ home, sink: 's', host: 'h', payloadClass: 'p', consent: 'c', lockBudgetMs: Number.NaN })).toThrow(/lockBudgetMs/); diff --git a/test/gstack-memorable.test.ts b/test/gstack-memorable.test.ts index a8f4745c2..69eb06845 100644 --- a/test/gstack-memorable.test.ts +++ b/test/gstack-memorable.test.ts @@ -10,6 +10,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'child_process'; +import { canRevokeWrites } from './helpers/fs-caps'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -167,11 +168,7 @@ describe('enable', () => { const ro = { GSTACK_HOME: roState, GSTACK_STATE_ROOT: roState, GSTACK_STATE_DIR: roState }; const r = run(['enable'], ro); fs.chmodSync(roState, 0o755); - if (r.status === 0) { - // running as a user that ignores file modes (root in CI): the write succeeded, nothing to assert on rollback - expect(r.stdout).toContain('enabled'); - return; - } + if (!canRevokeWrites()) { expect(r.status).toBe(0); return; } // modes not enforced here: the write succeeds expect(r.status).toBe(1); expect(r.stderr).toContain('could not record consent'); expect(fs.existsSync(settings) ? (readSettings().hooks ?? {}).UserPromptSubmit : undefined).toBeUndefined(); // fresh registration rolled back @@ -300,6 +297,135 @@ describe('status (read-only)', () => { }); }); +describe('enable/disable failure paths (coverage audit)', () => { + + function mixedCanonical(version: string, settingsHookBody?: string) { + fs.rmSync(canonical); + fs.mkdirSync(path.join(canonical, 'hosts', 'claude', 'hooks'), { recursive: true }); + fs.mkdirSync(path.join(canonical, 'bin'), { recursive: true }); + fs.writeFileSync(path.join(canonical, 'bin', 'gstack-session-update'), '#!/bin/sh\n', { mode: 0o755 }); + fs.writeFileSync(path.join(canonical, HOOK_REL), '#!/bin/sh\n', { mode: 0o755 }); + fs.writeFileSync(path.join(canonical, `${HOOK_REL}.ts`), '// twin\n'); + if (settingsHookBody) fs.writeFileSync(path.join(canonical, 'bin', 'gstack-settings-hook'), settingsHookBody, { mode: 0o755 }); + else { fs.copyFileSync(path.join(ROOT, 'bin', 'gstack-settings-hook'), path.join(canonical, 'bin', 'gstack-settings-hook')); fs.chmodSync(path.join(canonical, 'bin', 'gstack-settings-hook'), 0o755); } + fs.writeFileSync(path.join(canonical, 'VERSION'), version); + } + + test('enable refuses on a VERSION mismatch alone (hook and twin present)', () => { + mixedCanonical('0.0.0.0\n'); + const r = run(['enable']); + expect(r.status).toBe(1); + expect(r.stderr).toContain("is version '0.0.0.0'"); + expect(fs.existsSync(settings)).toBe(false); + }); + + test('enable refuses when the stable hook manager does not know list-items', () => { + mixedCanonical(fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8'), '#!/bin/sh\necho "Unknown action: $1" >&2\nexit 1\n'); + const r = run(['enable']); + expect(r.status).toBe(1); + expect(r.stderr).toContain('does not know list-items'); + expect(fs.existsSync(settings)).toBe(false); + }); + + test('enable refuses when BOTH gstack and the vendor are registered; disable then removes only gstack\'s entry', () => { + writeSettings({ hooks: { UserPromptSubmit: [ + { hooks: [{ type: 'command', command: `${canonical}/${HOOK_REL}`, timeout: 5 }] }, + { hooks: [{ type: 'command', command: vendorOwn() }] }, + ] } }); + const before = fs.readFileSync(settings, 'utf8'); + const r = run(['enable']); + expect(r.status).toBe(1); + expect(r.stderr).toContain('already registers this hook itself'); + expect(fs.readFileSync(settings, 'utf8')).toBe(before); + const d = run(['disable']); + expect(d.status).toBe(0); + expect(commands()).toEqual([vendorOwn()]); + }); + + test('enable passes the hook manager\'s lock give-up (exit 5) through and leaves the gate untouched', () => { + fs.mkdirSync(`${settings}.lock`, { recursive: true }); + fs.writeFileSync(path.join(`${settings}.lock`, 'owner'), 'another-live-process'); + // the hook manager's give-up defaults to 10 s; its test-only override keeps this fast + const r = run(['enable'], { GSTACK_SETTINGS_LOCK_TIMEOUT_MS: '500' }); + expect(r.status).toBe(5); + expect(r.stderr).toContain('settings hook update failed'); + expect(gate()).toBe('off'); + expect(fs.existsSync(settings)).toBe(false); + }, 30_000); + + test('disable surfaces a hook-manager lock give-up as exit 5 after flipping the gate off', () => { + setGate('on'); + writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: `${canonical}/${HOOK_REL}` }] }] } }); + fs.mkdirSync(`${settings}.lock`, { recursive: true }); + fs.writeFileSync(path.join(`${settings}.lock`, 'owner'), 'another-live-process'); + const r = run(['disable'], { GSTACK_SETTINGS_LOCK_TIMEOUT_MS: '500' }); + expect(r.status).toBe(5); + expect(gate()).toBe('off'); + expect(r.stdout).toContain('consent: memorable_recall=off'); + expect(r.stderr).toContain('survived'); + }, 30_000); + + test('a FRESH bridge lock held by another process makes enable exit 5 after the wait, lock left in place', () => { + const lock = path.join(env.GSTACK_HOME, 'locks', 'memorable-bridge.lock'); + fs.mkdirSync(lock, { recursive: true }); + fs.writeFileSync(path.join(lock, 'ts'), String(Math.floor(Date.now() / 1000))); + fs.writeFileSync(path.join(lock, 'owner'), '999999'); + const t0 = Date.now(); + const r = run(['enable']); + expect(r.status).toBe(5); + expect(r.stderr).toContain('another gstack-memorable is running'); + expect(Date.now() - t0).toBeGreaterThan(4000); + expect(fs.existsSync(lock)).toBe(true); + expect(fs.existsSync(settings)).toBe(false); + }, 30_000); + + test('consent-write failure with a PRE-EXISTING registration keeps the registration and restores the prior gate', () => { + if (!canRevokeWrites()) return; // chmod is advisory here (win32, root, DAC-override containers) + // state dir: gate already 'on' from an earlier enable, then made read-only + setGate('on'); + writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: `${canonical}/${HOOK_REL}`, timeout: 5 }] }] } }); + fs.chmodSync(path.join(env.GSTACK_HOME, 'config.yaml'), 0o444); + fs.chmodSync(env.GSTACK_HOME, 0o555); + const r = run(['enable']); + fs.chmodSync(env.GSTACK_HOME, 0o755); + expect(r.status).toBe(1); + expect(r.stderr).toContain('could not record consent'); + expect(commands()).toEqual([`${canonical}/${HOOK_REL}`]); // pre-existing registration kept + expect(gate()).toBe('on'); // prior value, not an assumed off + }); + + test('disable reports a failed consent write, still removes the hook, exits 1', () => { + if (!canRevokeWrites()) return; // chmod is advisory here (win32, root, DAC-override containers) + setGate('on'); + writeSettings({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: `${canonical}/${HOOK_REL}` }] }] } }); + fs.chmodSync(path.join(env.GSTACK_HOME, 'config.yaml'), 0o444); + fs.chmodSync(env.GSTACK_HOME, 0o555); + const r = run(['disable']); + fs.chmodSync(env.GSTACK_HOME, 0o755); + expect(r.status).toBe(1); + expect(r.stderr).toContain('consent: could not set'); + expect(r.stdout).toContain('hook: removed'); + }); + + test('usage: no verb exits 1 with usage on stderr; -h exits 0 with usage on stdout', () => { + const none = run([]); + expect(none.status).toBe(1); + expect(none.stderr).toContain('Usage: gstack-memorable'); + const help = run(['-h']); + expect(help.status).toBe(0); + expect(help.stdout).toContain('Usage: gstack-memorable'); + }); + + test('status names the Windows deferral and counts real receipts for the sink', () => { + expect(run(['status'], { GSTACK_MEMORABLE_TEST_UNAME: 'MINGW64_NT-10.0' }).stdout).toContain('platform: Windows is not supported'); + for (let i = 0; i < 2; i++) { + const w = spawnSync('bun', [path.join(ROOT, 'bin', 'gstack-egress-receipt'), 'write', '--sink', 'memorable-recall', '--host', 'local:/x/memorable', '--class', 'c', '--no-payload', '--consent', 'memorable_recall=on'], { env, encoding: 'utf8', timeout: 20_000 }); + expect(w.status).toBe(0); + } + expect(run(['status']).stdout).toContain('receipts: 2 for sink memorable-recall'); + }); +}); + describe('lifecycle lock and static pins', () => { test('two concurrent enables serialise: one entry, gate on, both exit 0', async () => { const kids = [0, 1].map(() => Bun.spawn(['bash', BIN, 'enable'], { env, stdout: 'pipe', stderr: 'pipe' })); diff --git a/test/gstack-settings-hook-schema-aware.test.ts b/test/gstack-settings-hook-schema-aware.test.ts index 87c1170a3..97b4c0242 100644 --- a/test/gstack-settings-hook-schema-aware.test.ts +++ b/test/gstack-settings-hook-schema-aware.test.ts @@ -963,6 +963,68 @@ describe('remove-source: identity-aware (tag OR table)', () => { }); }); +describe('remove-source: identity removal holds for EVERY KNOWN_HOOKS source (regression)', () => { + // The semantics change applies to all six rows, but setup's --no-team path + // and uninstall lean on four sources this file never exercised behaviourally. + const seedAll = () => fs.writeFileSync(settingsFile, JSON.stringify({ + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] }, + { hooks: [{ type: 'command', command: '/x/bin/gstack-verify-gate' }] }, + { hooks: [{ type: 'command', command: '/Users/me/my-stop-hook' }] }, + ], + PostToolUse: [ + { matcher: AUQ_MATCHER, hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/auq-error-fallback-hook' }] }, + { matcher: AUQ_MATCHER, hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/question-log-hook' }] }, + ], + SessionStart: [ + { hooks: [{ type: 'command', command: '/x/bin/gstack-session-update' }] }, + { hooks: [{ type: 'command', command: '/Users/me/my-session-hook' }] }, + ], + }, + }, null, 2)); + const allCommands = () => { + const h = settings().hooks ?? {}; + return Object.values(h).flatMap((entries: any) => entries.flatMap((e: any) => e.hooks.map((i: any) => i.command))).sort(); + }; + + for (const [source, own] of [ + ['gstack-timeline-stop', '/x/hosts/claude/hooks/timeline-stop-hook'], + ['verify-gate', '/x/bin/gstack-verify-gate'], + ['auq-error-fallback', '/x/hosts/claude/hooks/auq-error-fallback-hook'], + ['gstack-session-update', '/x/bin/gstack-session-update'], + ['plan-tune-cathedral', '/x/hosts/claude/hooks/question-log-hook'], + ] as const) { + test(`remove-source --source ${source} removes exactly its own UNTAGGED item and nothing else`, () => { + seedAll(); + const before = allCommands(); + const r = run(['remove-source', '--source', source]); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/removed 1 /); + expect(allCommands()).toEqual(before.filter((c) => c !== own)); + }); + } + + test('a non-array hooks. value is never touched (foreign shape), exit 0', () => { + fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { UserPromptSubmit: { weird: true }, Stop: [{ hooks: [{ type: 'command', command: '/x/hosts/claude/hooks/timeline-stop-hook' }] }] } }, null, 2)); + const r = run(['remove-source', '--source', 'gstack-memorable']); + expect(r.exitCode).toBe(0); + expect(r.stdout).toMatch(/removed 0 /); + expect(settings().hooks.UserPromptSubmit).toEqual({ weird: true }); + }); + + test('a tagged entry holding only a command-less item, and a tagged multi-item entry with no table rows, are kept with their tags', () => { + fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { UserPromptSubmit: [ + { _gstack_source: 'gstack-memorable', hooks: [{ type: 'command' }] }, + { _gstack_source: 'gstack-memorable', hooks: [{ type: 'command', command: '/a/foreign' }, { type: 'command', command: '/b/foreign' }] }, + ] } }, null, 2)); + const before = fs.readFileSync(settingsFile, 'utf-8'); + const r = run(['remove-source', '--source', 'gstack-memorable']); + expect(r.stdout).toMatch(/removed 0 /); + expect(fs.readFileSync(settingsFile, 'utf-8')).toBe(before); + }); +}); + describe('list-items: read-only identity view', () => { const memo = '/stable/gstack/hosts/claude/hooks/memorable-user-prompt-hook'; const foreign = '/Users/me/my-user-prompt-hook'; @@ -1005,6 +1067,17 @@ describe('list-items: read-only identity view', () => { expect(run(['list-items', '--event', 'UserPromptSubmit'])).toMatchObject({ exitCode: 0, stdout: '' }); }); + test('an unknown flag exits 1; --owned-by combined with --command-regex intersects (a regex never widens a selection)', () => { + seed(); + expect(run(['list-items', '--event', 'UserPromptSubmit', '--bogus', 'x']).exitCode).toBe(1); + const both = run(['list-items', '--event', 'UserPromptSubmit', '--owned-by', 'gstack-memorable', '--command-regex', 'memorable-user-prompt-hook$']); + expect(both.stdout.trim().split('\n')).toEqual([JSON.stringify(memo)]); + const none = run(['list-items', '--event', 'UserPromptSubmit', '--owned-by', 'gstack-memorable', '--command-regex', 'no-such-thing']); + expect(none).toMatchObject({ exitCode: 0, stdout: '' }); + const vendorOnly = run(['list-items', '--event', 'UserPromptSubmit', '--command-regex', 'memorable']); + expect(vendorOnly.stdout.trim().split('\n')).toEqual([JSON.stringify(vendor)]); // regex alone still excludes owned items + }); + test('exit codes mirror the mutating verbs: 1 usage, 3 unparseable, 4 unexpected shape', () => { seed(); expect(run(['list-items']).exitCode).toBe(1); diff --git a/test/memorable-user-prompt-hook.test.ts b/test/memorable-user-prompt-hook.test.ts index 2a0859717..976baddde 100644 --- a/test/memorable-user-prompt-hook.test.ts +++ b/test/memorable-user-prompt-hook.test.ts @@ -25,7 +25,7 @@ import * as os from 'os'; import * as path from 'path'; import { listReceipts, sha256Hex, verifyLedger } from '../lib/egress-receipt'; import { - budgetFor, capUtf8, pickAdditionalContext, renderContext, stringLeaves, vendorEnv, + budgetFor, capUtf8, pickAdditionalContext, renderContext, resolveVendor, stringLeaves, stripControl, vendorEnv, OUTPUT_CAP_BYTES, ENVELOPE_SOURCE, } from '../hosts/claude/hooks/memorable-user-prompt-hook.ts'; import { runExternal } from '../hosts/claude/hooks/spawn-bin'; @@ -342,6 +342,57 @@ describe('input bounds and fail-closed receipt', () => { }); }); +describe('input shapes and environment (coverage audit)', () => { + test('a non-object JSON payload ("just a string", 42) exits 0 with nothing spawned and nothing logged', () => { + gateOn(); + for (const input of ['"just a string"', '42', 'null']) { + expect(runHook(input)).toEqual({ status: 0, stdout: '', stderr: '' }); + } + expect(calls()).toBe(''); + expect(errLog()).toBe(''); + }); + + test('a cwd that no longer exists falls back to the process cwd and the vendor still runs', () => { + gateOn(); + const gone = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memo-gone-')); + fs.rmSync(gone, { recursive: true, force: true }); + const r = runHook(JSON.stringify({ prompt: 'x', cwd: gone })); + expect(r.status).toBe(0); + expect(r.stdout).toContain('remembered'); + }); + + test('a non-ASCII prompt is receipted by BYTE length, not string length', () => { + gateOn(); + const prompt = JSON.stringify({ prompt: 'déployer la migration — 日本語' }); + expect(Buffer.byteLength(prompt)).not.toBe(prompt.length); + runHook(prompt); + const rs = receipts(); + expect(rs).toHaveLength(1); + expect(rs[0].bytes).toBe(Buffer.byteLength(prompt)); + expect(rs[0].sha256).toBe(sha256Hex(Buffer.from(prompt))); + expect(Buffer.from(fs.readFileSync(path.join(home, 'stdin.bin')))).toEqual(Buffer.from(prompt)); + }); + + test('stdin never closed: the hook gives up reading within its stdin cap, spawns nothing, exits 0', async () => { + gateOn(); + const t0 = Date.now(); + const child = Bun.spawn(['bash', HOOK], { stdin: 'pipe', env, stdout: 'pipe', stderr: 'pipe' }); + child.stdin.write('{"prompt":"partial'); // never closed + const code = await child.exited; + expect(code).toBe(0); + expect(Date.now() - t0).toBeLessThan(4000); + expect(calls()).toBe(''); + }, 15_000); + + test('the bash shim without bun on PATH exits 0 with empty stdout', () => { + gateOn(); + const r = spawnSync('bash', [HOOK], { input: PROMPT, env: { ...env, PATH: '/usr/bin:/bin' }, timeout: 20_000 }); + expect(r.status).toBe(0); + expect((r.stdout ?? Buffer.alloc(0)).toString()).toBe(''); + expect(calls()).toBe(''); + }); +}); + describe('pure helpers', () => { test('budgetFor never goes negative and honours the cap', () => { expect(budgetFor(1000, 1000)).toBe(4500); @@ -366,6 +417,27 @@ describe('pure helpers', () => { expect(pickAdditionalContext(JSON.stringify({ decision: 'block' }))).toBeNull(); expect(pickAdditionalContext('nope')).toBeNull(); }); + test('stripControl drops C0 controls and DEL but keeps tab and newline', () => { + const input = 'a' + String.fromCharCode(0) + 'b' + String.fromCharCode(27) + '\tc\nd' + String.fromCharCode(127) + 'e'; + expect(stripControl(input)).toBe('ab\tc\nde'); + }); + test('resolveVendor: explicit override wins, may be quoted, and an unresolvable or non-executable override is null (no fall-through)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-memo-resolve-')); + try { + const exe = path.join(dir, 'vendor'); fs.writeFileSync(exe, '#!/bin/sh\n', { mode: 0o755 }); + const plain = path.join(dir, 'plain'); fs.writeFileSync(plain, '#!/bin/sh\n', { mode: 0o644 }); + const homeDir = path.join(dir, 'home'); fs.mkdirSync(path.join(homeDir, '.memorable', 'bin'), { recursive: true }); + const pinned = path.join(homeDir, '.memorable', 'bin', 'memorable'); fs.writeFileSync(pinned, '#!/bin/sh\n', { mode: 0o755 }); + expect(resolveVendor({ GSTACK_MEMORABLE_BIN: exe, MEMORABLE_BIN: pinned }, homeDir)).toBe(exe); + expect(resolveVendor({ MEMORABLE_BIN: `"${exe}"` }, homeDir)).toBe(exe); + expect(resolveVendor({ GSTACK_MEMORABLE_BIN: path.join(dir, 'missing') }, homeDir)).toBeNull(); + expect(resolveVendor({ GSTACK_MEMORABLE_BIN: plain }, homeDir)).toBeNull(); + expect(resolveVendor({}, homeDir)).toBe(pinned); + expect(resolveVendor({ PATH: '/nonexistent' }, path.join(dir, 'nohome'))).toBeNull(); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); test('stringLeaves is bounded', () => { let deep: unknown = 'leaf'; for (let i = 0; i < 100; i++) deep = { d: deep }; @@ -380,6 +452,17 @@ describe('runExternal (spawn-bin)', () => { expect(r.error).toBe('EPLATFORM'); expect(r.stdout.length).toBe(0); }); + test('a missing executable resolves with error ENOENT, status null, no timeout', async () => { + const r = await runExternal('/nonexistent/binary', [], { timeoutMs: 2000 }); + expect(r.error).toBe('ENOENT'); + expect(r.status).toBeNull(); + expect(r.timedOut).toBe(false); + }); + test('input undefined closes the child stdin immediately (cat sees EOF)', async () => { + const r = await runExternal('cat', [], { timeoutMs: 2000 }); + expect(r.status).toBe(0); + expect(r.stdout.length).toBe(0); + }); test('a fork-style child is contained by the group kill on timeout', async () => { const r = await runExternal('sh', ['-c', "sh -c 'sleep 31'"], { timeoutMs: 300 }); expect(r.timedOut).toBe(true); diff --git a/test/uninstall.test.ts b/test/uninstall.test.ts index d41bcee03..7542b741c 100644 --- a/test/uninstall.test.ts +++ b/test/uninstall.test.ts @@ -352,6 +352,36 @@ describe('the Memorable bridge hook is removed by name and the kept config is le }, 30000); }); +describe('the Memorable arm stays quiet when nothing of its is registered', () => { + test('no memorable entry -> no "Memorable UserPromptSubmit hook" in the summary, exit 0', () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-memo-none-')); + try { + const mockHome = path.join(tmp, 'home'); + const installRoot = path.join(mockHome, '.claude', 'skills', 'gstack'); + const installBin = path.join(installRoot, 'bin'); + fs.mkdirSync(installBin, { recursive: true }); + for (const b of ['gstack-uninstall', 'gstack-settings-hook', 'gstack-session-update', 'gstack-config']) { + const dst = path.join(installBin, b); + fs.copyFileSync(path.join(ROOT, 'bin', b), dst); + fs.chmodSync(dst, 0o755); + } + const settingsFile = path.join(mockHome, '.claude', 'settings.json'); + fs.writeFileSync(settingsFile, JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: '/Users/me/my-own-hook' }] }] } }, null, 2)); + fs.mkdirSync(path.join(mockHome, '.gstack'), { recursive: true }); + const result = spawnSync('bash', [path.join(installBin, 'gstack-uninstall'), '--force', '--keep-state'], { + stdio: 'pipe', timeout: 30_000, encoding: 'utf-8', cwd: tmp, + env: { ...process.env, HOME: mockHome, GSTACK_SETTINGS_FILE: settingsFile, GSTACK_STATE_ROOT: path.join(mockHome, '.gstack') }, + }); + expect(result.status).toBe(0); + expect(result.stdout).not.toContain('Memorable UserPromptSubmit hook'); + const s = JSON.parse(fs.readFileSync(settingsFile, 'utf-8')); + expect(s.hooks.UserPromptSubmit[0].hooks[0].command).toBe('/Users/me/my-own-hook'); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }, 30000); +}); + describe('hook cleanup under lock contention is loud, never silent (review-army)', () => { test('a held foreign lock during uninstall surfaces the give-up warning on stderr', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-uninstall-lock-'));