voyage/tests/lib/plugin-root-resolution.test.mjs
Kjell Tore Guttormsen 93e51615f3
test(commands): every VOYAGE_ROOT block must resolve the installed plugin root (red)
The four blocks in trekexecute (x2), trekresearch and trekreview read
${CLAUDE_PLUGIN_ROOT:-}, which Claude Code never substitutes (it replaces
the literal ${CLAUDE_PLUGIN_ROOT}), and the fallback returns the
version-less cache/<marketplace>/voyage directory, so the guard after it
exits 2 every time. Red on both conditions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:57:58 +02:00

88 lines
4 KiB
JavaScript

// tests/lib/plugin-root-resolution.test.mjs
// Every `VOYAGE_ROOT=` block in commands/*.md must resolve the installed plugin
// root, because the node helper that follows it is guarded by
// `[ -f "$VOYAGE_ROOT/lib/..." ] || exit 2`. A block that never resolves turns
// the whole phase into a permanent exit 2.
//
// Two conditions, both run through bash exactly as the block is written:
// substituted Claude Code replaces the LITERAL `${CLAUDE_PLUGIN_ROOT}` in a
// plugin command's text (measured in recorded sessions: commands
// ran `node <cache>/voyage/5.11.1/lib/...`). The Bash tool's env
// does not carry the variable. A `${CLAUDE_PLUGIN_ROOT:-}` form is
// not that literal, so it is never replaced.
// unsubstituted the text arrived as written; the fallback must find the newest
// installed version under cache/<marketplace>/voyage/<version>/,
// not the version-less parent directory, and by version number,
// not by name or mtime (measured: mtime put 5.11.0 above 5.11.1).
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, utimesSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const LITERAL = '${CLAUDE_PLUGIN_ROOT}';
// Each block runs from its `VOYAGE_ROOT=` line through the first `esac`.
function resolutionBlocks() {
const blocks = [];
for (const f of readdirSync(join(ROOT, 'commands')).filter(n => n.endsWith('.md')).sort()) {
const lines = readFileSync(join(ROOT, 'commands', f), 'utf-8').split('\n');
lines.forEach((line, i) => {
if (!line.startsWith('VOYAGE_ROOT=')) return;
const end = lines.findIndex((l, j) => j > i && l.trim() === 'esac');
assert.ok(end > i, `commands/${f}:${i + 1}: VOYAGE_ROOT block has no esac`);
blocks.push({ where: `commands/${f}:${i + 1}`, text: lines.slice(i, end + 1).join('\n') });
});
}
return blocks;
}
function run(blockText, home) {
const env = { PATH: process.env.PATH, HOME: home };
return execFileSync('bash', ['-c', `${blockText}\nprintf '%s' "$VOYAGE_ROOT"`], { env, encoding: 'utf-8' });
}
test('commands carry exactly the four known VOYAGE_ROOT blocks', () => {
assert.deepEqual(resolutionBlocks().map(b => b.where.split(':')[0]), [
'commands/trekexecute.md', 'commands/trekexecute.md',
'commands/trekresearch.md', 'commands/trekreview.md',
]);
});
test('substituted: every block takes the root Claude Code wrote into the text', () => {
const home = mkdtempSync(join(tmpdir(), 'vr-home-'));
const root = '/opt/fake-cache/mkt/voyage/9.9.9';
try {
for (const b of resolutionBlocks()) {
assert.ok(b.text.includes(LITERAL), `${b.where}: no literal ${LITERAL} for Claude Code to substitute`);
assert.equal(run(b.text.split(LITERAL).join(root), home), root, b.where);
}
} finally {
rmSync(home, { recursive: true, force: true });
}
});
test('unsubstituted: every block falls back to the highest installed version directory', () => {
const home = mkdtempSync(join(tmpdir(), 'vr-home-'));
const base = join(home, '.claude', 'plugins', 'cache', 'mkt', 'voyage');
try {
// Created highest first and given the OLDEST mtime, so neither name order
// ("5.9.0" > "5.11.1") nor mtime order picks the right one by accident.
const versions = ['5.11.1', '5.9.0', '5.10.1'];
versions.forEach((v, i) => {
mkdirSync(join(base, v, 'lib'), { recursive: true });
const t = new Date(Date.UTC(2026, 0, 1 + i));
utimesSync(join(base, v), t, t);
});
for (const b of resolutionBlocks()) {
assert.equal(run(b.text, home), join(base, '5.11.1'), b.where);
}
} finally {
rmSync(home, { recursive: true, force: true });
}
});