voyage/tests/hooks/post-compact-flush.test.mjs
Kjell Tore Guttormsen d8527976ef
fix(tests): the suite no longer hands the session id to the lock CLI
npm test now preloads tests/helpers/no-session-id.mjs, which deletes CLAUDE_CODE_SESSION_ID before any test file loads, and the 18 files that spread process.env into a child use childEnv() instead (23 sites). A live session's stop guard is no longer pointed at a temp-repo lock (S133).

engines stays >=18: --import exists from Node 18.18/20.6, and the quoted test glob already needs Node 21 (criteria-runner NODE_GLOB_MAJOR), so the real floor does not move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 14:09:14 +02:00

126 lines
5.1 KiB
JavaScript

// tests/hooks/post-compact-flush.test.mjs
// Step 13 (plan-v2) — PostCompact rehydrate hook test.
//
// Hook is read-only: discovers <cwd>/.claude/projects/*/.session-state.local.json,
// validates it, emits additionalContext for the post-compact assistant turn.
// Must always exit 0 — never blocks compaction.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFile } from 'node:child_process';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const HOOK = join(ROOT, 'hooks', 'scripts', 'post-compact-flush.mjs');
function runHookIn(cwd, input = {}) {
return new Promise((resolve) => {
const child = execFile(
'node',
[HOOK],
{ timeout: 5000, cwd, env: childEnv() },
(err, stdout, stderr) => {
resolve({
code: child.exitCode ?? 0,
stdout: stdout || '',
stderr: stderr || '',
});
},
);
child.stdin.end(typeof input === 'string' ? input : JSON.stringify(input));
});
}
function makeFixture() {
const dir = mkdtempSync(join(tmpdir(), 'post-compact-flush-'));
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
}
test('post-compact-flush: exits 0 with empty output when no .claude/projects/ exists', async () => {
const { dir, cleanup } = makeFixture();
try {
const { code, stdout } = await runHookIn(dir);
assert.strictEqual(code, 0, 'hook must always exit 0 — never blocks compaction');
assert.strictEqual(stdout, '{}', 'no state file → emit empty payload (silent no-op)');
} finally {
cleanup();
}
});
test('post-compact-flush: exits 0 with empty output when state file is malformed', async () => {
const { dir, cleanup } = makeFixture();
try {
mkdirSync(join(dir, '.claude/projects/2026-05-04-test'), { recursive: true });
writeFileSync(
join(dir, '.claude/projects/2026-05-04-test/.session-state.local.json'),
'{not valid json',
);
const { code, stdout } = await runHookIn(dir);
assert.strictEqual(code, 0, 'malformed state file → silent fail, exit 0');
assert.strictEqual(stdout, '{}', 'no additionalContext on malformed input');
} finally {
cleanup();
}
});
test('post-compact-flush: emits additionalContext with project + next_session_label + status from valid state file', async () => {
const { dir, cleanup } = makeFixture();
try {
mkdirSync(join(dir, '.claude/projects/2026-05-04-test'), { recursive: true });
const state = {
schema_version: 1,
project: '.claude/projects/2026-05-04-test',
next_session_brief_path: '.claude/projects/2026-05-04-test/brief.md',
next_session_label: 'Session 9: Wave 2 manual delivery',
status: 'in_progress',
updated_at: '2026-05-04T07:00:00.000Z',
};
writeFileSync(
join(dir, '.claude/projects/2026-05-04-test/.session-state.local.json'),
JSON.stringify(state, null, 2),
);
const { code, stdout } = await runHookIn(dir);
assert.strictEqual(code, 0, 'valid state → exit 0');
const parsed = JSON.parse(stdout);
assert.ok(parsed.additionalContext, 'must emit additionalContext for the next turn');
assert.match(parsed.additionalContext, /\.claude\/projects\/2026-05-04-test/, 'context includes project path');
assert.match(parsed.additionalContext, /Session 9: Wave 2 manual delivery/, 'context includes next_session_label');
assert.match(parsed.additionalContext, /status: in_progress/, 'context includes status');
} finally {
cleanup();
}
});
test('post-compact-flush: picks the most-recently-modified state file when multiple projects exist', async () => {
const { dir, cleanup } = makeFixture();
try {
mkdirSync(join(dir, '.claude/projects/older'), { recursive: true });
mkdirSync(join(dir, '.claude/projects/newer'), { recursive: true });
const baseState = (label) => ({
schema_version: 1,
project: `.claude/projects/${label}`,
next_session_brief_path: `.claude/projects/${label}/brief.md`,
next_session_label: `Label-${label}`,
status: 'in_progress',
updated_at: '2026-05-04T07:00:00.000Z',
});
const olderPath = join(dir, '.claude/projects/older/.session-state.local.json');
const newerPath = join(dir, '.claude/projects/newer/.session-state.local.json');
writeFileSync(olderPath, JSON.stringify(baseState('older')));
// Wait one tick to ensure mtime ordering is observable on all filesystems
await new Promise((r) => setTimeout(r, 50));
writeFileSync(newerPath, JSON.stringify(baseState('newer')));
const { code, stdout } = await runHookIn(dir);
assert.strictEqual(code, 0);
const parsed = JSON.parse(stdout);
assert.match(parsed.additionalContext, /Label-newer/, 'auto-discovery should pick the newest state file');
assert.doesNotMatch(parsed.additionalContext, /Label-older/, 'older state file must not be selected');
} finally {
cleanup();
}
});