mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
* fix(install): preserve non-array hook entries during uninstall Uninstall filtering returned null for hook entries without a hooks array, silently deleting user-owned entries with unexpected shapes. Return the entry unchanged instead so only GSD hooks are removed. * test(install): add regression test for non-array hook entry preservation (#1825) Fix mirrored filterGsdHooks helper to match production code and add test proving non-array hook entries survive uninstall filtering.
456 lines
18 KiB
JavaScript
456 lines
18 KiB
JavaScript
/**
|
|
* Regression tests for install process hook copying, permissions, manifest
|
|
* tracking, uninstall cleanup, and settings.json registration.
|
|
*
|
|
* Covers: #1755, Codex hook path/filename, cache invalidation path,
|
|
* manifest .sh tracking, uninstall settings cleanup, dead code removal.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { test, describe, beforeEach, afterEach, before } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { execFileSync } = require('child_process');
|
|
const { cleanup, createTempDir } = require('./helpers.cjs');
|
|
|
|
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
|
|
const { writeManifest, validateHookFields } = require(INSTALL_SRC);
|
|
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
|
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
|
|
|
// Expected .sh community hooks
|
|
const EXPECTED_SH_HOOKS = [
|
|
'gsd-session-state.sh',
|
|
'gsd-validate-commit.sh',
|
|
'gsd-phase-boundary.sh',
|
|
];
|
|
|
|
// All hooks that should be in hooks/dist/ after build
|
|
const EXPECTED_ALL_HOOKS = [
|
|
'gsd-check-update.js',
|
|
'gsd-context-monitor.js',
|
|
'gsd-prompt-guard.js',
|
|
'gsd-read-guard.js',
|
|
'gsd-statusline.js',
|
|
'gsd-workflow-guard.js',
|
|
...EXPECTED_SH_HOOKS,
|
|
];
|
|
|
|
const isWindows = process.platform === 'win32';
|
|
|
|
// ─── Ensure hooks/dist/ is populated ────────────────────────────────────────
|
|
|
|
before(() => {
|
|
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
|
encoding: 'utf-8',
|
|
stdio: 'pipe',
|
|
});
|
|
});
|
|
|
|
// ─── Helper: simulate the hook copy loop from install.js ────────────────────
|
|
// NOTE: This helper mirrors the chmod/copy logic only. It omits the .js
|
|
// template substitution ('.claude' → runtime dir, {{GSD_VERSION}} stamping)
|
|
// since these tests focus on file presence and permissions, not content.
|
|
|
|
function simulateHookCopy(hooksSrc, hooksDest) {
|
|
fs.mkdirSync(hooksDest, { recursive: true });
|
|
const hookEntries = fs.readdirSync(hooksSrc);
|
|
for (const entry of hookEntries) {
|
|
const srcFile = path.join(hooksSrc, entry);
|
|
if (fs.statSync(srcFile).isFile()) {
|
|
const destFile = path.join(hooksDest, entry);
|
|
if (entry.endsWith('.js')) {
|
|
const content = fs.readFileSync(srcFile, 'utf8');
|
|
fs.writeFileSync(destFile, content);
|
|
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
|
|
} else {
|
|
fs.copyFileSync(srcFile, destFile);
|
|
if (entry.endsWith('.sh')) {
|
|
try { fs.chmodSync(destFile, 0o755); } catch (e) { /* Windows */ }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 1. Hook file copy and permissions (#1755)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('#1755: .sh hooks are copied and executable after install', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir('gsd-hook-copy-');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('all expected hooks are copied from hooks/dist/ to target', () => {
|
|
const hooksDest = path.join(tmpDir, 'hooks');
|
|
simulateHookCopy(HOOKS_DIST, hooksDest);
|
|
|
|
for (const hook of EXPECTED_ALL_HOOKS) {
|
|
assert.ok(
|
|
fs.existsSync(path.join(hooksDest, hook)),
|
|
`${hook} should exist in target hooks dir`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('.sh hooks are executable after copy', {
|
|
skip: isWindows ? 'Windows has no POSIX file permissions' : false,
|
|
}, () => {
|
|
const hooksDest = path.join(tmpDir, 'hooks');
|
|
simulateHookCopy(HOOKS_DIST, hooksDest);
|
|
|
|
for (const sh of EXPECTED_SH_HOOKS) {
|
|
const stat = fs.statSync(path.join(hooksDest, sh));
|
|
assert.ok(
|
|
(stat.mode & 0o111) !== 0,
|
|
`${sh} should be executable after install copy`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('.js hooks are executable after copy', {
|
|
skip: isWindows ? 'Windows has no POSIX file permissions' : false,
|
|
}, () => {
|
|
const hooksDest = path.join(tmpDir, 'hooks');
|
|
simulateHookCopy(HOOKS_DIST, hooksDest);
|
|
|
|
const jsHooks = EXPECTED_ALL_HOOKS.filter(h => h.endsWith('.js'));
|
|
for (const js of jsHooks) {
|
|
const stat = fs.statSync(path.join(hooksDest, js));
|
|
assert.ok(
|
|
(stat.mode & 0o111) !== 0,
|
|
`${js} should be executable after install copy`
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 2. install.js source-level correctness checks
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('install.js source correctness', () => {
|
|
let src;
|
|
|
|
before(() => {
|
|
src = fs.readFileSync(INSTALL_SRC, 'utf-8');
|
|
});
|
|
|
|
test('.sh files get chmod after copyFileSync', () => {
|
|
// The else branch for non-.js hooks should apply chmod for .sh files
|
|
assert.ok(
|
|
src.includes("if (entry.endsWith('.sh'))"),
|
|
'install.js should check for .sh extension to apply chmod'
|
|
);
|
|
});
|
|
|
|
test('Codex hook uses correct filename gsd-check-update.js (not gsd-update-check.js)', () => {
|
|
// The cache file gsd-update-check.json is legitimate (different artifact);
|
|
// check that no hook registration uses the inverted .js filename.
|
|
// Match the exact pattern: quote + gsd-update-check.js + quote
|
|
assert.ok(
|
|
!src.match(/['"]gsd-update-check\.js['"]/),
|
|
'install.js must not reference the inverted hook name gsd-update-check.js in quotes'
|
|
);
|
|
});
|
|
|
|
test('Codex hook path does not use get-shit-done/hooks/ subdirectory', () => {
|
|
// The Codex hook should resolve to targetDir/hooks/, not targetDir/get-shit-done/hooks/
|
|
assert.ok(
|
|
!src.includes("'get-shit-done', 'hooks', 'gsd-check-update"),
|
|
'Codex hook should not use get-shit-done/hooks/ path segment'
|
|
);
|
|
});
|
|
|
|
test('cache invalidation uses ~/.cache/gsd/ path', () => {
|
|
assert.ok(
|
|
src.includes("os.homedir(), '.cache', 'gsd'"),
|
|
'Cache path should use os.homedir()/.cache/gsd/'
|
|
);
|
|
});
|
|
|
|
test('manifest tracks .sh hook files', () => {
|
|
assert.ok(
|
|
src.includes("file.endsWith('.sh')"),
|
|
'writeManifest should track .sh files in addition to .js'
|
|
);
|
|
});
|
|
|
|
test('gsd-workflow-guard.js is in uninstall hook list', () => {
|
|
const gsdHooksMatch = src.match(/const gsdHooks\s*=\s*\[([^\]]+)\]/);
|
|
assert.ok(gsdHooksMatch, 'gsdHooks array should exist');
|
|
const gsdHooksContent = gsdHooksMatch[1];
|
|
assert.ok(
|
|
gsdHooksContent.includes('gsd-workflow-guard.js'),
|
|
'gsdHooks should include gsd-workflow-guard.js'
|
|
);
|
|
});
|
|
|
|
test('phantom gsd-check-update.sh is not in uninstall hook list', () => {
|
|
const gsdHooksMatch = src.match(/const gsdHooks\s*=\s*\[([^\]]+)\]/);
|
|
assert.ok(gsdHooksMatch, 'gsdHooks array should exist');
|
|
const gsdHooksContent = gsdHooksMatch[1];
|
|
assert.ok(
|
|
!gsdHooksContent.includes('gsd-check-update.sh'),
|
|
'gsdHooks should not include phantom gsd-check-update.sh'
|
|
);
|
|
});
|
|
|
|
test('isGsdHookCommand covers all GSD hook names', () => {
|
|
// The consolidated uninstall cleanup uses isGsdHookCommand — verify all hook names are present
|
|
const expectedHookNames = [
|
|
'gsd-check-update', 'gsd-statusline', 'gsd-session-state',
|
|
'gsd-context-monitor', 'gsd-phase-boundary', 'gsd-prompt-guard',
|
|
'gsd-read-guard', 'gsd-validate-commit', 'gsd-workflow-guard',
|
|
];
|
|
for (const name of expectedHookNames) {
|
|
assert.ok(
|
|
src.includes(`'${name}'`) || src.includes(`"${name}"`),
|
|
`isGsdHookCommand should match ${name}`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('Codex install migrates legacy gsd-update-check entries', () => {
|
|
assert.ok(
|
|
src.includes('gsd-update-check'),
|
|
'install.js should detect legacy gsd-update-check entries for migration'
|
|
);
|
|
});
|
|
|
|
test('no duplicate isCursor or isWindsurf branches in uninstall skill removal', () => {
|
|
// The uninstall skill removal if/else chain should not have standalone
|
|
// isCursor or isWindsurf branches — they're already handled by the combined
|
|
// (isCodex || isCursor || isWindsurf || isTrae) branch
|
|
const uninstallStart = src.indexOf('function uninstall(');
|
|
const uninstallEnd = src.indexOf('function verifyInstalled(');
|
|
assert.ok(uninstallStart !== -1, 'function uninstall( must exist in install.js');
|
|
assert.ok(uninstallEnd !== -1, 'function verifyInstalled( must exist in install.js');
|
|
const uninstallBlock = src.substring(uninstallStart, uninstallEnd);
|
|
|
|
// Count occurrences of 'else if (isCursor)' in uninstall — should be 0
|
|
const cursorBranches = (uninstallBlock.match(/else if \(isCursor\)/g) || []).length;
|
|
assert.strictEqual(cursorBranches, 0, 'No standalone isCursor branch should exist in uninstall');
|
|
|
|
// Count occurrences of 'else if (isWindsurf)' in uninstall — should be 0
|
|
const windsurfBranches = (uninstallBlock.match(/else if \(isWindsurf\)/g) || []).length;
|
|
assert.strictEqual(windsurfBranches, 0, 'No standalone isWindsurf branch should exist in uninstall');
|
|
});
|
|
|
|
test('verifyInstalled warns about missing .sh hooks', () => {
|
|
assert.ok(
|
|
src.includes('Missing expected hook:'),
|
|
'install should warn about missing .sh hooks after verification'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 3. Manifest tracks .sh hooks
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('writeManifest includes .sh hooks', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir('gsd-manifest-');
|
|
// Set up minimal structure expected by writeManifest
|
|
const hooksDir = path.join(tmpDir, 'hooks');
|
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
// Copy hooks from dist to simulate install
|
|
simulateHookCopy(HOOKS_DIST, hooksDir);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('manifest contains .sh hook entries', () => {
|
|
writeManifest(tmpDir, 'claude');
|
|
|
|
const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json');
|
|
assert.ok(fs.existsSync(manifestPath), 'manifest file should exist');
|
|
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
|
|
for (const sh of EXPECTED_SH_HOOKS) {
|
|
assert.ok(
|
|
manifest.files['hooks/' + sh],
|
|
`manifest should contain hash for ${sh}`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('manifest contains .js hook entries', () => {
|
|
writeManifest(tmpDir, 'claude');
|
|
|
|
const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json');
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
|
|
const jsHooks = EXPECTED_ALL_HOOKS.filter(h => h.endsWith('.js'));
|
|
for (const js of jsHooks) {
|
|
assert.ok(
|
|
manifest.files['hooks/' + js],
|
|
`manifest should contain hash for ${js}`
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 4. Uninstall per-hook granularity (#1755 followup)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('uninstall settings cleanup preserves user hooks', () => {
|
|
// Mirror the isGsdHookCommand logic from install.js
|
|
const isGsdHookCommand = (cmd) =>
|
|
cmd && (cmd.includes('gsd-check-update') || cmd.includes('gsd-statusline') ||
|
|
cmd.includes('gsd-session-state') || cmd.includes('gsd-context-monitor') ||
|
|
cmd.includes('gsd-phase-boundary') || cmd.includes('gsd-prompt-guard') ||
|
|
cmd.includes('gsd-read-guard') || cmd.includes('gsd-validate-commit') ||
|
|
cmd.includes('gsd-workflow-guard'));
|
|
|
|
// Simulate the per-hook filtering logic from uninstall
|
|
function filterGsdHooks(entries) {
|
|
return entries
|
|
.map(entry => {
|
|
if (!entry.hooks || !Array.isArray(entry.hooks)) return entry;
|
|
entry.hooks = entry.hooks.filter(h => !isGsdHookCommand(h.command));
|
|
return entry.hooks.length > 0 ? entry : null;
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
|
|
test('mixed entry with GSD + user hooks preserves user hooks', () => {
|
|
const entries = [{
|
|
matcher: 'Bash',
|
|
hooks: [
|
|
{ type: 'command', command: 'node /path/to/gsd-prompt-guard.js' },
|
|
{ type: 'command', command: 'bash /my/custom-lint.sh' },
|
|
],
|
|
}];
|
|
|
|
const result = filterGsdHooks(entries);
|
|
assert.strictEqual(result.length, 1, 'entry should survive with remaining user hook');
|
|
assert.strictEqual(result[0].hooks.length, 1, 'only user hook should remain');
|
|
assert.ok(result[0].hooks[0].command.includes('custom-lint'), 'user hook preserved');
|
|
});
|
|
|
|
test('entry with only GSD hooks is fully removed', () => {
|
|
const entries = [{
|
|
hooks: [
|
|
{ type: 'command', command: 'node /path/to/gsd-check-update.js' },
|
|
{ type: 'command', command: 'node /path/to/gsd-statusline.js' },
|
|
],
|
|
}];
|
|
|
|
const result = filterGsdHooks(entries);
|
|
assert.strictEqual(result.length, 0, 'entry should be removed when all hooks are GSD');
|
|
});
|
|
|
|
test('entry with only user hooks is untouched', () => {
|
|
const entries = [{
|
|
matcher: 'Bash',
|
|
hooks: [
|
|
{ type: 'command', command: 'bash /my/pre-check.sh' },
|
|
],
|
|
}];
|
|
|
|
const result = filterGsdHooks(entries);
|
|
assert.strictEqual(result.length, 1, 'entry should survive');
|
|
assert.strictEqual(result[0].hooks.length, 1, 'user hook should remain');
|
|
});
|
|
|
|
test('non-array hook entries are preserved during uninstall (#1825)', () => {
|
|
const entries = [
|
|
{ type: 'custom', command: 'echo hello' },
|
|
{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node /path/to/gsd-prompt-guard.js' }] },
|
|
{ url: 'https://example.com/webhook' },
|
|
];
|
|
|
|
const result = filterGsdHooks(JSON.parse(JSON.stringify(entries)));
|
|
assert.strictEqual(result.length, 2, 'both non-array entries should survive');
|
|
assert.deepStrictEqual(result[0], { type: 'custom', command: 'echo hello' }, 'first non-array entry preserved');
|
|
assert.deepStrictEqual(result[1], { url: 'https://example.com/webhook' }, 'second non-array entry preserved');
|
|
});
|
|
|
|
test('all GSD hook names are recognized by isGsdHookCommand', () => {
|
|
const gsdCommands = [
|
|
'node /path/gsd-check-update.js',
|
|
'node /path/gsd-statusline.js',
|
|
'bash /path/gsd-session-state.sh',
|
|
'node /path/gsd-context-monitor.js',
|
|
'bash /path/gsd-phase-boundary.sh',
|
|
'node /path/gsd-prompt-guard.js',
|
|
'node /path/gsd-read-guard.js',
|
|
'bash /path/gsd-validate-commit.sh',
|
|
'node /path/gsd-workflow-guard.js',
|
|
];
|
|
|
|
for (const cmd of gsdCommands) {
|
|
assert.ok(isGsdHookCommand(cmd), `should recognize: ${cmd}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 5. Codex legacy migration
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Codex legacy gsd-update-check migration', () => {
|
|
test('install.js strips legacy gsd-update-check hook blocks from config', () => {
|
|
const src = fs.readFileSync(INSTALL_SRC, 'utf-8');
|
|
assert.ok(
|
|
src.includes('gsd-update-check') && src.includes('replace('),
|
|
'install.js should have migration logic to strip legacy gsd-update-check entries'
|
|
);
|
|
});
|
|
|
|
test('migration regex removes LF legacy hook block', () => {
|
|
const legacyBlock = [
|
|
'[features]',
|
|
'codex_hooks = true',
|
|
'',
|
|
'# GSD Hooks',
|
|
'[[hooks]]',
|
|
'event = "SessionStart"',
|
|
'command = "node /old/path/gsd-update-check.js"',
|
|
'',
|
|
].join('\n');
|
|
|
|
let content = legacyBlock;
|
|
content = content.replace(/\n# GSD Hooks\n\[\[hooks\]\]\nevent = "SessionStart"\ncommand = "node [^\n]*gsd-update-check\.js"\n/g, '\n');
|
|
assert.ok(!content.includes('gsd-update-check'), 'legacy hook block should be removed');
|
|
assert.ok(content.includes('[features]'), 'non-hook content should be preserved');
|
|
});
|
|
|
|
test('migration regex removes CRLF legacy hook block', () => {
|
|
const legacyBlock = [
|
|
'[features]',
|
|
'codex_hooks = true',
|
|
'',
|
|
'# GSD Hooks',
|
|
'[[hooks]]',
|
|
'event = "SessionStart"',
|
|
'command = "node /old/path/gsd-update-check.js"',
|
|
'',
|
|
].join('\r\n');
|
|
|
|
let content = legacyBlock;
|
|
content = content.replace(/\r\n# GSD Hooks\r\n\[\[hooks\]\]\r\nevent = "SessionStart"\r\ncommand = "node [^\r\n]*gsd-update-check\.js"\r\n/g, '\r\n');
|
|
assert.ok(!content.includes('gsd-update-check'), 'legacy CRLF hook block should be removed');
|
|
assert.ok(content.includes('[features]'), 'non-hook content should be preserved');
|
|
});
|
|
});
|