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>
88 lines
4 KiB
JavaScript
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 });
|
|
}
|
|
});
|