voyage/tests/hooks/merge-evidence-many.test.mjs
2026-11-04 10:54:56 +01:00

182 lines
9.2 KiB
JavaScript

// tests/hooks/merge-evidence-many.test.mjs
// [MMG] A Bash command with several merges into main asks one question, and
// that question cannot show each run's evidence: the guard's reader
// (hooks/lib/merge-evidence.mjs) used to show only the first merge's Tested by
// line, so `git merge a && git merge b` read as if b were tested too.
// evidenceForMerges(merges) says, for two or more merges, that the run
// evidence is not shown and why; one merge keeps the two lines as before
// (byte-identical), zero merges give ''.
// `keep:` cases hold at the base already and stay untagged; the one-merge case
// goes through the hook process only, so it never calls the new export.
// The reader loads through `import * as m`, so a missing export is a failed
// assertion at the red base, never a crash. Every child gets childEnv(); refs
// are made with `git update-ref`, never `git tag`.
import { after, test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { appendFileSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as m from '../../hooks/lib/merge-evidence.mjs';
import { runHookWithEnv } from '../helpers/hook-helper.mjs';
import { childEnv } from '../helpers/child-env.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
const UNDONE_TWO_WAY = 'Undone by: `git revert -m 1 <merge>` (two-way).';
const NOT_SHOWN = (n) => `Tested by: not shown (this command merges ${n} branches into main; `
+ 'merge one at a time to see each run\'s evidence).\nUndone by: not shown.';
const made = [];
after(() => { for (const b of made) rmSync(b, { recursive: true, force: true }); });
function g(cwd, ...args) {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: childEnv() });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
}
/**
* A throwaway repo on `main` (README) with two branches one commit ahead each,
* `feat` (a.mjs) and `other` (b.mjs), checked out on main. No hooks, no signing.
* (Copied from tests/hooks/merge-evidence.test.mjs `mevRepo`, a second branch added.)
*/
function manyRepo() {
const base = realpathSync(mkdtempSync(join(tmpdir(), 'merge-evidence-many-')));
made.push(base);
const repo = join(base, 'repo');
mkdirSync(repo, { recursive: true });
mkdirSync(join(base, 'hooks-none'));
g(repo, 'init', '-q', '-b', 'main');
appendFileSync(join(repo, '.git', 'config'), '[user]\n\tname = Test\n\temail = test@example.invalid\n'
+ `[commit]\n\tgpgsign = false\n[core]\n\thooksPath = "${join(base, 'hooks-none')}"\n`);
writeFileSync(join(repo, 'README.md'), '# t\n');
g(repo, 'add', 'README.md');
g(repo, 'commit', '-q', '-m', 'init');
g(repo, 'checkout', '-q', '-b', 'feat');
writeFileSync(join(repo, 'a.mjs'), 'export const a = 1;\n');
g(repo, 'add', 'a.mjs');
g(repo, 'commit', '-q', '-m', 'feat: the work');
const featSha = g(repo, 'rev-parse', 'HEAD');
g(repo, 'checkout', '-q', 'main');
g(repo, 'checkout', '-q', '-b', 'other');
writeFileSync(join(repo, 'b.mjs'), 'export const b = 1;\n');
g(repo, 'add', 'b.mjs');
g(repo, 'commit', '-q', '-m', 'feat: the other work');
const otherSha = g(repo, 'rev-parse', 'HEAD');
g(repo, 'checkout', '-q', 'main');
const evDir = join(repo, '.git', 'voyage', 'merge-evidence');
return {
base, repo, featSha, otherSha,
/** Write an evidence file by hand (an object as JSON). */
put: (body, sha) => {
mkdirSync(evDir, { recursive: true });
writeFileSync(join(evDir, `${sha}.json`), JSON.stringify(body, null, 2));
},
};
}
/** A complete evidence object for `sha` on `branch` (copied from tests/hooks/merge-evidence.test.mjs `evidence`). */
function evidence(sha, branch) {
return {
v: 1, branch, branch_head_sha: sha, written_at: '2026-11-02T10:00:00.000Z',
end_to_end: { command: '/bin/bash tests/e2e.sh', exit: 0 },
verification: { passed: 5, total: 5 },
review: { verdict: 'ALLOW', sha },
door: { type: 'two-way', text: 'a revert restores the old gate' },
};
}
const tested = (sha) =>
`Tested by: End-to-end \`/bin/bash tests/e2e.sh\` exit 0; Verification 5/5; review ALLOW at ${sha.slice(0, 8)}.`;
/** The repo with evidence for both branch heads. */
function withEvidence() {
const fx = manyRepo();
fx.put(evidence(fx.featSha, 'feat'), fx.featSha);
fx.put(evidence(fx.otherSha, 'other'), fx.otherSha);
return fx;
}
/** The guard on a Bash call, run in `cwd` (copied from tests/hooks/merge-evidence.test.mjs `ask`). */
async function ask(cwd, command) {
const input = { hook_event_name: 'PreToolUse', tool_name: 'Bash', cwd, tool_input: { command } };
const r = await runHookWithEnv(PRE_BASH, input, {});
let out = null;
try { out = JSON.parse(String(r.stdout).trim()); } catch { out = null; }
return {
...r,
decision: out?.hookSpecificOutput?.permissionDecision ?? null,
reason: String(out?.hookSpecificOutput?.permissionDecisionReason ?? ''),
};
}
const TWO = 'git merge --no-ff feat && git merge --no-ff other';
// ---- several merges ---------------------------------------------------------------
test('[MMG] git merge a && git merge b on main says the run evidence is not shown for 2 branches', async () => {
const fx = withEvidence();
const r = await ask(fx.repo, TWO);
assert.equal(r.decision, 'ask', `Control: the merges into main are asked about: ${r.stdout}${r.stderr}`);
assert.ok(r.reason.startsWith('Voyage asks before this command (P12 approval guard):'), `Control: the guard's own question:\n${r.reason}`);
assert.ok(r.reason.includes(NOT_SHOWN(2)), `the not-shown line for 2 branches:\n${r.reason}`);
assert.ok(!r.reason.includes(tested(fx.featSha)), `not the first merge's Tested by line:\n${r.reason}`);
assert.ok(!r.reason.includes(tested(fx.otherSha)), `not the second merge's Tested by line:\n${r.reason}`);
assert.ok(!r.reason.includes('Tested by: End-to-end'), `no Tested by: End-to-end line at all:\n${r.reason}`);
});
test('[MMG] evidenceForMerges of two merges never holds a Tested by: End-to-end line', () => {
assert.equal(typeof m.evidenceForMerges, 'function', 'hooks/lib/merge-evidence.mjs exports evidenceForMerges');
const fx = withEvidence();
const feat = { subject: fx.repo, merge: { args: ['--no-ff', 'feat'] } };
const other = { subject: fx.repo, merge: { args: ['--no-ff', 'other'] } };
// Control: each alone has its own evidence.
assert.equal(m.evidenceLines(fx.repo, ['--no-ff', 'feat']), `${tested(fx.featSha)}\n${UNDONE_TWO_WAY}`, 'Control: feat alone');
assert.equal(m.evidenceLines(fx.repo, ['--no-ff', 'other']), `${tested(fx.otherSha)}\n${UNDONE_TWO_WAY}`, 'Control: other alone');
const two = m.evidenceForMerges([feat, other]);
assert.equal(two, NOT_SHOWN(2));
assert.ok(!two.includes('Tested by: End-to-end'), two);
const three = m.evidenceForMerges([feat, other, feat]);
assert.equal(three, NOT_SHOWN(3), 'the count is the number of merges');
assert.ok(!three.includes('Tested by: End-to-end'), three);
});
test('[MMG] evidenceForMerges: zero merges give \'\', one gives its evidence lines, a bad input never throws', () => {
assert.equal(typeof m.evidenceForMerges, 'function', 'hooks/lib/merge-evidence.mjs exports evidenceForMerges');
const fx = withEvidence();
assert.equal(m.evidenceForMerges([]), '');
assert.equal(m.evidenceForMerges([{ subject: fx.repo, merge: { args: ['--no-ff', 'feat'] } }]),
`${tested(fx.featSha)}\n${UNDONE_TWO_WAY}`, 'one merge: its two lines, as evidenceLines gives them');
assert.equal(m.evidenceForMerges([{ subject: fx.repo }]), 'Tested by: no run evidence.\nUndone by: no run evidence.',
'one merge without args: no run evidence');
for (const bad of [null, undefined, 'x', 42, [null], [null, null]]) {
let out;
assert.doesNotThrow(() => { out = m.evidenceForMerges(bad); }, `never throws on ${JSON.stringify(bad)}`);
assert.equal(typeof out, 'string', `a string for ${JSON.stringify(bad)}`);
assert.ok(!out.includes('Tested by: End-to-end'), `no green line for ${JSON.stringify(bad)}: ${out}`);
}
});
// ---- keep ----------------------------------------------------------------------
test('keep: the decision stays ask with several merges', async () => {
const fx = withEvidence();
for (const command of [TWO, 'git merge --no-ff feat; git merge --no-ff other', 'git merge feat && git merge other && git merge feat']) {
const r = await ask(fx.repo, command);
assert.equal(r.code, 0, `${command}: ${r.stdout}${r.stderr}`);
assert.equal(r.decision, 'ask', `${command}: ${r.stdout}${r.stderr}`);
}
});
test('keep: one merge\'s question holds the same two lines as before', async () => {
const fx = withEvidence();
const r = await ask(fx.repo, 'git merge --no-ff feat');
assert.equal(r.decision, 'ask', `${r.stdout}${r.stderr}`);
assert.ok(r.reason.endsWith(` ${tested(fx.featSha)}\n${UNDONE_TWO_WAY}`), `the two lines end the reason, after one space:\n${r.reason}`);
assert.equal(r.reason.split('Tested by:').length, 2, `one Tested by line:\n${r.reason}`);
assert.ok(!r.reason.includes('not shown'), r.reason);
});