// 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 /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//voyage//, // 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 }); } });