mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
Tests #1834, #1924, #2136 exercise hook/artifact deployment and don't care about SDK install. Now that installSdkIfNeeded() failures are fatal, these tests fail on any CI runner without gsd-sdk pre-built because the sdk/ tsc build path runs and can fail in CI env. Pass --no-sdk so each test focuses on its actual subject. SDK install path has dedicated end-to-end coverage in install-smoke.yml.
362 lines
16 KiB
JavaScript
362 lines
16 KiB
JavaScript
/**
|
|
* Regression tests for bug #2136 / #2206
|
|
*
|
|
* Root cause: three bash hooks (gsd-phase-boundary.sh, gsd-session-state.sh,
|
|
* gsd-validate-commit.sh) shipped without a gsd-hook-version header, and the
|
|
* stale-hook detector in gsd-check-update.js only matched JavaScript comment
|
|
* syntax (//) — not bash comment syntax (#).
|
|
*
|
|
* Result: every session showed "⚠ stale hooks — run /gsd-update" immediately
|
|
* after a fresh install, because the detector saw hookVersion: 'unknown' for
|
|
* all three bash hooks.
|
|
*
|
|
* This fix requires THREE parts working in concert:
|
|
* 1. Bash hooks ship with "# gsd-hook-version: {{GSD_VERSION}}"
|
|
* 2. install.js substitutes {{GSD_VERSION}} in .sh files at install time
|
|
* 3. gsd-check-update.js regex matches both "//" and "#" comment styles
|
|
*
|
|
* Neither fix alone is sufficient:
|
|
* - Headers + regex fix only (no install.js fix): installed hooks contain
|
|
* literal "{{GSD_VERSION}}" — the {{-guard silently skips them, making
|
|
* bash hook staleness permanently undetectable after future updates.
|
|
* - Headers + install.js fix only (no regex fix): installed hooks are
|
|
* stamped correctly but the detector still can't read bash "#" comments,
|
|
* so they still land in the "unknown / stale" branch on every session.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
// NOTE: Do NOT set GSD_TEST_MODE here — the E2E install tests spawn the
|
|
// real installer subprocess, which skips all install logic when GSD_TEST_MODE=1.
|
|
|
|
const { describe, test, before, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const { execFileSync } = require('child_process');
|
|
|
|
const HOOKS_DIR = path.join(__dirname, '..', 'hooks');
|
|
const CHECK_UPDATE_FILE = path.join(HOOKS_DIR, 'gsd-check-update.js');
|
|
const WORKER_FILE = path.join(HOOKS_DIR, 'gsd-check-update-worker.js');
|
|
const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js');
|
|
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
|
|
|
|
const SH_HOOKS = [
|
|
'gsd-phase-boundary.sh',
|
|
'gsd-session-state.sh',
|
|
'gsd-validate-commit.sh',
|
|
];
|
|
|
|
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
|
|
|
|
before(() => {
|
|
execFileSync(process.execPath, [BUILD_SCRIPT], {
|
|
encoding: 'utf-8',
|
|
stdio: 'pipe',
|
|
});
|
|
});
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function createTempDir(prefix) {
|
|
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
}
|
|
|
|
function cleanup(dir) {
|
|
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
}
|
|
|
|
function runInstaller(configDir) {
|
|
// --no-sdk: this test covers .sh hook version stamping only; skip SDK
|
|
// build (covered by install-smoke.yml).
|
|
execFileSync(process.execPath, [INSTALL_SCRIPT, '--claude', '--global', '--yes', '--no-sdk'], {
|
|
encoding: 'utf-8',
|
|
stdio: 'pipe',
|
|
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir },
|
|
});
|
|
return path.join(configDir, 'hooks');
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Part 1: Bash hook sources carry the version header placeholder
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('bug #2136 part 1: bash hook sources carry gsd-hook-version placeholder', () => {
|
|
for (const sh of SH_HOOKS) {
|
|
test(`${sh} contains "# gsd-hook-version: {{GSD_VERSION}}"`, () => {
|
|
const content = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8');
|
|
assert.ok(
|
|
content.includes('# gsd-hook-version: {{GSD_VERSION}}'),
|
|
`${sh} must include "# gsd-hook-version: {{GSD_VERSION}}" so the ` +
|
|
`installer can stamp it and gsd-check-update.js can detect staleness`
|
|
);
|
|
});
|
|
}
|
|
|
|
test('version header is on line 2 (immediately after shebang)', () => {
|
|
// Placing the header immediately after #!/bin/bash ensures it is always
|
|
// found regardless of how much of the file is read.
|
|
for (const sh of SH_HOOKS) {
|
|
const lines = fs.readFileSync(path.join(HOOKS_DIR, sh), 'utf8').split('\n');
|
|
assert.strictEqual(lines[0], '#!/bin/bash', `${sh} line 1 must be #!/bin/bash`);
|
|
assert.ok(
|
|
lines[1].startsWith('# gsd-hook-version:'),
|
|
`${sh} line 2 must be the gsd-hook-version header (got: "${lines[1]}")`
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Part 2: gsd-check-update-worker.js regex handles bash "#" comment syntax
|
|
// (Logic moved from inline -e template literal to dedicated worker file)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('bug #2136 part 2: stale-hook detector handles bash comment syntax', () => {
|
|
let src;
|
|
|
|
before(() => {
|
|
src = fs.readFileSync(WORKER_FILE, 'utf8');
|
|
});
|
|
|
|
test('version regex in source matches "#" comment syntax in addition to "//"', () => {
|
|
// The regex string in the source must contain the alternation for "#".
|
|
// The worker uses plain JS (no template-literal escaping), so the form is
|
|
// "(?:\/\/|#)" directly in source.
|
|
const hasBashAlternative =
|
|
src.includes('(?:\\/\\/|#)') || // escaped form (old template-literal style)
|
|
src.includes('(?:\/\/|#)'); // direct form in plain JS worker
|
|
assert.ok(
|
|
hasBashAlternative,
|
|
'gsd-check-update-worker.js version regex must include an alternative for bash "#" comments. ' +
|
|
'Expected to find (?:\\/\\/|#) or (?:\/\/|#) in the source. ' +
|
|
'The original "//" only regex causes bash hooks to always report hookVersion: "unknown"'
|
|
);
|
|
});
|
|
|
|
test('version regex does not use the old JS-only form as the sole pattern', () => {
|
|
// The old regex inside the template literal was the string:
|
|
// /\\/\\/ gsd-hook-version:\\s*(.+)/
|
|
// which, when evaluated in the subprocess, produced: /\/\/ gsd-hook-version:\s*(.+)/
|
|
// That only matched JS "//" comments — never bash "#".
|
|
// We verify that the old exact string no longer appears.
|
|
assert.ok(
|
|
!src.includes('\\/\\/ gsd-hook-version'),
|
|
'gsd-check-update-worker.js must not use the old JS-only (\\/\\/ gsd-hook-version) ' +
|
|
'escape form as the sole version matcher — it cannot match bash "#" comments'
|
|
);
|
|
});
|
|
|
|
test('version regex correctly matches both bash and JS hook version headers', () => {
|
|
// Verify that the versionMatch line in the source uses a regex that matches
|
|
// both bash "#" and JS "//" comment styles. We check the source contains the
|
|
// expected alternation, then directly test the known required pattern.
|
|
//
|
|
// We do NOT try to extract and evaluate the regex from source (it contains ")"
|
|
// which breaks simple extraction), so instead we confirm the source matches
|
|
// our expectation and run the regex itself.
|
|
assert.ok(
|
|
src.includes('gsd-hook-version'),
|
|
'gsd-check-update-worker.js must contain a gsd-hook-version version check'
|
|
);
|
|
|
|
// The fixed regex that must be present: matches both comment styles
|
|
const fixedRegex = /(?:\/\/|#) gsd-hook-version:\s*(.+)/;
|
|
|
|
assert.ok(
|
|
fixedRegex.test('# gsd-hook-version: 1.36.0'),
|
|
'bash-style "# gsd-hook-version: X" must be matchable by the required regex'
|
|
);
|
|
assert.ok(
|
|
fixedRegex.test('// gsd-hook-version: 1.36.0'),
|
|
'JS-style "// gsd-hook-version: X" must still match (no regression)'
|
|
);
|
|
assert.ok(
|
|
!fixedRegex.test('gsd-hook-version: 1.36.0'),
|
|
'line without a comment prefix must not match (prevents false positives)'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Part 3a: install.js bundled path substitutes {{GSD_VERSION}} in .sh hooks
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('bug #2136 part 3a: install.js bundled path substitutes {{GSD_VERSION}} in .sh hooks', () => {
|
|
let src;
|
|
|
|
before(() => {
|
|
src = fs.readFileSync(INSTALL_SCRIPT, 'utf8');
|
|
});
|
|
|
|
test('.sh branch in bundled hook copy loop reads file and substitutes GSD_VERSION', () => {
|
|
// Anchor on configDirReplacement — unique to the bundled-hooks path.
|
|
const anchorIdx = src.indexOf('configDirReplacement');
|
|
assert.ok(anchorIdx !== -1, 'bundled hook copy loop anchor (configDirReplacement) not found');
|
|
|
|
// Window large enough for the if/else block
|
|
const region = src.slice(anchorIdx, anchorIdx + 2000);
|
|
|
|
assert.ok(
|
|
region.includes("entry.endsWith('.sh')"),
|
|
"bundled hook copy loop must check entry.endsWith('.sh')"
|
|
);
|
|
assert.ok(
|
|
region.includes('GSD_VERSION'),
|
|
'bundled .sh branch must reference GSD_VERSION substitution. Without this, ' +
|
|
'installed .sh hooks contain the literal "{{GSD_VERSION}}" placeholder and ' +
|
|
'bash hook staleness becomes permanently undetectable after future updates'
|
|
);
|
|
// copyFileSync on a .sh file would skip substitution — ensure we read+write instead
|
|
const shBranchIdx = region.indexOf("entry.endsWith('.sh')");
|
|
const shBranchRegion = region.slice(shBranchIdx, shBranchIdx + 400);
|
|
assert.ok(
|
|
shBranchRegion.includes('readFileSync') || shBranchRegion.includes('writeFileSync'),
|
|
'bundled .sh branch must read the file (readFileSync) to perform substitution, ' +
|
|
'not copyFileSync directly (which skips template expansion)'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Part 3b: install.js Codex path also substitutes {{GSD_VERSION}} in .sh hooks
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('bug #2136 part 3b: install.js Codex path substitutes {{GSD_VERSION}} in .sh hooks', () => {
|
|
let src;
|
|
|
|
before(() => {
|
|
src = fs.readFileSync(INSTALL_SCRIPT, 'utf8');
|
|
});
|
|
|
|
test('.sh branch in Codex hook copy block substitutes GSD_VERSION', () => {
|
|
// Anchor on codexHooksSrc — unique to the Codex path.
|
|
const anchorIdx = src.indexOf('codexHooksSrc');
|
|
assert.ok(anchorIdx !== -1, 'Codex hook copy block anchor (codexHooksSrc) not found');
|
|
|
|
const region = src.slice(anchorIdx, anchorIdx + 2000);
|
|
|
|
assert.ok(
|
|
region.includes("entry.endsWith('.sh')"),
|
|
"Codex hook copy block must check entry.endsWith('.sh')"
|
|
);
|
|
assert.ok(
|
|
region.includes('GSD_VERSION'),
|
|
'Codex .sh branch must substitute {{GSD_VERSION}}. The bundled path was fixed ' +
|
|
'but Codex installs a separate copy of the hooks from hooks/dist that also needs stamping'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Part 4: End-to-end — installed .sh hooks have stamped version, not placeholder
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('bug #2136 part 4: installed .sh hooks contain stamped concrete version', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempDir('gsd-2136-install-');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('installed .sh hooks contain a concrete version string, not the template placeholder', () => {
|
|
const hooksDir = runInstaller(tmpDir);
|
|
|
|
for (const sh of SH_HOOKS) {
|
|
const hookPath = path.join(hooksDir, sh);
|
|
assert.ok(fs.existsSync(hookPath), `${sh} must be installed`);
|
|
|
|
const content = fs.readFileSync(hookPath, 'utf8');
|
|
|
|
assert.ok(
|
|
content.includes('# gsd-hook-version:'),
|
|
`installed ${sh} must contain a "# gsd-hook-version:" header`
|
|
);
|
|
assert.ok(
|
|
!content.includes('{{GSD_VERSION}}'),
|
|
`installed ${sh} must not contain literal "{{GSD_VERSION}}" — ` +
|
|
`install.js must substitute it with the concrete package version`
|
|
);
|
|
|
|
const versionMatch = content.match(/# gsd-hook-version:\s*(\S+)/);
|
|
assert.ok(versionMatch, `installed ${sh} version header must have a version value`);
|
|
assert.match(
|
|
versionMatch[1],
|
|
/^\d+\.\d+\.\d+/,
|
|
`installed ${sh} version "${versionMatch[1]}" must be a semver-like string`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('stale-hook detector reports zero stale bash hooks immediately after fresh install', () => {
|
|
// This is the definitive end-to-end proof: after install, run the actual
|
|
// version-check logic (extracted from gsd-check-update.js) against the
|
|
// installed hooks and verify none are flagged stale.
|
|
const hooksDir = runInstaller(tmpDir);
|
|
const pkg = require(path.join(__dirname, '..', 'package.json'));
|
|
const installedVersion = pkg.version;
|
|
|
|
// Build a subprocess that runs the staleness check logic in isolation.
|
|
// We pass the installed version, hooks dir, and hook filenames as JSON
|
|
// to avoid any injection risk.
|
|
const checkScript = `
|
|
'use strict';
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
function isNewer(a, b) {
|
|
const pa = (a || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
|
const pb = (b || '').split('.').map(s => Number(s.replace(/-.*/, '')) || 0);
|
|
for (let i = 0; i < 3; i++) {
|
|
if (pa[i] > pb[i]) return true;
|
|
if (pa[i] < pb[i]) return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const hooksDir = ${JSON.stringify(hooksDir)};
|
|
const installed = ${JSON.stringify(installedVersion)};
|
|
const shHooks = ${JSON.stringify(SH_HOOKS)};
|
|
// Use the same regex that the fixed gsd-check-update.js uses
|
|
const versionRe = /(?:\\/\\/|#) gsd-hook-version:\\s*(.+)/;
|
|
|
|
const staleHooks = [];
|
|
for (const hookFile of shHooks) {
|
|
const hookPath = path.join(hooksDir, hookFile);
|
|
if (!fs.existsSync(hookPath)) {
|
|
staleHooks.push({ file: hookFile, hookVersion: 'missing' });
|
|
continue;
|
|
}
|
|
const content = fs.readFileSync(hookPath, 'utf8');
|
|
const m = content.match(versionRe);
|
|
if (m) {
|
|
const hookVersion = m[1].trim();
|
|
if (isNewer(installed, hookVersion) && !hookVersion.includes('{{')) {
|
|
staleHooks.push({ file: hookFile, hookVersion, installedVersion: installed });
|
|
}
|
|
} else {
|
|
staleHooks.push({ file: hookFile, hookVersion: 'unknown', installedVersion: installed });
|
|
}
|
|
}
|
|
process.stdout.write(JSON.stringify(staleHooks));
|
|
`;
|
|
|
|
const result = execFileSync(process.execPath, ['-e', checkScript], { encoding: 'utf8' });
|
|
const staleHooks = JSON.parse(result);
|
|
|
|
assert.deepStrictEqual(
|
|
staleHooks,
|
|
[],
|
|
`Fresh install must produce zero stale bash hooks.\n` +
|
|
`Got: ${JSON.stringify(staleHooks, null, 2)}\n` +
|
|
`This indicates either the version header was not stamped by install.js, ` +
|
|
`or the detector regex cannot match bash "#" comment syntax.`
|
|
);
|
|
});
|
|
});
|