voyage/tests/hooks/approval-log.test.mjs
Kjell Tore Guttormsen 33b58f460a
fix: the fix round before 6.3.0 (order 10)
1. A step of only tests can never be proven red. One reading,
   stepTestsOnly (lib/parsers/plan-schema.mjs, test paths from the new
   lib/util/test-path.mjs): the plan validator warns PLAN_STEP_TESTS_ONLY,
   red-proof --plan/--step adds tests_only to a not-red-at-base, and
   /trekexecute and the step helper send such a step straight up to the
   plan instead of into fix rounds that cannot succeed. /trekplan, the plan
   template and plan-critic (dimension 8, still 12) say the test belongs in
   the step that builds the behaviour.
2. Timing tests under load: the hook helper's kill limit is 30 s and per
   call overridable; SC #14 passes on the fastest of up to three runs
   against the same 800 ms limit (tests/helpers/timing.mjs).
3. The example plan's On failure lines open with a K11 word.
4. /trekreview saves the reviewer's whole answer for the findings check and
   names FINDING_PROSE_LINE_OUT_OF_RANGE (error: re-ask; warning: report).
5. The criteria runner's commands run without NODE_TEST_CONTEXT
   (commandEnv, moved to lib/util/command-env.mjs).
6. The resolver returns effort only when the brief's phase signal sets one;
   the four commands said it always did.

Also: the K2 hook tests used fixed session ids in a per-user temp dir, so
two suites running at once took each other's approval records; the ids are
now unique per run.

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

139 lines
6.1 KiB
JavaScript

// tests/hooks/approval-log.test.mjs
// P12 + P3 — an approval a person gave through Claude Code's prompt is written
// to the approval log (contract K2, lib/util/approval-log.mjs, owned by wave-1
// order 2). The log CLI is a STUB here until order 2 is merged: the stub
// records its argv. When the CLI is missing, the guard says "not logged" —
// never green, never silent.
//
// Operator decision 06.10: merge/push to main is logged as `merge`, tag and
// publish as `release`; push of other branches and destructive git are asked
// about but not logged. P12 approvals carry an expiry (`--expires`).
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
import { scriptsFor, pluginCopy, makeRepo, gitIn, tmp, readStubLog, parseJsonOut } from './guard-helpers.mjs';
const K2 = 'lib/util/approval-log.mjs';
const STUBBED = pluginCopy({ [K2]: 'stub' });
const MISSING = pluginCopy({ [K2]: 'missing' });
const hookIn = (root) => join(root, 'hooks', 'scripts', 'post-approval-log.mjs');
const REPO = makeRepo('main');
gitIn(REPO, 'checkout', '-q', '-b', 'feat');
const sha = (s) => createHash('sha256').update(s).digest('hex');
let callNo = 0;
// Approval records live in one temp dir per user (hooks/lib/approval-record.mjs),
// keyed by session_id + tool_use_id, so two suites running at once (two
// worktrees) took each other's records with fixed ids. Unique per run.
const RUN = `${process.pid}-${Date.now()}`;
// A Bash call goes through the same two hooks as in a session: the guard asks
// (and records what it asked), the call runs, then the approval is logged.
async function post(root, input, env = {}) {
const logFile = join(tmp(), 'stub.jsonl');
const ids = { session_id: `sess-log-${RUN}`, tool_use_id: `toolu_log_${(callNo += 1)}` };
if (input.tool_name === 'Bash') {
await runHookWithEnv(join(root, 'hooks', 'scripts', 'pre-bash-executor.mjs'),
{ hook_event_name: 'PreToolUse', cwd: REPO, ...ids, tool_name: 'Bash', tool_input: input.tool_input }, env);
}
const r = await runHookWithEnv(hookIn(root), { hook_event_name: 'PostToolUse', cwd: REPO, ...ids, ...input }, { STUB_LOG: logFile, ...env });
return { ...r, calls: readStubLog(logFile), out: parseJsonOut(r.stdout) };
}
const bash = (command) => ({ tool_name: 'Bash', tool_input: { command }, tool_response: { stdout: '', stderr: '' } });
const VOYAGE_PLAN = '---\nplan_version: 1.7\n---\n\n# Plan\n\n### Step 1: x\n';
const OTHER_PLAN = '# Refactor auth\n\n1. Extract the helper\n';
const exitPlan = (plan, filePath = '/tmp/plans/p.md') => ({
tool_name: 'ExitPlanMode',
tool_input: { plan, planFilePath: filePath },
tool_response: { plan, filePath },
});
function arg(call, flag) {
const i = call.argv.indexOf(flag);
return i === -1 ? undefined : call.argv[i + 1];
}
test('K2: push to main is logged as merge, with subject, sha256 and an expiry', async () => {
const r = await post(STUBBED, bash('git push origin main'));
assert.equal(r.code, 0, r.stderr);
assert.equal(r.calls.length, 1, `one append expected; got ${JSON.stringify(r.calls)}`);
const c = r.calls[0];
assert.equal(c.argv[0], 'append');
assert.equal(arg(c, '--kind'), 'merge');
assert.ok(arg(c, '--subject'), 'subject (a path) is given');
assert.match(arg(c, '--sha256'), /^[0-9a-f]{64}$/);
const exp = Date.parse(arg(c, '--expires'));
assert.ok(Number.isFinite(exp) && exp > Date.now(), 'expires is a future ISO time');
assert.match(r.out.hookSpecificOutput.additionalContext, /approval logged/i);
});
test('K2: a tag is logged as release', async () => {
const r = await post(STUBBED, bash('git tag v9.9.9'));
assert.equal(r.calls.length, 1);
assert.equal(arg(r.calls[0], '--kind'), 'release');
});
test('K2: push of a feature branch is asked about but NOT logged', async () => {
const r = await post(STUBBED, bash('git push origin feat'));
assert.equal(r.code, 0);
assert.equal(r.calls.length, 0);
});
test('K2: destructive git is asked about but NOT logged', async () => {
const r = await post(STUBBED, bash('git reset --hard HEAD~1'));
assert.equal(r.code, 0, r.stderr);
assert.equal(r.calls.length, 0);
});
test('K2: an ordinary command logs nothing and says nothing', async () => {
const r = await post(STUBBED, bash('npm test'));
assert.equal(r.code, 0, r.stderr);
assert.equal(r.calls.length, 0);
assert.equal(r.out, null);
});
test('K2 missing: the approval is reported "not logged", never green', async () => {
const r = await post(MISSING, bash('git push origin main'));
assert.equal(r.code, 0);
assert.match(r.out.hookSpecificOutput.additionalContext, /not logged/i);
});
test('K2 refuses (exit 1): reported "not logged"', async () => {
const r = await post(STUBBED, bash('git push origin main'), { STUB_EXIT: '1' });
assert.match(r.out.hookSpecificOutput.additionalContext, /not logged/i);
});
test('K2 could not run (exit 2): reported "not logged"', async () => {
const r = await post(STUBBED, bash('git push origin main'), { STUB_EXIT: '2' });
assert.match(r.out.hookSpecificOutput.additionalContext, /not logged/i);
});
test('P3: an approved Voyage plan is logged as plan, bound to the sha256 of the approved text', async () => {
const r = await post(STUBBED, exitPlan(VOYAGE_PLAN, '/tmp/plans/voyage-plan.md'));
assert.equal(r.calls.length, 1, JSON.stringify(r.calls));
const c = r.calls[0];
assert.equal(arg(c, '--kind'), 'plan');
assert.equal(arg(c, '--subject'), '/tmp/plans/voyage-plan.md');
assert.equal(arg(c, '--sha256'), sha(VOYAGE_PLAN));
});
test('P3: an approved plan without plan_version is not Voyage\'s and is not logged', async () => {
const r = await post(STUBBED, exitPlan(OTHER_PLAN));
assert.equal(r.code, 0, r.stderr);
assert.equal(r.calls.length, 0);
assert.equal(r.out, null);
});
test('wiring: hooks.json runs post-approval-log on PostToolUse for Bash and ExitPlanMode', () => {
for (const tool of ['Bash', 'ExitPlanMode']) {
const s = scriptsFor('PostToolUse', tool);
assert.ok(s.some((p) => p.endsWith('post-approval-log.mjs')), `${tool}: ${JSON.stringify(s)}`);
}
});