/** * GSD Tools Tests - Commands */ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert'); const fs = require('fs'); const path = require('path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); describe('history-digest command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('empty phases directory returns valid schema', () => { const result = runGsdTools('history-digest', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const digest = JSON.parse(result.output); assert.deepStrictEqual(digest.phases, {}, 'phases should be empty object'); assert.deepStrictEqual(digest.decisions, [], 'decisions should be empty array'); assert.deepStrictEqual(digest.tech_stack, [], 'tech_stack should be empty array'); }); test('nested frontmatter fields extracted correctly', () => { // Create phase directory with SUMMARY containing nested frontmatter const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); const summaryContent = `--- phase: "01" name: "Foundation Setup" dependency-graph: provides: - "Database schema" - "Auth system" affects: - "API layer" tech-stack: added: - "prisma" - "jose" patterns-established: - "Repository pattern" - "JWT auth flow" key-decisions: - "Use Prisma over Drizzle" - "JWT in httpOnly cookies" --- # Summary content here `; fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), summaryContent); const result = runGsdTools('history-digest', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const digest = JSON.parse(result.output); // Check nested dependency-graph.provides assert.ok(digest.phases['01'], 'Phase 01 should exist'); assert.deepStrictEqual( digest.phases['01'].provides.sort(), ['Auth system', 'Database schema'], 'provides should contain nested values' ); // Check nested dependency-graph.affects assert.deepStrictEqual( digest.phases['01'].affects, ['API layer'], 'affects should contain nested values' ); // Check nested tech-stack.added assert.deepStrictEqual( digest.tech_stack.sort(), ['jose', 'prisma'], 'tech_stack should contain nested values' ); // Check patterns-established (flat array) assert.deepStrictEqual( digest.phases['01'].patterns.sort(), ['JWT auth flow', 'Repository pattern'], 'patterns should be extracted' ); // Check key-decisions assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions'); assert.ok( digest.decisions.some(d => d.decision === 'Use Prisma over Drizzle'), 'Should contain first decision' ); }); test('multiple phases merged into single digest', () => { // Create phase 01 const phase01Dir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phase01Dir, { recursive: true }); fs.writeFileSync( path.join(phase01Dir, '01-01-SUMMARY.md'), `--- phase: "01" name: "Foundation" provides: - "Database" patterns-established: - "Pattern A" key-decisions: - "Decision 1" --- ` ); // Create phase 02 const phase02Dir = path.join(tmpDir, '.planning', 'phases', '02-api'); fs.mkdirSync(phase02Dir, { recursive: true }); fs.writeFileSync( path.join(phase02Dir, '02-01-SUMMARY.md'), `--- phase: "02" name: "API" provides: - "REST endpoints" patterns-established: - "Pattern B" key-decisions: - "Decision 2" tech-stack: added: - "zod" --- ` ); const result = runGsdTools('history-digest', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const digest = JSON.parse(result.output); // Both phases present assert.ok(digest.phases['01'], 'Phase 01 should exist'); assert.ok(digest.phases['02'], 'Phase 02 should exist'); // Decisions merged assert.strictEqual(digest.decisions.length, 2, 'Should have 2 decisions total'); // Tech stack merged assert.deepStrictEqual(digest.tech_stack, ['zod'], 'tech_stack should have zod'); }); test('malformed SUMMARY.md skipped gracefully', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(phaseDir, { recursive: true }); // Valid summary fs.writeFileSync( path.join(phaseDir, '01-01-SUMMARY.md'), `--- phase: "01" provides: - "Valid feature" --- ` ); // Malformed summary (no frontmatter) fs.writeFileSync( path.join(phaseDir, '01-02-SUMMARY.md'), `# Just a heading No frontmatter here ` ); // Another malformed summary (broken YAML) fs.writeFileSync( path.join(phaseDir, '01-03-SUMMARY.md'), `--- broken: [unclosed --- ` ); const result = runGsdTools('history-digest', tmpDir); assert.ok(result.success, `Command should succeed despite malformed files: ${result.error}`); const digest = JSON.parse(result.output); assert.ok(digest.phases['01'], 'Phase 01 should exist'); assert.ok( digest.phases['01'].provides.includes('Valid feature'), 'Valid feature should be extracted' ); }); test('flat provides field still works (backward compatibility)', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '01-01-SUMMARY.md'), `--- phase: "01" provides: - "Direct provides" --- ` ); const result = runGsdTools('history-digest', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const digest = JSON.parse(result.output); assert.deepStrictEqual( digest.phases['01'].provides, ['Direct provides'], 'Direct provides should work' ); }); test('inline array syntax supported', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '01-01-SUMMARY.md'), `--- phase: "01" provides: [Feature A, Feature B] patterns-established: ["Pattern X", "Pattern Y"] --- ` ); const result = runGsdTools('history-digest', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const digest = JSON.parse(result.output); assert.deepStrictEqual( digest.phases['01'].provides.sort(), ['Feature A', 'Feature B'], 'Inline array should work' ); assert.deepStrictEqual( digest.phases['01'].patterns.sort(), ['Pattern X', 'Pattern Y'], 'Inline quoted array should work' ); }); }); // ───────────────────────────────────────────────────────────────────────────── // phases list command // ───────────────────────────────────────────────────────────────────────────── describe('summary-extract command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('missing file returns error', () => { const result = runGsdTools('summary-extract .planning/phases/01-test/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command should succeed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.error, 'File not found', 'should report missing file'); }); test('extracts all fields from SUMMARY.md', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '01-01-SUMMARY.md'), `--- one-liner: Set up Prisma with User and Project models key-files: - prisma/schema.prisma - src/lib/db.ts tech-stack: added: - prisma - zod patterns-established: - Repository pattern - Dependency injection key-decisions: - Use Prisma over Drizzle: Better DX and ecosystem - Single database: Start simple, shard later requirements-completed: - AUTH-01 - AUTH-02 --- # Summary Full summary content here. ` ); const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.path, '.planning/phases/01-foundation/01-01-SUMMARY.md', 'path correct'); assert.strictEqual(output.one_liner, 'Set up Prisma with User and Project models', 'one-liner extracted'); assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma', 'src/lib/db.ts'], 'key files extracted'); assert.deepStrictEqual(output.tech_added, ['prisma', 'zod'], 'tech added extracted'); assert.deepStrictEqual(output.patterns, ['Repository pattern', 'Dependency injection'], 'patterns extracted'); assert.strictEqual(output.decisions.length, 2, 'decisions extracted'); assert.deepStrictEqual(output.requirements_completed, ['AUTH-01', 'AUTH-02'], 'requirements completed extracted'); }); test('selective extraction with --fields', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '01-01-SUMMARY.md'), `--- one-liner: Set up database key-files: - prisma/schema.prisma tech-stack: added: - prisma patterns-established: - Repository pattern key-decisions: - Use Prisma: Better DX requirements-completed: - AUTH-01 --- ` ); const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md --fields one_liner,key_files,requirements_completed', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.one_liner, 'Set up database', 'one_liner included'); assert.deepStrictEqual(output.key_files, ['prisma/schema.prisma'], 'key_files included'); assert.deepStrictEqual(output.requirements_completed, ['AUTH-01'], 'requirements_completed included'); assert.strictEqual(output.tech_added, undefined, 'tech_added excluded'); assert.strictEqual(output.patterns, undefined, 'patterns excluded'); assert.strictEqual(output.decisions, undefined, 'decisions excluded'); }); test('handles missing frontmatter fields gracefully', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '01-01-SUMMARY.md'), `--- one-liner: Minimal summary --- # Summary ` ); const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.one_liner, 'Minimal summary', 'one-liner extracted'); assert.deepStrictEqual(output.key_files, [], 'key_files defaults to empty'); assert.deepStrictEqual(output.tech_added, [], 'tech_added defaults to empty'); assert.deepStrictEqual(output.patterns, [], 'patterns defaults to empty'); assert.deepStrictEqual(output.decisions, [], 'decisions defaults to empty'); assert.deepStrictEqual(output.requirements_completed, [], 'requirements_completed defaults to empty'); }); test('parses key-decisions with rationale', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync( path.join(phaseDir, '01-01-SUMMARY.md'), `--- key-decisions: - Use Prisma: Better DX than alternatives - JWT tokens: Stateless auth for scalability --- ` ); const result = runGsdTools('summary-extract .planning/phases/01-foundation/01-01-SUMMARY.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.decisions[0].summary, 'Use Prisma', 'decision summary parsed'); assert.strictEqual(output.decisions[0].rationale, 'Better DX than alternatives', 'decision rationale parsed'); assert.strictEqual(output.decisions[1].summary, 'JWT tokens', 'second decision summary'); assert.strictEqual(output.decisions[1].rationale, 'Stateless auth for scalability', 'second decision rationale'); }); }); // ───────────────────────────────────────────────────────────────────────────── // init commands tests // ───────────────────────────────────────────────────────────────────────────── describe('progress command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('renders JSON progress', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0 MVP\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done'); fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan 2'); const result = runGsdTools('progress json', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.total_plans, 2, '2 total plans'); assert.strictEqual(output.total_summaries, 1, '1 summary'); assert.strictEqual(output.percent, 50, '50%'); assert.strictEqual(output.phases.length, 1, '1 phase'); assert.strictEqual(output.phases[0].status, 'In Progress', 'phase in progress'); }); test('renders bar format', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-test'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done'); const result = runGsdTools('progress bar --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); assert.ok(result.output.includes('1/1'), 'should include count'); assert.ok(result.output.includes('100%'), 'should include 100%'); }); test('renders table format', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0 MVP\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); const result = runGsdTools('progress table --raw', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); assert.ok(result.output.includes('Phase'), 'should have table header'); assert.ok(result.output.includes('foundation'), 'should include phase name'); }); test('does not crash when summaries exceed plans (orphaned SUMMARY.md)', () => { fs.writeFileSync( path.join(tmpDir, '.planning', 'ROADMAP.md'), `# Roadmap v1.0 MVP\n` ); const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation'); fs.mkdirSync(p1, { recursive: true }); // 1 plan but 2 summaries (orphaned SUMMARY.md after PLAN.md deletion) fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Done'); fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Orphaned summary'); // bar format - should not crash with RangeError const barResult = runGsdTools('progress bar --raw', tmpDir); assert.ok(barResult.success, `Bar format crashed: ${barResult.error}`); assert.ok(barResult.output.includes('100%'), 'percent should be clamped to 100%'); // table format - should not crash with RangeError const tableResult = runGsdTools('progress table --raw', tmpDir); assert.ok(tableResult.success, `Table format crashed: ${tableResult.error}`); // json format - percent should be clamped const jsonResult = runGsdTools('progress json', tmpDir); assert.ok(jsonResult.success, `JSON format crashed: ${jsonResult.error}`); const output = JSON.parse(jsonResult.output); assert.ok(output.percent <= 100, `percent should be <= 100 but got ${output.percent}`); }); }); // ───────────────────────────────────────────────────────────────────────────── // todo complete command // ───────────────────────────────────────────────────────────────────────────── describe('todo complete command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('moves todo from pending to completed', () => { const pendingDir = path.join(tmpDir, '.planning', 'todos', 'pending'); fs.mkdirSync(pendingDir, { recursive: true }); fs.writeFileSync( path.join(pendingDir, 'add-dark-mode.md'), `title: Add dark mode\narea: ui\ncreated: 2025-01-01\n` ); const result = runGsdTools('todo complete add-dark-mode.md', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.completed, true); // Verify moved assert.ok( !fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', 'add-dark-mode.md')), 'should be removed from pending' ); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md')), 'should be in completed' ); // Verify completion timestamp added const content = fs.readFileSync( path.join(tmpDir, '.planning', 'todos', 'completed', 'add-dark-mode.md'), 'utf-8' ); assert.ok(content.startsWith('completed:'), 'should have completed timestamp'); }); test('fails for nonexistent todo', () => { const result = runGsdTools('todo complete nonexistent.md', tmpDir); assert.ok(!result.success, 'should fail'); assert.ok(result.error.includes('not found'), 'error mentions not found'); }); }); // ───────────────────────────────────────────────────────────────────────────── // scaffold command // ───────────────────────────────────────────────────────────────────────────── describe('scaffold command', () => { let tmpDir; beforeEach(() => { tmpDir = createTempProject(); }); afterEach(() => { cleanup(tmpDir); }); test('scaffolds context file', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); const result = runGsdTools('scaffold context --phase 3', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.created, true); // Verify file content const content = fs.readFileSync( path.join(tmpDir, '.planning', 'phases', '03-api', '03-CONTEXT.md'), 'utf-8' ); assert.ok(content.includes('Phase 3'), 'should reference phase number'); assert.ok(content.includes('Decisions'), 'should have decisions section'); assert.ok(content.includes('Discretion Areas'), 'should have discretion section'); }); test('scaffolds UAT file', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); const result = runGsdTools('scaffold uat --phase 3', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.created, true); const content = fs.readFileSync( path.join(tmpDir, '.planning', 'phases', '03-api', '03-UAT.md'), 'utf-8' ); assert.ok(content.includes('User Acceptance Testing'), 'should have UAT heading'); assert.ok(content.includes('Test Results'), 'should have test results section'); }); test('scaffolds verification file', () => { fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true }); const result = runGsdTools('scaffold verification --phase 3', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.created, true); const content = fs.readFileSync( path.join(tmpDir, '.planning', 'phases', '03-api', '03-VERIFICATION.md'), 'utf-8' ); assert.ok(content.includes('Goal-Backward Verification'), 'should have verification heading'); }); test('scaffolds phase directory', () => { const result = runGsdTools('scaffold phase-dir --phase 5 --name User Dashboard', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.created, true); assert.ok( fs.existsSync(path.join(tmpDir, '.planning', 'phases', '05-user-dashboard')), 'directory should be created' ); }); test('does not overwrite existing files', () => { const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api'); fs.mkdirSync(phaseDir, { recursive: true }); fs.writeFileSync(path.join(phaseDir, '03-CONTEXT.md'), '# Existing content'); const result = runGsdTools('scaffold context --phase 3', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); assert.strictEqual(output.created, false, 'should not overwrite'); assert.strictEqual(output.reason, 'already_exists'); }); });