diff --git a/plugins/ultraplan-local/tests/commands/ultracontinue.test.mjs b/plugins/ultraplan-local/tests/commands/ultracontinue.test.mjs new file mode 100644 index 0000000..c7c2c61 --- /dev/null +++ b/plugins/ultraplan-local/tests/commands/ultracontinue.test.mjs @@ -0,0 +1,156 @@ +// tests/commands/ultracontinue.test.mjs +// Regression tests for /ultracontinue-local (commands/ultracontinue-local.md). +// +// Steps 2 + 4 of the v3.4.1 hot-fix plan +// (project 2026-05-04-v3.3.1-ultracontinue-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'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(HERE, '..', '..'); +const COMMAND_FILE = join(ROOT, 'commands', 'ultracontinue-local.md'); + +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('ultracontinue 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(), 'ultracontinue-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('ultracontinue 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('ultracontinue 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('ultracontinue 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.*/i, + 'Phase 1 must mention "expected " 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)', + ); +});