/** * @test REQ-DISCOVERY-001 Sprint state worktree discovery * @intent Validate discoverActiveSprints() discovers sprint states from .worktrees/sprint/ * @covers AC-DISCOVERY-001-01 through AC-DISCOVERY-001-15 * * Note: These tests import the JS module via dynamic import since * sprint-discovery.js is CommonJS and vitest is ESM. */ import { describe, test, expect, beforeAll, afterAll } from 'vitest'; import path from 'path'; import fs from 'fs'; import os from 'os'; // Types mirroring DiscoveredSprint interface interface DiscoveredSprint { state: any; sourcePath: string; worktreeExists: boolean; } let sprintDiscovery: { discoverActiveSprints(dir: string): DiscoveredSprint[]; findGitRoot(dir: string): string | null; }; const TMP_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), 'sprint-discovery-test-')); // Simulate a real repo structure: // TMP_ROOT/ // .git/ (marker for git root discovery) // .sprint-state/sprint-state.json (optional, for cwd fallback) // .worktrees/sprint/ // sprint-2026-06-23-01/.sprint-state/sprint-state.json (worktree sprint) // sprint-2026-06-23-02/.sprint-state/sprint-state.json (completed sprint) // orphan-sprint/ (missing .sprint-state/) // broken-sprint/.sprint-state/sprint-state.json (corrupted JSON) const GIT_DIR = path.join(TMP_ROOT, '.git'); const WORKTREE_DIR = path.join(TMP_ROOT, '.worktrees', 'sprint'); const WORKTREE_1 = path.join(WORKTREE_DIR, 'sprint-2026-06-23-01'); const WORKTREE_2 = path.join(WORKTREE_DIR, 'sprint-2026-06-23-02'); const WORKTREE_3 = path.join(WORKTREE_DIR, 'orphan-sprint'); const WORKTREE_4 = path.join(WORKTREE_DIR, 'broken-sprint'); const WORKTREE_5 = path.join(WORKTREE_DIR, 'stale-in-progress'); // worktree deleted but state exists const CWD_SPRINT_DIR = path.join(TMP_ROOT, '.sprint-state'); const ACTIVE_SPRINT = { id: 'sprint-2026-06-23-01', phase: 2, status: 'in_progress', started_at: '2026-06-23T10:00:00Z', task_description: 'Fix issue #247 sprint status worktree discovery', isolation: { worktree_path: WORKTREE_1, branch: 'sprint/sprint-2026-06-23-01', base_commit: 'abc1234', }, phase_history: [ { phase: -1, phase_name: 'ISOLATE', status: 'completed', started_at: '2026-06-23T09:00:00Z', completed_at: '2026-06-23T09:02:00Z', duration_seconds: 120 }, { phase: 0, phase_name: 'THINK', status: 'completed', started_at: '2026-06-23T09:02:00Z', completed_at: '2026-06-23T09:30:00Z', duration_seconds: 1680 }, { phase: 2, phase_name: 'BUILD', status: 'in_progress', started_at: '2026-06-23T10:00:00Z' }, ], }; const COMPLETED_SPRINT = { id: 'sprint-2026-06-23-02', phase: 8, status: 'completed', started_at: '2026-06-22T10:00:00Z', task_description: 'Already finished sprint', isolation: { worktree_path: WORKTREE_2, branch: 'sprint/sprint-2026-06-23-02', }, phase_history: [ { phase: 8, phase_name: 'CLEANUP', status: 'completed' }, ], }; const CWD_SPRINT = { id: 'sprint-cwd-only', phase: 1, status: 'in_progress', started_at: '2026-06-23T11:00:00Z', task_description: 'Sprint running in cwd (no worktree)', }; function writeTestJson(filePath: string, data: unknown) { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(data, null, 2)); } beforeAll(async () => { // Setup git root marker fs.mkdirSync(GIT_DIR, { recursive: true }); // Create worktree sprints writeTestJson(path.join(WORKTREE_1, '.sprint-state', 'sprint-state.json'), ACTIVE_SPRINT); writeTestJson(path.join(WORKTREE_2, '.sprint-state', 'sprint-state.json'), COMPLETED_SPRINT); // orphan-sprint: directory exists but no .sprint-state/ fs.mkdirSync(WORKTREE_3, { recursive: true }); // broken-sprint: corrupted JSON fs.mkdirSync(path.join(WORKTREE_4, '.sprint-state'), { recursive: true }); fs.writeFileSync(path.join(WORKTREE_4, '.sprint-state', 'sprint-state.json'), '{not valid json!!!'); // stale-in-progress: sprint-state.json exists but worktree dir deleted after read // We simulate this by creating the state file in a temp location writeTestJson(path.join(WORKTREE_DIR, 'stale-in-progress', '.sprint-state', 'sprint-state.json'), { id: 'sprint-stale', phase: 5, status: 'in_progress', started_at: '2026-06-20T10:00:00Z', task_description: 'Stale sprint — worktree deleted', isolation: { worktree_path: path.join(WORKTREE_DIR, 'stale-in-progress'), branch: 'sprint/stale' }, }); // Delete the worktree dir to simulate orphan if (fs.existsSync(WORKTREE_5)) fs.rmSync(WORKTREE_5, { recursive: true }); // Load the module try { sprintDiscovery = await import('../sprint-discovery.js'); } catch { // Module not yet created — tests will fail until GREEN phase } }); afterAll(() => { fs.rmSync(TMP_ROOT, { recursive: true, force: true }); }); function skipIfMissing() { if (!sprintDiscovery) { console.warn('[SKIP] sprint-discovery module not yet implemented'); } return !sprintDiscovery; } // ── findGitRoot ── describe('sprint-discovery: findGitRoot', () => { test('finds git root from within repo directory', () => { if (skipIfMissing()) return; const root = sprintDiscovery.findGitRoot(TMP_ROOT); expect(root).toBe(TMP_ROOT); }); test('finds git root from a subdirectory', () => { if (skipIfMissing()) return; const subDir = path.join(TMP_ROOT, '.worktrees', 'sprint', 'sprint-2026-06-23-01'); const root = sprintDiscovery.findGitRoot(subDir); expect(root).toBe(TMP_ROOT); }); test('returns null when no .git found in ancestors', () => { if (skipIfMissing()) return; const noGit = fs.mkdtempSync(path.join(os.tmpdir(), 'no-git-')); try { const root = sprintDiscovery.findGitRoot(noGit); expect(root).toBeNull(); } finally { fs.rmSync(noGit, { recursive: true, force: true }); } }); test('returns null for non-existent path', () => { if (skipIfMissing()) return; const root = sprintDiscovery.findGitRoot('/nonexistent/path/foo/bar'); expect(root).toBeNull(); }); }); // ── discoverActiveSprints ── describe('sprint-discovery: discoverActiveSprints', () => { test('discovers active sprint from worktree directory', () => { if (skipIfMissing()) return; const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT); const active = results.filter(r => r.state.id === 'sprint-2026-06-23-01'); expect(active.length).toBe(1); expect(active[0].state.task_description).toBe('Fix issue #247 sprint status worktree discovery'); expect(active[0].state.status).toBe('in_progress'); expect(active[0].worktreeExists).toBe(true); expect(active[0].sourcePath).toContain(path.join('.worktrees', 'sprint', 'sprint-2026-06-23-01')); }); test('includes completed sprints with existing worktrees', () => { if (skipIfMissing()) return; const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT); // sprint-2026-06-23-02 is completed but worktree still exists — should be included const completed = results.filter(r => r.state.id === 'sprint-2026-06-23-02'); expect(completed.length).toBe(1); }); test('handles corrupted sprint-state.json without crashing', () => { if (skipIfMissing()) return; // broken-sprint dir exists with invalid JSON — should be silently skipped expect(() => sprintDiscovery.discoverActiveSprints(TMP_ROOT)).not.toThrow(); }); test('skips worktree subdir without .sprint-state/', () => { if (skipIfMissing()) return; const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT); const orphans = results.filter(r => r.state.id === 'orphan-sprint'); expect(orphans.length).toBe(0); }); test('filters out stale sprint with deleted worktree directory', () => { if (skipIfMissing()) return; const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT); const stale = results.filter(r => r.state.id === 'sprint-stale'); // worktreeExists should be false (dir deleted), so should be filtered expect(stale.length).toBe(0); }); test('falls back to cwd .sprint-state/ when no worktrees', () => { if (skipIfMissing()) return; // Setup a separate dir with no worktrees but cwd sprint state const fallbackDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fallback-')); writeTestJson(path.join(fallbackDir, '.sprint-state', 'sprint-state.json'), CWD_SPRINT); try { const results = sprintDiscovery.discoverActiveSprints(fallbackDir); const cwdResults = results.filter(r => r.state.id === 'sprint-cwd-only'); expect(cwdResults.length).toBe(1); expect(cwdResults[0].worktreeExists).toBe(false); } finally { fs.rmSync(fallbackDir, { recursive: true, force: true }); } }); test('returns empty array when no sprint states anywhere', () => { if (skipIfMissing()) return; const emptyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'empty-')); try { const results = sprintDiscovery.discoverActiveSprints(emptyDir); expect(results).toEqual([]); } finally { fs.rmSync(emptyDir, { recursive: true, force: true }); } }); test('sorts results by started_at descending', () => { if (skipIfMissing()) return; // sprint-2026-06-23-01 started 2026-06-23, cwd sprint started 2026-06-23 (same day, but different time) // We only have one active sprint in this test setup, so this tests deterministic ordering const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT); expect(results.length).toBeGreaterThanOrEqual(1); // Verify sorting: each result's started_at should be >= next result's started_at for (let i = 0; i < results.length - 1; i++) { const a = String(results[i].state.started_at ?? ''); const b = String(results[i + 1].state.started_at ?? ''); expect(a >= b).toBe(true); } }); test('caps results at MAX_RESULTS (5)', () => { if (skipIfMissing()) return; // Create 7 fake active sprints in worktree const worktreeDir = path.join(TMP_ROOT, '.worktrees', 'sprint'); for (let i = 3; i <= 10; i++) { const sprintId = `sprint-many-${String(i).padStart(2, '0')}`; const sprintDir = path.join(worktreeDir, sprintId); writeTestJson(path.join(sprintDir, '.sprint-state', 'sprint-state.json'), { id: sprintId, phase: 2, status: 'in_progress', started_at: `2026-06-${String(i).padStart(2, '0')}T10:00:00Z`, task_description: `Many sprint ${i}`, isolation: { worktree_path: sprintDir, branch: `sprint/many-${i}` }, }); } const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT); expect(results.length).toBeLessThanOrEqual(5); }); test('deduplicates sprints found in both worktree and cwd', () => { if (skipIfMissing()) return; // Write the same sprint ID to cwd .sprint-state/ writeTestJson(path.join(CWD_SPRINT_DIR, 'sprint-state.json'), { ...ACTIVE_SPRINT, isolation: { ...ACTIVE_SPRINT.isolation, worktree_path: TMP_ROOT }, }); const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT); const duplicates = results.filter(r => r.state.id === 'sprint-2026-06-23-01'); expect(duplicates.length).toBe(1); expect(duplicates[0].worktreeExists).toBe(true); }); test('tie-breaker: latest started_at wins when same sprint.id in two worktrees', () => { if (skipIfMissing()) return; // Create WS-A (older) and WS-B (newer) with same sprint.id const wa = path.join(WORKTREE_DIR, 'tie-a'); const wb = path.join(WORKTREE_DIR, 'tie-b'); writeTestJson(path.join(wa, '.sprint-state', 'sprint-state.json'), { id: 'sprint-tie', started_at: '2026-06-23T10:00:00Z', status: 'in_progress', task_description: 'Tiebreaker A (older)', isolation: { worktree_path: wa, branch: 'tie-a' }, }); writeTestJson(path.join(wb, '.sprint-state', 'sprint-state.json'), { id: 'sprint-tie', started_at: '2026-06-23T12:00:00Z', status: 'in_progress', task_description: 'Tiebreaker B (newer)', isolation: { worktree_path: wb, branch: 'tie-b' }, }); const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT); const ties = results.filter(r => r.state.id === 'sprint-tie'); expect(ties.length).toBe(1); expect(ties[0].state.task_description).toContain('newer'); }); test('handles sprint-state.json with valid JSON but missing required fields', () => { if (skipIfMissing()) return; const partialDir = path.join(WORKTREE_DIR, 'partial-fields'); writeTestJson(path.join(partialDir, '.sprint-state', 'sprint-state.json'), { // no id, no status, no started_at task_description: 'Partial fields only', }); // After migration, missing id gets auto-generated, so result is found const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT); const partials = results.filter(r => r.state.task_description === 'Partial fields only'); expect(partials.length).toBe(1); // Migrated state should have auto-generated id expect(partials[0].state.id).toMatch(/^sprint-\d+$/); }); test('handles EACCES on .sprint-state/ directory gracefully', () => { if (skipIfMissing()) return; // We can't easily test EACCES in unit tests, but we verify the code has try/catch // and does not throw. We test that the function at least doesn't crash on valid setups. expect(() => sprintDiscovery.discoverActiveSprints(TMP_ROOT)).not.toThrow(); }); test('finds sprints from cwd that is inside a worktree subdirectory', () => { if (skipIfMissing()) return; // Simulate: cwd = TMP_ROOT/.worktrees/sprint/sprint-2026-06-23-01 (inside worktree) // Should walk up to find git root, then discover ALL worktree sprints const insideWorktree = path.join(TMP_ROOT, '.worktrees', 'sprint', 'sprint-2026-06-23-01'); const results = sprintDiscovery.discoverActiveSprints(insideWorktree); // Should find the active sprint (sprint-2026-06-23-01) even when called from inside it const active = results.filter(r => r.state.id === 'sprint-2026-06-23-01'); expect(active.length).toBe(1); }); test('uses sprint ID as fallback title when task_description missing', () => { if (skipIfMissing()) return; const noDescDir = path.join(WORKTREE_DIR, 'no-description'); writeTestJson(path.join(noDescDir, '.sprint-state', 'sprint-state.json'), { id: 'sprint-2026-06-23-nodesc', phase: 0, status: 'in_progress', started_at: '2026-06-23T10:00:00Z', isolation: { worktree_path: noDescDir, branch: 'sprint/no-desc' }, }); const results = sprintDiscovery.discoverActiveSprints(TMP_ROOT); const desc = results.filter(r => r.state.id === 'sprint-2026-06-23-nodesc'); expect(desc.length).toBe(1); // After migration, missing task_description gets default '-' expect(desc[0].state.task_description).toBe('-'); }); });