* test(#3426): add RED test for Codex Windows hooks .cmd shim requirement Drive buildCodexHookWindowsShimIR (typed IR) + ensureCodexHooksJsonSessionStart integration against mocked win32 platform. Counter-tests confirm darwin/linux paths remain unchanged. NOTE: Windows wall-clock verification depends on Docker matrix Windows runners. Local test exercises the generator IR shape only. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#3426): Codex Windows hooks use .cmd shim to avoid bash.exe POSIX-exec failure Root cause: Codex on Windows runs hook commands from PowerShell/cmd. The previous hooks.json command format was `"node.exe" "script.js"`. Codex's hook-dispatch shell (Git Bash / MSYS) tried to POSIX-exec node.exe (a Windows PE binary) via execvp(), which fails with ENOEXEC — reported as `bash.exe: cannot execute binary file`. Fix: `ensureCodexHooksJsonSessionStart` now calls `buildCodexHookWindowsShimIR` on win32 to write a .cmd shim alongside the .js hook file. cmd.exe executes .cmd files natively via CreateProcess, bypassing the POSIX exec layer entirely. Non-Windows paths (darwin, linux) are unchanged: they continue to use the node-runner command. Also adds `gsd-check-update.cmd` to the codex-hooks-json managed-basename set so reconcileCodexHooksJsonSessionStart correctly replaces stale node-runner entries on reinstall. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(3426): update changeset to reference PR #3768 * fix(3426): fail-loud on Codex Windows shim-write failure instead of silently restoring broken command Replace the silent fallback to `projectManagedHookCommand` (the old `node.exe script.js` form) with an explicit warn-and-skip path. When `atomicWriteFileSync` fails to write the `.cmd` shim, the previous code silently called `reconcileCodexHooksJsonSessionStart` with the legacy node-runner command. That command triggers the exact `bash.exe: cannot execute binary file` POSIX-exec failure that #3426 exists to fix — so a successful-looking install was secretly restoring the original bug. New behaviour: - Emit `console.warn` with the failure reason and a remediation hint, matching the `${yellow}⚠${reset} Skipped …` idiom used at line 9098. - Return `{ changed: false, wrote: false }` to skip registration for this runtime entirely, so the outer caller can surface "NOT installed" instead of "installed (but broken)". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(3426): typed-IR assertions on .cmd shim eol/quoting/passthrough + IR extension Extend `buildCodexHookWindowsShimIR` to expose two new typed fields on the returned IR object (CONTRIBUTING.md L558-L565 IR-first discipline): eol: { cmd: '\r\n' } — CRLF is canonical for cmd.exe .cmd files passthroughArgs: true — shim forwards all args via %* Add a new describe block (Step 2b) with three IR-level assertions: 1. `eol.cmd === '\r\n'` — prevents silent EOL regression that could break parsing on Windows versions that require CRLF. 2. `invocation.target` is the raw unquoted path (no shell-metachar leakage) — quoting happens only at render time. 3. `passthroughArgs === true` — the %* forwarding contract is explicitly typed so regressions fail before the text is rendered. All assertions operate on the typed IR returned by the generator, NOT on the rendered `.cmd` file content — text-matching is the anti-pattern CONTRIBUTING.md L522-582 prohibits. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(3426): fix Windows CI failures — update hook-command filter patterns Four test files filtered for managed hooks in hooks.json using the literal string `gsd-check-update.js`. On Windows the PR-introduced .cmd shim changes the hooks.json command to `"path/gsd-check-update.cmd"` (no node prefix, .cmd extension), so those filters matched 0 entries and 24 Windows subtests failed. Fixes: - bug-2760-codex-install-defensive.test.cjs (7 filters): change `/gsd-check-update\.js/` → `/gsd-check-update/` to match both .js (POSIX) and .cmd (Windows) commands. - bug-3357-codex-legacy-hooks-json-migration.test.cjs (3 filters): same `.js` → no-extension change. - bug-3427-3433-codex-install-shape.test.cjs (2 filters): same fix; add explanatory comment to uninstall assertion. - codex-config.test.cjs (9 filters + 1 exact-command assertion): bulk-replace all `hooksJsonCommands.filter(cmd => cmd.includes('gsd-check-update.js'))` with `gsd-check-update`; make the `fresh CODEX_HOME` test platform- aware — on win32 assert `.cmd` shim path, on POSIX assert the existing `"runner" "script.js"` form (#3017). All four suites pass locally (macOS / darwin). Windows subtests verified against the Windows CI failure log patterns. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(3426): address pr-review-toolkit + codex review findings - fix(uninstall): add gsd-check-update.cmd to gsdHooks cleanup list so the .cmd shim is removed from disk on Windows uninstall (was left as orphan artifact — silent failure post-uninstall) - test(3426): add uninstall test asserting gsd-check-update.cmd is deleted from hooks dir after `uninstall(true, 'codex')` (no coverage existed) - fix(comment): correct JSDoc on buildCodexHookWindowsShimIR — shim content is three-line @ECHO OFF/@SETLOCAL/@runner snippet, not bare `@node "script.js" %*` as the old comment claimed - fix(comment): update stale assertion message in codex-config.test.cjs L1457 — said "config.toml references it" but the hook is in hooks.json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
142 lines
5.1 KiB
JavaScript
142 lines
5.1 KiB
JavaScript
/**
|
|
* Regression test for bug #3357.
|
|
*
|
|
* Older Codex installs carried legacy GSD SessionStart commands in hooks.json.
|
|
* Current install keeps the managed SessionStart hook in hooks.json (single
|
|
* representation per layer) and strips stale managed entries before writing
|
|
* exactly one canonical managed command.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
|
|
const installModule = require('../bin/install.js');
|
|
const { readInstallState } = require('../get-shit-done/bin/lib/installer-migrations.cjs');
|
|
const { install, parseTomlToObject } = installModule;
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
const HOOKS_DIST = path.join(__dirname, '..', 'hooks', 'dist');
|
|
const BUILD_HOOKS_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
|
|
|
function withCodexHome(codexHome, fn) {
|
|
const previousCodexHome = process.env.CODEX_HOME;
|
|
process.env.CODEX_HOME = codexHome;
|
|
try {
|
|
return fn();
|
|
} finally {
|
|
if (previousCodexHome == null) delete process.env.CODEX_HOME;
|
|
else process.env.CODEX_HOME = previousCodexHome;
|
|
}
|
|
}
|
|
|
|
function legacyGsdHook(codexHome) {
|
|
return {
|
|
hooks: [{
|
|
type: 'command',
|
|
command: `node "${path.join(codexHome, 'hooks', 'gsd-check-update.js')}"`,
|
|
}],
|
|
};
|
|
}
|
|
|
|
function userHook() {
|
|
return {
|
|
hooks: [{
|
|
type: 'command',
|
|
command: 'node "/Users/example/bin/user-hook.js"',
|
|
}],
|
|
};
|
|
}
|
|
|
|
function tomlGsdHookCount(codexHome) {
|
|
const parsed = parseTomlToObject(fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8'));
|
|
const sessionStart = parsed.hooks?.SessionStart ?? [];
|
|
return sessionStart
|
|
.flatMap((entry) => Array.isArray(entry.hooks) ? entry.hooks : [])
|
|
.filter((hook) => typeof hook.command === 'string' && hook.command.includes('gsd-check-update'))
|
|
.length;
|
|
}
|
|
|
|
describe('#3357 — Codex install removes legacy GSD hooks.json entries', { concurrency: false }, () => {
|
|
let tmpRoot;
|
|
let codexHome;
|
|
|
|
beforeEach(() => {
|
|
if (!fs.existsSync(HOOKS_DIST) || fs.readdirSync(HOOKS_DIST).length === 0) {
|
|
execFileSync(process.execPath, [BUILD_HOOKS_SCRIPT], { stdio: 'pipe' });
|
|
}
|
|
tmpRoot = createTempDir('gsd-3357-');
|
|
codexHome = path.join(tmpRoot, '.codex');
|
|
fs.mkdirSync(codexHome, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete installModule.__codexSchemaValidator;
|
|
cleanup(tmpRoot);
|
|
});
|
|
|
|
test('rewrites hooks.json to one managed SessionStart hook when file only had legacy managed entry', () => {
|
|
fs.writeFileSync(
|
|
path.join(codexHome, 'hooks.json'),
|
|
JSON.stringify({ SessionStart: [legacyGsdHook(codexHome)] }, null, 2),
|
|
);
|
|
|
|
withCodexHome(codexHome, () => install(true, 'codex'));
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'));
|
|
const commands = hooksJson.SessionStart.flatMap((entry) => entry.hooks).map((hook) => hook.command);
|
|
const managed = commands.filter((cmd) => typeof cmd === 'string' && cmd.includes('gsd-check-update'));
|
|
assert.equal(managed.length, 1);
|
|
assert.equal(tomlGsdHookCount(codexHome), 0);
|
|
});
|
|
|
|
test('preserves user hooks.json entries while removing the legacy GSD hook', () => {
|
|
const userOwnedSameBasenameHook = {
|
|
hooks: [{
|
|
type: 'command',
|
|
command: 'node "/Users/example/bin/gsd-check-update.js"',
|
|
}],
|
|
};
|
|
fs.writeFileSync(
|
|
path.join(codexHome, 'hooks.json'),
|
|
JSON.stringify({ SessionStart: [legacyGsdHook(codexHome), userHook(), userOwnedSameBasenameHook] }, null, 2),
|
|
);
|
|
|
|
withCodexHome(codexHome, () => install(true, 'codex'));
|
|
|
|
const hooksJson = JSON.parse(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'));
|
|
const commands = hooksJson.SessionStart.flatMap((entry) => entry.hooks).map((hook) => hook.command);
|
|
const managed = commands.filter((cmd) => typeof cmd === 'string' && cmd.includes('gsd-check-update'));
|
|
assert.equal(commands.includes('node "/Users/example/bin/user-hook.js"'), true);
|
|
assert.equal(commands.includes('node "/Users/example/bin/gsd-check-update.js"'), true);
|
|
assert.equal(managed.length, 2);
|
|
assert.equal(tomlGsdHookCount(codexHome), 0);
|
|
});
|
|
|
|
test('restores migrated hooks.json and install state when later Codex validation fails', () => {
|
|
const before = JSON.stringify({ SessionStart: [legacyGsdHook(codexHome)] }, null, 2);
|
|
fs.writeFileSync(path.join(codexHome, 'hooks.json'), before);
|
|
|
|
installModule.__codexSchemaValidator = () => ({
|
|
ok: false,
|
|
reason: 'forced migration rollback test',
|
|
});
|
|
|
|
assert.throws(
|
|
() => withCodexHome(codexHome, () => install(true, 'codex')),
|
|
/forced migration rollback test/
|
|
);
|
|
|
|
assert.equal(fs.readFileSync(path.join(codexHome, 'hooks.json'), 'utf8'), before);
|
|
assert.equal(
|
|
readInstallState(codexHome).appliedMigrations.some((entry) => entry.id === '2026-05-11-codex-legacy-hooks-json'),
|
|
false
|
|
);
|
|
});
|
|
});
|