mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 22:48:57 +02:00
test: coverage for the memorable bridge (remove-source regression for every KNOWN_HOOKS source)
- settings-hook: identity removal pinned for each source in KNOWN_HOOKS; list-items unknown flag and combined --owned-by/--command-regex - gstack-memorable: enable/disable failure paths (lock give-up exit 5 with the test-only lock timeout override, consent-write failures guarded by canRevokeWrites, canonical-version mismatch, no-bun status) - hook: non-object JSON, missing cwd, non-ASCII bytes, held-open stdin, shim without bun, stripControl, resolveVendor, runExternal ENOENT - egress-receipt: lockBudgetMs 0 and writeOutcome on garbage input - uninstall: no memorable entry present reports nothing removed Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
45035b7f94
commit
77b7b31892
@@ -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/);
|
||||
|
||||
@@ -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' }));
|
||||
|
||||
@@ -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.<event> 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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-'));
|
||||
|
||||
Reference in New Issue
Block a user