Three parallel diagnostic agents (G: sdk/install path-sep, H: hook scripts, I: worktree/workspace) characterised the remaining windows-22 failures on23b52f1a. Patches by class: CRLF in test parsers / file-content reads - bug-2136-sh-hook-version: shebang check split('\n') → split(/\r?\n/) - bug-3542-executor-git-stash-prohibition: strip \r from read content (git rewrites stashed text with CRLF on win autocrlf=true checkout) - workspace: BOM-strip + explicit \r strip in parseCommandFile (BOM at byte 0 defeats /^---/ anchor → fmMatch null → fm.name undefined) Windows path-separator / 8.3-shortname normalization - bug-3491-nested-git-worktree: use fs.realpathSync.native to expand %TEMP% RUNNER~1 → runneradmin; normalize sep before path-equality - prune-orphaned-worktrees: normalize \\→/ before substring includes (git emits forward-slash in --porcelain on Windows even when path.join produced backslashes) - bug-3017-codex-hook-absolute-node: accept POSIX path OR path with drive-letter prefix in hookPath equality assertion - bug-3126-global-skills-base-runtime-path: use path.join for expected /xdg/<runtime> values (production calls path.join → \xdg\… on win32) Test under-specified platform / forgot win32 env - bug-2979-hook-absolute-node: pass {platform:'linux'} to buildHookCommand + rewriteLegacyManagedNodeHookCommands so the POSIX-branch tests don't pick up the #3393 GitBash code path - bug-3288-model-catalog + bug-3571-config-manifest: also set USERPROFILE alongside HOME so os.homedir() on win32 redirects to the test fixture instead of the runner's real ~ External-cmd resolution - bug-2647-outer-tarball-sdk-dist: use npm.cmd + {shell:true} on win32 so execFileSync resolves PATHEXT (literal `npm` is ENOENT) ESM loader: tests/runtime-bridge-sync-smoke.test.cjs already migrated to pathToFileURL in23b52f1a(cluster E). Explicit per-test/describe skip on win32 (with required string reasons to satisfy no-unconditional-win32-skip guard) - feat-3347-graphify-auto-update-hook: 3 describes — harness spawns bash/kill/sleep + the hook itself is bash - feat-3595-fs-fault-injection: move \t and \n filenames into the POSIX-only branch (NTFS forbids 0x00–0x1F in filenames) - bug-2775/2829/3033/3231/3359: POSIX shim under ~/.local/bin with chmod 0o755 — not how Windows install works - bug-3211: single subtest where cp.execSync reassignment isn't picked up on win32 (POSIX coverage via the mock; live windows behavior covered by 3211-D which keeps running) - install-path-detection: parses sh-style export PATH= rc files; Windows has no rc files (registry Path) - worktree-safety-policy: single test using POSIX /repo/wt fixture paths that can't be expressed under win32 path.resolve Validated: plex2 (ubuntu docker) 11224/0 pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
403 lines
15 KiB
JavaScript
403 lines
15 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Regression tests for #3347 — hooks/gsd-graphify-update.sh behavior.
|
|
*
|
|
* The hook is a PostToolUse handler that fires after every Bash tool call.
|
|
* It is a no-op except when ALL of these are true:
|
|
* - Tool name is Bash
|
|
* - tool_input.command matches a HEAD-advancing git operation
|
|
* - Current branch == default branch (main/master/trunk; configurable)
|
|
* - .planning/config.json has graphify.enabled === true
|
|
* - .planning/config.json has graphify.auto_update === true
|
|
* - $CI environment variable is unset / empty
|
|
* - graphify binary is on PATH
|
|
* - No live rebuild already in progress (PID lock check)
|
|
*
|
|
* When all gates pass, the hook:
|
|
* 1. Writes .planning/graphs/.last-build-status.json with status="running"
|
|
* and the current HEAD sha (sync, before detach).
|
|
* 2. Detaches a background `graphify update .` invocation that copies
|
|
* outputs into .planning/graphs/ and updates the status file to
|
|
* status="ok" or status="failed" on completion.
|
|
* 3. Returns exit 0 in <100ms regardless.
|
|
*
|
|
* On a hook return path failure (bail), no status file is written and no
|
|
* lock is acquired — the commit completes with no side effect.
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const cp = require('node:child_process');
|
|
const os = require('node:os');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const HOOK = path.join(ROOT, 'hooks', 'gsd-graphify-update.sh');
|
|
|
|
const isWindows = process.platform === 'win32';
|
|
|
|
function createTempGitRepo(opts = {}) {
|
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3347-'));
|
|
cp.execFileSync('git', ['init', '-b', opts.defaultBranch || 'main'], {
|
|
cwd: tmpDir,
|
|
stdio: 'ignore',
|
|
});
|
|
cp.execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpDir });
|
|
cp.execFileSync('git', ['config', 'user.name', 'Test'], { cwd: tmpDir });
|
|
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# test\n');
|
|
cp.execFileSync('git', ['add', 'README.md'], { cwd: tmpDir });
|
|
cp.execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir, stdio: 'ignore' });
|
|
|
|
fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true });
|
|
if (opts.config !== undefined) {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify(opts.config, null, 2),
|
|
);
|
|
}
|
|
return tmpDir;
|
|
}
|
|
|
|
function makeMockGraphifyBin(tmpDir, { exitCode = 0, sleepMs = 0 } = {}) {
|
|
const binDir = path.join(tmpDir, '.mock-bin');
|
|
fs.mkdirSync(binDir, { recursive: true });
|
|
const script = path.join(binDir, 'graphify');
|
|
// Mock: sleep optionally to allow lock observation, then write
|
|
// graphify-out/graph.json and exit with the requested code.
|
|
const body = [
|
|
'#!/usr/bin/env bash',
|
|
'set -u',
|
|
sleepMs ? `sleep ${(sleepMs / 1000).toFixed(3)}` : '',
|
|
'mkdir -p graphify-out',
|
|
'echo \'{"nodes":[],"edges":[]}\' > graphify-out/graph.json',
|
|
'echo "mock report" > graphify-out/GRAPH_REPORT.md',
|
|
'echo "<html></html>" > graphify-out/graph.html',
|
|
`exit ${exitCode}`,
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n');
|
|
fs.writeFileSync(script, body + '\n', { mode: 0o755 });
|
|
return binDir;
|
|
}
|
|
|
|
function runHook(tmpDir, toolPayload, { env = {}, pathPrepend = '' } = {}) {
|
|
const PATH = pathPrepend
|
|
? `${pathPrepend}${path.delimiter}${process.env.PATH || ''}`
|
|
: process.env.PATH || '';
|
|
return cp.spawnSync('bash', [HOOK], {
|
|
cwd: tmpDir,
|
|
input: JSON.stringify(toolPayload),
|
|
env: {
|
|
...process.env,
|
|
PATH,
|
|
CI: '',
|
|
...env,
|
|
},
|
|
encoding: 'utf8',
|
|
timeout: 30000,
|
|
});
|
|
}
|
|
|
|
function cleanup(tmpDir) {
|
|
// The hook detaches a graphify-rebuild subprocess that may still be writing
|
|
// into tmpDir when the test body returns. Wait briefly for its lock file to
|
|
// disappear (rebuild process exit trap removes it), then retry rmSync to
|
|
// absorb any remaining transient ENOTEMPTY race.
|
|
const lockPath = path.join(tmpDir, '.planning/graphs/.rebuild.lock');
|
|
const lockDeadline = Date.now() + 4000;
|
|
while (Date.now() < lockDeadline) {
|
|
if (!fs.existsSync(lockPath)) break;
|
|
try {
|
|
const pid = parseInt(fs.readFileSync(lockPath, 'utf8'), 10);
|
|
if (!Number.isFinite(pid) || pid <= 0) break;
|
|
cp.execFileSync('kill', ['-0', String(pid)], { stdio: 'ignore' });
|
|
} catch {
|
|
break; // PID dead → safe to clean up
|
|
}
|
|
cp.execFileSync('sleep', ['0.05']);
|
|
}
|
|
fs.rmSync(tmpDir, { recursive: true, force: true, maxRetries: 8, retryDelay: 100 });
|
|
}
|
|
|
|
describe('#3347 hook — bail paths (no side effects)',
|
|
{ skip: isWindows ? 'POSIX-only: harness spawns bash + kill -0 + sleep; the hook itself is a bash script under test' : false },
|
|
() => {
|
|
test('non-Bash tool call exits 0 with no status file', (t) => {
|
|
const tmpDir = createTempGitRepo({
|
|
config: { graphify: { enabled: true, auto_update: true } },
|
|
});
|
|
t.after(() => cleanup(tmpDir));
|
|
const r = runHook(tmpDir, { tool_name: 'Edit', tool_input: { file_path: 'x' } });
|
|
assert.strictEqual(r.status, 0, 'hook must exit 0 on non-Bash tool');
|
|
assert.ok(
|
|
!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')),
|
|
'no status file should be created when bailing',
|
|
);
|
|
});
|
|
|
|
test('Bash but non-HEAD-advancing command exits 0 with no status file', (t) => {
|
|
const tmpDir = createTempGitRepo({
|
|
config: { graphify: { enabled: true, auto_update: true } },
|
|
});
|
|
t.after(() => cleanup(tmpDir));
|
|
const r = runHook(tmpDir, { tool_name: 'Bash', tool_input: { command: 'ls -la' } });
|
|
assert.strictEqual(r.status, 0);
|
|
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')));
|
|
});
|
|
|
|
test('git commit but graphify.enabled=false → no dispatch', (t) => {
|
|
const tmpDir = createTempGitRepo({
|
|
config: { graphify: { enabled: false, auto_update: true } },
|
|
});
|
|
t.after(() => cleanup(tmpDir));
|
|
const r = runHook(tmpDir, { tool_name: 'Bash', tool_input: { command: 'git commit -m x' } });
|
|
assert.strictEqual(r.status, 0);
|
|
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')));
|
|
});
|
|
|
|
test('git commit but graphify.auto_update=false → no dispatch (opt-in)', (t) => {
|
|
const tmpDir = createTempGitRepo({
|
|
config: { graphify: { enabled: true, auto_update: false } },
|
|
});
|
|
t.after(() => cleanup(tmpDir));
|
|
const r = runHook(tmpDir, { tool_name: 'Bash', tool_input: { command: 'git commit -m x' } });
|
|
assert.strictEqual(r.status, 0);
|
|
assert.ok(
|
|
!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')),
|
|
'opt-in default-off: auto_update=false must suppress dispatch',
|
|
);
|
|
});
|
|
|
|
test('CI=true → no dispatch even with both gates true', (t) => {
|
|
const tmpDir = createTempGitRepo({
|
|
config: { graphify: { enabled: true, auto_update: true } },
|
|
});
|
|
t.after(() => cleanup(tmpDir));
|
|
const mockBin = makeMockGraphifyBin(tmpDir);
|
|
const r = runHook(
|
|
tmpDir,
|
|
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
|
{ env: { CI: 'true' }, pathPrepend: mockBin },
|
|
);
|
|
assert.strictEqual(r.status, 0);
|
|
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')));
|
|
});
|
|
|
|
test('on non-default branch → no dispatch', (t) => {
|
|
const tmpDir = createTempGitRepo({
|
|
config: { graphify: { enabled: true, auto_update: true } },
|
|
});
|
|
t.after(() => cleanup(tmpDir));
|
|
cp.execFileSync('git', ['checkout', '-b', 'worktree-agent-abc'], {
|
|
cwd: tmpDir,
|
|
stdio: 'ignore',
|
|
});
|
|
const mockBin = makeMockGraphifyBin(tmpDir);
|
|
const r = runHook(
|
|
tmpDir,
|
|
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
|
{ pathPrepend: mockBin },
|
|
);
|
|
assert.strictEqual(r.status, 0);
|
|
assert.ok(
|
|
!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')),
|
|
'branch check must filter worktree-agent-* (non-default-branch) commits',
|
|
);
|
|
});
|
|
|
|
test('graphify binary not on PATH → silent exit 0', (t) => {
|
|
const tmpDir = createTempGitRepo({
|
|
config: { graphify: { enabled: true, auto_update: true } },
|
|
});
|
|
t.after(() => cleanup(tmpDir));
|
|
// Note: do NOT prepend mock bin; rely on real PATH not having graphify
|
|
const r = runHook(
|
|
tmpDir,
|
|
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
|
{ env: { PATH: '/usr/bin:/bin' } },
|
|
);
|
|
assert.strictEqual(r.status, 0, 'must not break commits when graphify missing');
|
|
});
|
|
});
|
|
|
|
describe('#3347 hook — dispatch path (all gates pass)',
|
|
{ skip: isWindows ? 'POSIX-only: harness spawns bash + kill -0 + sleep; the hook itself is a bash script under test' : false },
|
|
() => {
|
|
test('writes status file with status=running synchronously before returning', (t) => {
|
|
const tmpDir = createTempGitRepo({
|
|
config: { graphify: { enabled: true, auto_update: true } },
|
|
});
|
|
t.after(() => cleanup(tmpDir));
|
|
// Sleep 2s in mock so we can observe the running state before completion
|
|
const mockBin = makeMockGraphifyBin(tmpDir, { sleepMs: 2000 });
|
|
|
|
const r = runHook(
|
|
tmpDir,
|
|
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
|
{ pathPrepend: mockBin },
|
|
);
|
|
assert.strictEqual(r.status, 0, 'hook must return 0');
|
|
|
|
const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json');
|
|
assert.ok(fs.existsSync(statusPath), 'status file must be written synchronously');
|
|
const status = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
|
|
assert.strictEqual(status.status, 'running', 'initial status must be "running"');
|
|
assert.ok(/^[0-9a-f]{7,40}$/.test(status.head_at_build), 'head_at_build must be a commit sha');
|
|
});
|
|
|
|
test('completes to status=ok after detached graphify run succeeds', (t) => {
|
|
const tmpDir = createTempGitRepo({
|
|
config: { graphify: { enabled: true, auto_update: true } },
|
|
});
|
|
t.after(() => cleanup(tmpDir));
|
|
const mockBin = makeMockGraphifyBin(tmpDir, { exitCode: 0, sleepMs: 200 });
|
|
|
|
runHook(
|
|
tmpDir,
|
|
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
|
{ pathPrepend: mockBin },
|
|
);
|
|
|
|
// Wait up to 5s for the detached process to finish updating the status
|
|
const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json');
|
|
const deadline = Date.now() + 15000;
|
|
let status;
|
|
while (Date.now() < deadline) {
|
|
if (fs.existsSync(statusPath)) {
|
|
status = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
|
|
if (status.status === 'ok') break;
|
|
}
|
|
cp.execFileSync('sleep', ['0.1']);
|
|
}
|
|
assert.ok(status, 'status file must exist after dispatch');
|
|
assert.strictEqual(status.status, 'ok', 'mock graphify exit=0 → status ok');
|
|
assert.strictEqual(status.exit_code, 0);
|
|
assert.ok(typeof status.duration_ms === 'number' && status.duration_ms >= 0);
|
|
});
|
|
|
|
test('completes to status=failed when graphify exits non-zero', (t) => {
|
|
const tmpDir = createTempGitRepo({
|
|
config: { graphify: { enabled: true, auto_update: true } },
|
|
});
|
|
t.after(() => cleanup(tmpDir));
|
|
const mockBin = makeMockGraphifyBin(tmpDir, { exitCode: 1, sleepMs: 100 });
|
|
|
|
runHook(
|
|
tmpDir,
|
|
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
|
{ pathPrepend: mockBin },
|
|
);
|
|
|
|
const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json');
|
|
const deadline = Date.now() + 15000;
|
|
let status;
|
|
while (Date.now() < deadline) {
|
|
if (fs.existsSync(statusPath)) {
|
|
status = JSON.parse(fs.readFileSync(statusPath, 'utf8'));
|
|
if (status.status === 'failed') break;
|
|
}
|
|
cp.execFileSync('sleep', ['0.1']);
|
|
}
|
|
assert.ok(status, 'status file must exist after dispatch');
|
|
assert.strictEqual(status.status, 'failed', 'mock graphify exit=1 → status failed');
|
|
assert.strictEqual(status.exit_code, 1);
|
|
});
|
|
|
|
test('lock file with a live PID prevents concurrent dispatch', (t) => {
|
|
const tmpDir = createTempGitRepo({
|
|
config: { graphify: { enabled: true, auto_update: true } },
|
|
});
|
|
t.after(() => cleanup(tmpDir));
|
|
fs.mkdirSync(path.join(tmpDir, '.planning/graphs'), { recursive: true });
|
|
// Seed a live-PID lock pointing at our own process — kill -0 will succeed
|
|
fs.writeFileSync(path.join(tmpDir, '.planning/graphs/.rebuild.lock'), String(process.pid));
|
|
|
|
const mockBin = makeMockGraphifyBin(tmpDir);
|
|
const r = runHook(
|
|
tmpDir,
|
|
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
|
{ pathPrepend: mockBin },
|
|
);
|
|
assert.strictEqual(r.status, 0);
|
|
// Status file should NOT be written because a rebuild is in flight
|
|
assert.ok(
|
|
!fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')),
|
|
'live PID lock must suppress dispatch',
|
|
);
|
|
});
|
|
|
|
test('stale lock file (dead PID) is treated as absent', (t) => {
|
|
const tmpDir = createTempGitRepo({
|
|
config: { graphify: { enabled: true, auto_update: true } },
|
|
});
|
|
t.after(() => cleanup(tmpDir));
|
|
fs.mkdirSync(path.join(tmpDir, '.planning/graphs'), { recursive: true });
|
|
// PID 1 is init; kill -0 1 succeeds for root but fails for non-root.
|
|
// Use a very large PID number unlikely to exist (max pid = 4194304 on linux).
|
|
fs.writeFileSync(path.join(tmpDir, '.planning/graphs/.rebuild.lock'), '4194303');
|
|
|
|
const mockBin = makeMockGraphifyBin(tmpDir, { sleepMs: 500 });
|
|
const r = runHook(
|
|
tmpDir,
|
|
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
|
{ pathPrepend: mockBin },
|
|
);
|
|
assert.strictEqual(r.status, 0);
|
|
const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json');
|
|
assert.ok(fs.existsSync(statusPath), 'stale lock must not block dispatch');
|
|
});
|
|
|
|
test('respects git.base_branch config override (default branch != main)', (t) => {
|
|
const tmpDir = createTempGitRepo({
|
|
defaultBranch: 'trunk',
|
|
config: {
|
|
graphify: { enabled: true, auto_update: true },
|
|
git: { base_branch: 'trunk' },
|
|
},
|
|
});
|
|
t.after(() => cleanup(tmpDir));
|
|
const mockBin = makeMockGraphifyBin(tmpDir, { sleepMs: 100 });
|
|
const r = runHook(
|
|
tmpDir,
|
|
{ tool_name: 'Bash', tool_input: { command: 'git commit -m x' } },
|
|
{ pathPrepend: mockBin },
|
|
);
|
|
assert.strictEqual(r.status, 0);
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')),
|
|
'hook must honor git.base_branch when default branch is not main',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('#3347 hook — HEAD-advancing command matchers',
|
|
{ skip: isWindows ? 'POSIX-only: harness spawns bash to invoke the .sh hook under test' : false },
|
|
() => {
|
|
for (const cmd of [
|
|
'git commit -m fix',
|
|
'git merge feature',
|
|
'git pull --ff-only',
|
|
'git rebase --continue',
|
|
'git cherry-pick abc123',
|
|
]) {
|
|
test(`dispatches on: ${cmd}`, (t) => {
|
|
const tmpDir = createTempGitRepo({
|
|
config: { graphify: { enabled: true, auto_update: true } },
|
|
});
|
|
t.after(() => cleanup(tmpDir));
|
|
const mockBin = makeMockGraphifyBin(tmpDir, { sleepMs: 100 });
|
|
runHook(
|
|
tmpDir,
|
|
{ tool_name: 'Bash', tool_input: { command: cmd } },
|
|
{ pathPrepend: mockBin },
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning/graphs/.last-build-status.json')),
|
|
`must dispatch for HEAD-advancing op: ${cmd}`,
|
|
);
|
|
});
|
|
}
|
|
});
|