diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index f27ce2b89..5be0a224f 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -81,6 +81,23 @@ function writeCodexConfig(codexHome, content) { fs.writeFileSync(path.join(codexHome, 'config.toml'), content, 'utf8'); } +function readHooksSessionStartCommands(codexHome) { + const hooksPath = path.join(codexHome, 'hooks.json'); + if (!fs.existsSync(hooksPath)) return []; + const raw = fs.readFileSync(hooksPath, 'utf8').trim(); + if (!raw) return []; + const parsed = JSON.parse(raw); + const table = (parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)) + ? parsed.hooks + : parsed; + const sessionStart = Array.isArray(table.SessionStart) ? table.SessionStart : []; + return sessionStart.flatMap((entry) => + (Array.isArray(entry?.hooks) ? entry.hooks : []) + .map((hook) => hook && hook.command) + .filter((cmd) => typeof cmd === 'string') + ); +} + function countMatches(content, pattern) { return (content.match(pattern) || []).length; } @@ -1409,14 +1426,19 @@ describe('Codex install hook configuration (e2e)', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - test('Codex install copies hook file that is referenced in config.toml (#2153)', () => { + test('Codex install copies hook file that is referenced in hooks.json (#2153)', () => { // Regression test: Codex install writes gsd-check-update hook reference into - // config.toml but must also copy the hook file to ~/$CODEX_HOME/hooks/ + // hooks.json and must also copy the hook file to ~/$CODEX_HOME/hooks/ runCodexInstall(codexHome); const configContent = readCodexConfig(codexHome); - // config.toml must reference the hook - assert.ok(configContent.includes('gsd-check-update.js'), 'config.toml references gsd-check-update.js'); + assert.ok(!configContent.includes('gsd-check-update.js'), 'config.toml does not carry managed SessionStart hooks'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + assert.equal( + hooksJsonCommands.some((cmd) => cmd.includes('gsd-check-update.js')), + true, + 'hooks.json references gsd-check-update.js' + ); // The hook file must physically exist at the referenced path const hookFile = path.join(codexHome, 'hooks', 'gsd-check-update.js'); assert.ok( @@ -1430,11 +1452,8 @@ describe('Codex install hook configuration (e2e)', () => { const content = readCodexConfig(codexHome); assert.ok(content.includes('[features]\ncodex_hooks = true\n'), 'writes codex_hooks feature'); - // Codex 0.124.0+ nested schema: [[hooks.SessionStart]] + [[hooks.SessionStart.hooks]] const parsed = parseTomlToObject(content); - assert.ok(parsed.hooks && Array.isArray(parsed.hooks.SessionStart), 'writes [[hooks.SessionStart]] AoT'); - assert.ok(Array.isArray(parsed.hooks.SessionStart[0].hooks), 'writes [[hooks.SessionStart.hooks]] sub-table'); - assert.strictEqual(parsed.hooks.SessionStart[0].hooks[0].type, 'command', 'handler type is "command"'); + assert.ok(!parsed.hooks || !Array.isArray(parsed.hooks.SessionStart), 'config.toml does not carry managed SessionStart hooks'); // #3017: handler command now uses the absolute Node binary path so // GUI/minimal-PATH runtimes can resolve it. The shape is // "" "" @@ -1444,14 +1463,11 @@ describe('Codex install hook configuration (e2e)', () => { const expectedRunner = JSON.parse(resolveNodeRunner()); const expectedHookPath = path.join(codexHome, 'hooks', 'gsd-check-update.js').replace(/\\/g, '/'); const expectedCommand = `"${expectedRunner}" "${expectedHookPath}"`; - assert.strictEqual( - parsed.hooks.SessionStart[0].hooks[0].command, - expectedCommand, - 'handler command must use absolute node runner pointing at gsd-check-update.js (#3017)' - ); - assert.ok(!Array.isArray(parsed.hooks), 'no flat [[hooks]] AoT emitted'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdCommands = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + assert.strictEqual(gsdCommands.length, 1, 'writes one GSD update hook in hooks.json'); + assert.strictEqual(gsdCommands[0], expectedCommand, 'handler command must use absolute node runner pointing at gsd-check-update.js (#3017)'); assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'writes one codex_hooks key'); - assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 1, 'writes one GSD update hook'); assertNoDraftRootKeys(content); assertUsesOnlyEol(content, '\n'); }); @@ -1533,7 +1549,9 @@ describe('Codex install hook configuration (e2e)', () => { assert.ok(content.includes('# user comment'), 'preserves user comment'); assert.ok(content.includes('[model]\nname = "o3"'), 'preserves model section'); assert.ok(content.includes('command = "echo custom"'), 'preserves custom hook'); - assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 1, 'adds one GSD update hook'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + assert.strictEqual(gsdEntries.length, 1, 'adds one GSD update hook in hooks.json'); assertNoDraftRootKeys(content); }); @@ -1670,7 +1688,9 @@ describe('Codex install hook configuration (e2e)', () => { assert.ok(!content.includes('codex_hooks = false'), 'removes false codex_hooks value'); assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); assert.ok(content.includes('command = "echo custom"'), 'preserves custom hook'); - assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 1, 'does not duplicate GSD update hook'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + assert.strictEqual(gsdEntries.length, 1, 'does not duplicate GSD update hook in hooks.json'); assertNoDraftRootKeys(content); }); @@ -1712,7 +1732,9 @@ describe('Codex install hook configuration (e2e)', () => { assert.strictEqual(countMatches(content, /^"codex_hooks" = true$/gm), 1, 'normalizes the quoted codex_hooks key to true'); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not prepend a second bare features table'); assert.ok(content.includes('other_feature = true'), 'preserves existing feature keys'); - assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 1, 'keeps one GSD update hook'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + assert.strictEqual(gsdEntries.length, 1, 'keeps one GSD update hook in hooks.json'); assertNoDraftRootKeys(content); }); @@ -1733,7 +1755,9 @@ describe('Codex install hook configuration (e2e)', () => { assert.ok(content.includes('[features."a#b"]\nenabled = true'), 'preserves the quoted nested features table'); assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'adds one real top-level features table'); assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'adds one codex_hooks key'); - assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 1, 'remains idempotent for the GSD hook block'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json'); assertNoDraftRootKeys(content); }); @@ -1753,7 +1777,9 @@ describe('Codex install hook configuration (e2e)', () => { assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 0, 'does not add a [features] table'); assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 1, 'adds one dotted codex_hooks key'); assert.ok(content.includes('features.other_feature = true'), 'preserves existing dotted features key'); - assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 1, 'adds one GSD update hook for dotted codex_hooks and remains idempotent'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + assert.strictEqual(gsdEntries.length, 1, 'adds one GSD update hook for dotted codex_hooks and remains idempotent'); assertNoDraftRootKeys(content); }); @@ -1817,7 +1843,9 @@ describe('Codex install hook configuration (e2e)', () => { assert.strictEqual(countMatches(content, /^features\."codex_hooks" = true$/gm), 1, 'normalizes the quoted dotted key to true'); assert.strictEqual(countMatches(content, /^features\.codex_hooks = true$/gm), 0, 'does not append a bare dotted duplicate'); assert.ok(content.includes('features.other_feature = true'), 'preserves other dotted features keys'); - assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 1, 'adds one GSD update hook for quoted dotted codex_hooks and remains idempotent'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + assert.strictEqual(gsdEntries.length, 1, 'adds one GSD update hook for quoted dotted codex_hooks and remains idempotent'); assertNoDraftRootKeys(content); }); @@ -1932,7 +1960,9 @@ describe('Codex install hook configuration (e2e)', () => { assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'replaces the multiline basic-string assignment with one true value'); assert.ok(!content.includes('multiline-basic-sentinel'), 'removes multiline basic-string continuation lines'); assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); - assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 1, 'remains idempotent for the GSD hook block'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json'); assertNoDraftRootKeys(content); }); @@ -1957,7 +1987,9 @@ describe('Codex install hook configuration (e2e)', () => { assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'replaces the multiline literal-string assignment with one true value'); assert.ok(!content.includes('multiline-literal-sentinel'), 'removes multiline literal-string continuation lines'); assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); - assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 1, 'remains idempotent for the GSD hook block'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json'); assertNoDraftRootKeys(content); }); @@ -1983,7 +2015,9 @@ describe('Codex install hook configuration (e2e)', () => { assert.ok(!content.includes('array-sentinel-1'), 'removes multiline array continuation lines'); assert.ok(!content.includes('array-sentinel-2'), 'removes multiline array continuation lines'); assert.ok(content.includes('other_feature = true'), 'preserves following feature keys'); - assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 1, 'remains idempotent for the GSD hook block'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + assert.strictEqual(gsdEntries.length, 1, 'remains idempotent for the GSD hook block in hooks.json'); assertNoDraftRootKeys(content); }); @@ -2028,7 +2062,9 @@ describe('Codex install hook configuration (e2e)', () => { assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'keeps one codex_hooks = true'); assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); assert.strictEqual(countMatches(content, /echo custom-after-command/g), 1, 'preserves non-GSD hook exactly once'); - assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 1, 'keeps one GSD update hook'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + assert.strictEqual(gsdEntries.length, 1, 'keeps one GSD update hook in hooks.json'); assertUsesOnlyEol(content, '\r\n'); assertNoDraftRootKeys(content); }); @@ -2051,7 +2087,9 @@ describe('Codex install hook configuration (e2e)', () => { assert.strictEqual(countMatches(content, /^\[features\]\s*$/gm), 1, 'keeps one [features] section'); assert.strictEqual(countMatches(content, /^codex_hooks = true # keep me$/gm), 1, 'preserves the commented true value'); assert.ok(content.includes('other_feature = true'), 'preserves other feature keys'); - assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 1, 'adds the GSD update hook once'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + assert.strictEqual(gsdEntries.length, 1, 'adds the GSD update hook once in hooks.json'); assertNoDraftRootKeys(content); }); @@ -2065,13 +2103,14 @@ describe('Codex install hook configuration (e2e)', () => { // [features] is inserted after top-level lines, before [model] — not prepended assert.ok(content.includes('# first line wins\n\n[features]\ncodex_hooks = true\n'), 'inserts features after top-level lines using first newline style'); assert.ok(content.includes(`# GSD Agent Configuration — managed by get-shit-done installer\n`), 'writes the managed agent block using the first newline style'); - // Structural check: nested schema must be present regardless of mixed EOL + // Structural check: managed SessionStart hooks live in hooks.json. const parsedMixed = parseTomlToObject(content); - assert.ok(parsedMixed.hooks && Array.isArray(parsedMixed.hooks.SessionStart), 'writes [[hooks.SessionStart]] AoT with first-newline style'); - assert.ok(Array.isArray(parsedMixed.hooks.SessionStart[0].hooks), 'writes [[hooks.SessionStart.hooks]] sub-table'); + assert.ok(!parsedMixed.hooks || !Array.isArray(parsedMixed.hooks.SessionStart), 'does not write managed SessionStart hooks to config.toml'); + const hooksJsonCommands = readHooksSessionStartCommands(codexHome); + const gsdEntries = hooksJsonCommands.filter((cmd) => cmd.includes('gsd-check-update.js')); + assert.strictEqual(gsdEntries.length, 1, 'writes one managed SessionStart hook to hooks.json'); assert.ok(content.includes('[model]\r\nname = "o3"'), 'preserves the existing CRLF model lines'); assert.strictEqual(countMatches(content, /^codex_hooks = true$/gm), 1, 'remains idempotent on repeated installs'); - assert.strictEqual(countMatches(content, /gsd-check-update\.js/g), 1, 'does not duplicate the GSD hook block'); assertNoDraftRootKeys(content); }); });