ktg-plugin-marketplace/plugins/voyage/tests/commands/trekcontinue.test.mjs
Kjell Tore Guttormsen 7a90d348ad feat(voyage)!: marketplace handoff — rename plugins/ultraplan-local to plugins/voyage [skip-docs]
Session 5 of voyage-rebrand (V6). Operator-authorized cross-plugin scope.

- git mv plugins/ultraplan-local plugins/voyage (rename detected, history preserved)
- .claude-plugin/marketplace.json: voyage entry replaces ultraplan-local
- CLAUDE.md: voyage row in plugin list, voyage in design-system consumer list
- README.md: bulk rename ultra*-local commands -> trek* commands; ultraplan-local refs -> voyage; type discriminators (type: trekbrief/trekreview); session-title pattern (voyage:<command>:<slug>); v4.0.0 release-note paragraph
- plugins/voyage/.claude-plugin/plugin.json: homepage/repository URLs point to monorepo voyage path
- plugins/voyage/verify.sh: drop URL whitelist exception (no longer needed)

Closes voyage-rebrand. bash plugins/voyage/verify.sh PASS 7/7. npm test 361/361.
2026-05-05 15:37:52 +02:00

351 lines
15 KiB
JavaScript

// tests/commands/trekcontinue.test.mjs
// Regression tests for /trekcontinue (commands/trekcontinue.md).
//
// Steps 2 + 4 of the v3.4.1 hot-fix plan
// (project 2026-05-04-v3.3.1-trekcontinue-fixes).
//
// Pattern mix:
// - Pattern B (tmp-dir, mkdtempSync + try/finally) — fixture builds
// - Pattern D (markdown structure) — assertions against command prose
// - Hook integration via runHook + pre-bash-executor (Pattern C, Step 4)
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { runHook } from '../helpers/hook-helper.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const COMMAND_FILE = join(ROOT, 'commands', 'trekcontinue.md');
const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs');
function readCommand() {
return readFileSync(COMMAND_FILE, 'utf8');
}
function extractPhase(commandText, phaseHeader) {
// phaseHeader e.g. "## Phase 0 ", "## Phase 1 ", "## Phase 2 "
const startIdx = commandText.indexOf(phaseHeader);
if (startIdx === -1) return '';
const rest = commandText.slice(startIdx);
// Stop at the next "## Phase " (or "## Hard rules" — also a top-level break)
const nextPhase = rest.search(/\n## (?:Phase |Hard )/);
if (nextPhase === -1) return rest;
return rest.slice(0, nextPhase);
}
function inProgressState(updatedAtIso) {
return {
schema_version: 1,
project: '.claude/projects/2026-05-04-fixture-a',
next_session_brief_path: '.claude/projects/2026-05-04-fixture-a/brief.md',
next_session_label: 'Session 2: in progress fixture',
status: 'in_progress',
updated_at: updatedAtIso,
};
}
function completedState(updatedAtIso) {
return {
schema_version: 1,
project: '.claude/projects/2026-05-04-fixture-b',
next_session_brief_path: '.claude/projects/2026-05-04-fixture-b/brief.md',
next_session_label: 'Session N: completed fixture',
status: 'completed',
updated_at: updatedAtIso,
};
}
// ---------------------------------------------------------------
// Step 2 — Bug 1 regression tests (SC-1, SC-2)
// ---------------------------------------------------------------
test('trekcontinue Bug 1 — Phase 1 documents auto-discovery sort by Date.parse(updated_at) DESC', () => {
// Fixture-builds two project dirs and verifies our chosen sort key
// matches what Phase 1 prose documents.
const root = mkdtempSync(join(tmpdir(), 'trekcontinue-disc-'));
try {
const projectsRoot = join(root, '.claude', 'projects');
mkdirSync(join(projectsRoot, '2026-05-04-fixture-a'), { recursive: true });
mkdirSync(join(projectsRoot, '2026-05-04-fixture-b'), { recursive: true });
const inProgress = inProgressState('2026-05-04T18:00:00.000Z');
const completed = completedState('2026-05-03T09:00:00.000Z');
writeFileSync(
join(projectsRoot, '2026-05-04-fixture-a', '.session-state.local.json'),
JSON.stringify(inProgress, null, 2),
);
writeFileSync(
join(projectsRoot, '2026-05-04-fixture-b', '.session-state.local.json'),
JSON.stringify(completed, null, 2),
);
// Numeric sort by Date.parse — newest first.
const candidates = [
{ ...completed, _path: 'b' },
{ ...inProgress, _path: 'a' },
].sort((x, y) => Date.parse(y.updated_at) - Date.parse(x.updated_at));
assert.equal(candidates[0]._path, 'a', 'newest in_progress fixture must win the sort');
const phase1 = extractPhase(readCommand(), '## Phase 1 ');
assert.match(
phase1,
/Date\.parse/,
'Phase 1 prose must document Date.parse-based sort (numeric, not lexicographic)',
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('trekcontinue Bug 1 — Phase 0 dispatches via parsed flags, not substring contains', () => {
const phase0 = extractPhase(readCommand(), '## Phase 0 ');
// Must NOT use the legacy "contains --help or -h" substring dispatch.
assert.doesNotMatch(
phase0,
/contains\s+`?--help`?\s+or\s+`?-h`?/i,
'Phase 0 must not dispatch via substring `contains` — use parsed flags / positional',
);
// Must reference parseArgs / flags['--help'] / positional[0] (parsed-arg dispatch).
const referencesParsedDispatch =
/flags\[\s*['"]--help['"]\s*\]/.test(phase0) ||
/positional\[\s*0\s*\]/.test(phase0);
assert.ok(
referencesParsedDispatch,
'Phase 0 must dispatch via parsed flags["--help"] or positional[0] === "-h"',
);
});
test('trekcontinue Bug 1 — Phase 1 documents empty-args path explicitly to auto-discovery', () => {
const phase1 = extractPhase(readCommand(), '## Phase 1 ');
// Some explicit text mentioning the empty / whitespace path so a future reader
// can't misread Phase 0 as "fall through to usage on empty".
assert.match(
phase1,
/\b(empty|whitespace)\b/i,
'Phase 1 must explicitly handle the empty-args case (auto-discovery)',
);
assert.match(
phase1,
/auto-discover/i,
'Phase 1 must reference auto-discovery as the empty-args fallback',
);
});
test('trekcontinue Bug 1 sub — Phase 1 emits SC-2 diagnostic for .md positional arg', () => {
const phase1 = extractPhase(readCommand(), '## Phase 1 ');
// SC-2 verbatim diagnostic strings.
assert.match(
phase1,
/expected.*<project-dir>/i,
'Phase 1 must mention "expected <project-dir>" in the .md-arg diagnostic',
);
assert.match(
phase1,
/did you mean to paste/i,
'Phase 1 must mention "did you mean to paste" in the .md-arg diagnostic',
);
// Detection condition must reference .md.
assert.match(
phase1,
/\.md\b/,
'Phase 1 must detect .md positional arg (case for SC-2)',
);
});
// ---------------------------------------------------------------
// Step 4 — Bug 2 regression tests (SC-3)
// ---------------------------------------------------------------
test('trekcontinue Bug 2 — pre-bash-executor ALLOWS resolved validator invocation', async () => {
// (d-1) Sanity-check that the planned Phase 2 Bash form (validator
// invocation with a concrete absolute path) is not blocked by the
// marketplace pre-bash-executor hook chain.
const cmd = "node lib/validators/session-state-validator.mjs --json /tmp/fixture-not-real/.session-state.local.json";
const { code } = await runHook(PRE_BASH, { tool_name: 'Bash', tool_input: { command: cmd } });
assert.strictEqual(code, 0, 'pre-bash-executor must not block resolved validator invocations');
});
// ---------------------------------------------------------------
// Step 8 — Bug 3 regression test (Phase 1.5 consistency wire-up)
// ---------------------------------------------------------------
test('trekcontinue Bug 3 — Phase 1.5 documents consistency check between Phase 1 and Phase 2', () => {
const cmd = readCommand();
// Phase 1.5 must exist literally in the prose between Phase 1 and Phase 2.
assert.match(cmd, /## Phase 1\.5 /, 'Phase 1.5 header must be present');
assert.match(cmd, /next-session-prompt-validator/, 'Phase 1.5 must invoke next-session-prompt-validator');
const phase15Idx = cmd.indexOf('## Phase 1.5 ');
const phase2Idx = cmd.indexOf('## Phase 2 ');
assert.ok(phase15Idx !== -1 && phase2Idx !== -1 && phase15Idx < phase2Idx,
'Phase 1.5 must appear before Phase 2');
});
test('trekcontinue Bug 3 (e) — CLI consistency mode flags producer mismatch in JSON output', () => {
const root = mkdtempSync(join(tmpdir(), 'trekcontinue-fm-'));
try {
const projectDir = join(root, '.claude', 'projects', '2026-05-04-fixture-c');
mkdirSync(projectDir, { recursive: true });
// State file (status: in_progress, updated_at = T-base)
const stateUpdatedAt = '2026-05-04T15:00:00.000Z';
writeFileSync(
join(projectDir, '.session-state.local.json'),
JSON.stringify({
schema_version: 1,
project: projectDir,
next_session_brief_path: join(projectDir, 'brief.md'),
next_session_label: 'Session 2',
status: 'in_progress',
updated_at: stateUpdatedAt,
}, null, 2),
);
// Project-dir prompt: produced_by trekexecute at T-1
const projectPrompt = join(projectDir, 'NEXT-SESSION-PROMPT.local.md');
writeFileSync(projectPrompt,
'---\nproduced_by: trekexecute\nproduced_at: 2026-05-04T15:30:00.000Z\n---\n\n# Session 2\n');
// Plugin-root prompt: produced_by graceful-handoff at T-0 (newer)
const pluginPrompt = join(root, 'NEXT-SESSION-PROMPT.local.md');
writeFileSync(pluginPrompt,
'---\nproduced_by: graceful-handoff\nproduced_at: 2026-05-04T15:31:00.000Z\n---\n\n# A2 master\n');
// Both fresh relative to state.updated_at → producer mismatch must hard-fail.
let exitCode = 0;
let stdout = '';
try {
stdout = execFileSync(process.execPath, [
join(ROOT, 'lib', 'validators', 'next-session-prompt-validator.mjs'),
'--json',
'--consistency',
projectPrompt,
pluginPrompt,
], { encoding: 'utf-8', cwd: ROOT });
} catch (e) {
exitCode = e.status;
stdout = e.stdout ? e.stdout.toString() : '';
}
assert.notEqual(exitCode, 0, 'consistency CLI must exit non-zero on producer mismatch');
const parsed = JSON.parse(stdout);
assert.equal(parsed.valid, false);
const mismatch = parsed.errors.find(e => e.code === 'NEXT_SESSION_PROMPT_PRODUCER_MISMATCH');
assert.ok(mismatch, 'must surface NEXT_SESSION_PROMPT_PRODUCER_MISMATCH error');
assert.match(mismatch.message, new RegExp(projectPrompt.replace(/[/\\]/g, '.')), 'error message must reference project-dir prompt path');
assert.match(mismatch.message, new RegExp(pluginPrompt.replace(/[/\\]/g, '.')), 'error message must reference plugin-root prompt path');
assert.match(mismatch.message, /produced_by/i, 'error message must mention produced_by');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('trekcontinue Bug 2 — Phase 2 contains no {state-file-path} or any {curly-template} placeholder', () => {
// (d-2) Pattern D structure test. The fix must eliminate the
// {state-file-path} placeholder and any other {anything} curly-brace
// template syntax from Phase 2 — substitution failures are the
// root cause of the path-guard hook crash.
const phase2 = extractPhase(readCommand(), '## Phase 2 ');
assert.equal(
phase2.includes('{state-file-path}'),
false,
'Phase 2 must not contain the {state-file-path} placeholder',
);
assert.doesNotMatch(
phase2,
/\{[a-z][a-z0-9-]*\}/,
'Phase 2 must not contain any {lowercase-template} curly-brace placeholder',
);
assert.match(
phase2,
/Read tool/,
'Phase 2 must document the deterministic Read tool flow',
);
});
// ---------------------------------------------------------------
// Step 10 — Bug 4 regression tests (Phase 0.5 wire-up + cleanup f-1/f-2/f-3)
// ---------------------------------------------------------------
test('trekcontinue Bug 4 — Phase 0.5 documents cleanup mode dispatch', () => {
const cmd = readCommand();
assert.match(cmd, /## Phase 0\.5 /, 'Phase 0.5 header must be present');
// Phase 0.5 must come BETWEEN Phase 0 and Phase 1.
const idx05 = cmd.indexOf('## Phase 0.5 ');
const idx1 = cmd.indexOf('## Phase 1 ');
assert.ok(idx05 !== -1 && idx1 !== -1 && idx05 < idx1,
'Phase 0.5 must appear before Phase 1');
// Must reference cleanupProject and parsed flags['--cleanup'].
const phase05 = extractPhase(cmd, '## Phase 0.5 ');
assert.match(phase05, /cleanupProject/, 'Phase 0.5 must invoke cleanupProject');
assert.match(phase05, /flags\['--cleanup'\]/, "Phase 0.5 must dispatch via flags['--cleanup']");
// Usage block must document both forms.
assert.match(cmd, /--cleanup --confirm/, 'usage must mention --cleanup --confirm');
});
test('trekcontinue Bug 4 (f-1) dry-run lists candidates without deleting', async () => {
const { cleanupProject } = await import('../../lib/util/cleanup.mjs');
const root = mkdtempSync(join(tmpdir(), 'trekcontinue-cleanup-'));
try {
const dir = join(root, 'project-completed');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, '.session-state.local.json'), JSON.stringify({
schema_version: 1,
project: dir,
next_session_brief_path: join(dir, 'brief.md'),
next_session_label: 'Done',
status: 'completed',
updated_at: '2026-05-04T16:00:00.000Z',
}, null, 2));
writeFileSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'),
'---\nproduced_by: trekexecute\nproduced_at: 2026-05-04T16:00:00.000Z\n---\n\n# Done\n');
const r = cleanupProject(dir, { dryRun: true });
assert.equal(r.valid, true, JSON.stringify(r.errors));
assert.equal(r.parsed.wouldDelete.length, 2);
assert.equal(readFileSync(join(dir, '.session-state.local.json'), 'utf8').length > 0, true);
assert.equal(readFileSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'), 'utf8').length > 0, true);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('trekcontinue Bug 4 (f-2) confirm deletes and (f-3) idempotent re-run handles already-clean dir', async () => {
const { cleanupProject } = await import('../../lib/util/cleanup.mjs');
const { existsSync } = await import('node:fs');
const root = mkdtempSync(join(tmpdir(), 'trekcontinue-cleanup-'));
try {
const dir = join(root, 'project-completed');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, '.session-state.local.json'), JSON.stringify({
schema_version: 1,
project: dir,
next_session_brief_path: join(dir, 'brief.md'),
next_session_label: 'Done',
status: 'completed',
updated_at: '2026-05-04T16:00:00.000Z',
}, null, 2));
writeFileSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'),
'---\nproduced_by: trekexecute\nproduced_at: 2026-05-04T16:00:00.000Z\n---\n\n# Done\n');
// f-2: confirm deletes
const r2 = cleanupProject(dir, { dryRun: false, confirm: true });
assert.equal(r2.valid, true, JSON.stringify(r2.errors));
assert.equal(r2.parsed.deleted.length, 2);
assert.equal(existsSync(join(dir, '.session-state.local.json')), false);
assert.equal(existsSync(join(dir, 'NEXT-SESSION-PROMPT.local.md')), false);
// f-3: idempotent re-run on a fully-cleaned dir reports CLEANUP_NO_STATE_FILE
// (no state file → nothing to clean) — a deterministic terminal signal,
// not a crash. Operators can ignore it.
const r3 = cleanupProject(dir, { dryRun: false, confirm: true });
assert.equal(r3.valid, false);
assert.ok(r3.errors.find(e => e.code === 'CLEANUP_NO_STATE_FILE'));
} finally {
rmSync(root, { recursive: true, force: true });
}
});