engine-compare imports the coordinator's title limit and trim instead of a copy whose reason went with Arm B; the autonomy gate exports one parser and its test runs in-process with two CLI runs; one import scanner in lib-imports; the fenced-section walker shared from tests/helpers/md-section.mjs instead of six copies; plan-gate hash in-process; living-paths scans once; routing drops the orchestrator filter whose files are gone; small leftovers removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
359 lines
16 KiB
JavaScript
359 lines
16 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 { spawnSync } from 'node:child_process';
|
|
import { runHook } from '../helpers/hook-helper.mjs';
|
|
import { sectionText } from '../helpers/md-section.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');
|
|
|
|
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 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 });
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------
|
|
// Phase 0 runs the arg-parser CLI and reads its JSON (no parseArgs($ARGUMENTS)
|
|
// function call, which no Bash line runs).
|
|
// ---------------------------------------------------------------
|
|
|
|
/** Whitespace (line breaks too) collapsed to one space. */
|
|
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
|
|
|
/** The single line in `text` that runs `lib/<mod>` (and holds `verb`, when given). */
|
|
function libLine(text, mod, verb = '') {
|
|
const esc = mod.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
|
|
const re = new RegExp(`^\\s*node \\$\\{CLAUDE_PLUGIN_ROOT\\}/lib/${esc}\\b`);
|
|
const hits = text.split('\n').filter((l) => re.test(l) && l.includes(verb));
|
|
assert.equal(hits.length, 1, `expected exactly one \`${mod}${verb ? ` ${verb}` : ''}\` line; found ${hits.length}`);
|
|
return hits[0].trim();
|
|
}
|
|
|
|
/** The runtime ```bash block of `text` that holds `cmd`, or undefined. */
|
|
function bashBlockHolding(text, cmd) {
|
|
return text.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0]).find((b) => b.includes(cmd));
|
|
}
|
|
|
|
/** A Phase 0 parser block run through bash with the plugin root filled in and `args` in place of $ARGUMENTS. */
|
|
function runParser(cmd, args) {
|
|
const filled = cmd.split('$ARGUMENTS').join(args).split('${CLAUDE_PLUGIN_ROOT}').join(ROOT);
|
|
return spawnSync('bash', ['-c', filled], { cwd: ROOT, encoding: 'utf8' });
|
|
}
|
|
|
|
const PHASE0_PARSER_LINE = "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekcontinue --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'";
|
|
|
|
test('trekcontinue Phase 0 has no parseArgs($ARGUMENTS call, and its parser line run with --help sets the help flag', () => {
|
|
const phase0 = sectionText(readCommand(), '## Phase 0 — `--help` handling');
|
|
assert.doesNotMatch(phase0, /parseArgs\(\$ARGUMENTS/, 'Phase 0 still calls parseArgs($ARGUMENTS, ...) as a function');
|
|
const cmd = libLine(phase0, 'parsers/arg-parser.mjs');
|
|
assert.equal(cmd, PHASE0_PARSER_LINE, 'the Phase 0 parser line is not the trekcontinue parser call on $ARGUMENTS');
|
|
const block = bashBlockHolding(phase0, cmd);
|
|
assert.ok(block, 'the parser line is not in a runtime ```bash block');
|
|
assert.deepEqual(block.split('\n').slice(-2), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
|
|
'the block does not end with $ARGUMENTS as the heredoc body and its closing line');
|
|
assert.match(flat(phase0), /Run first, exactly like this:/);
|
|
assert.match(flat(phase0), /Read `flags` and `positional` from the JSON it prints/);
|
|
assert.match(flat(phase0), /`flags\['--help'\] === true`/, 'Phase 0 does not dispatch on the parsed --help flag');
|
|
assert.match(flat(phase0), /`positional\[0\] === '-h'`/, 'Phase 0 does not dispatch on a lone -h as the first positional');
|
|
const help = runParser(block, '--help');
|
|
assert.equal(help.status, 0, help.stdout + help.stderr);
|
|
assert.equal(JSON.parse(help.stdout).flags['--help'], true, 'the parser line run with --help does not set the help flag');
|
|
const dir = runParser(block, '.claude/projects/demo');
|
|
assert.equal(dir.status, 0, dir.stdout + dir.stderr);
|
|
const r = JSON.parse(dir.stdout);
|
|
assert.equal(r.flags['--help'], undefined, 'a project dir set the --help flag');
|
|
assert.deepEqual(r.positional, ['.claude/projects/demo']);
|
|
const h = runParser(block, '-h');
|
|
assert.equal(h.status, 0, h.stdout + h.stderr);
|
|
assert.equal(JSON.parse(h.stdout).positional[0], '-h');
|
|
const p05 = flat(sectionText(readCommand(), '## Phase 0.5 — Cleanup mode dispatch'));
|
|
assert.doesNotMatch(p05, /After `parseArgs` has resolved/);
|
|
assert.match(p05, /After the parser has printed/);
|
|
});
|
|
|
|
test('trekcontinue — stats write through stats-append, never silently skipped', () => {
|
|
const md = readCommand();
|
|
assert.match(md, /node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/stats\/stats-append\.mjs trekcontinue <<'JSON'/);
|
|
assert.doesNotMatch(md, /skip (stats )?silently|silently skip/i);
|
|
assert.doesNotMatch(md, /\$\{CLAUDE_PLUGIN_DATA\}\/trekcontinue-stats\.jsonl/);
|
|
});
|