import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { execFile } from 'node:child_process' import { promisify } from 'node:util' import type { Config, LiveSessionInfo } from '../src/types.js' import type { HistorySession } from '../src/http/history.js' // ── mock history.listSessions so the real ~/.claude/projects never pollutes ────── // (vitest hoists vi.mock; the factory may only reference vars prefixed "mock".) const mockListSessions = vi.fn(async (): Promise => []) vi.mock('../src/http/history.js', () => ({ listSessions: (...a: unknown[]) => mockListSessions(...a), })) const { parseGitHead, buildProjects, _clearProjectCache } = await import('../src/http/projects.js') const execFileP = promisify(execFile) // ── helpers ───────────────────────────────────────────────────────────────────── function makeCfg(overrides: Partial): Config { return { port: 3000, bindHost: '0.0.0.0', shellPath: '/bin/zsh', homeDir: '/home/tester', idleTtlMs: 1000, scrollbackBytes: 1024, maxPayloadBytes: 1024, wsPath: '/term', maxSessions: 50, maxMsgsPerSec: 2000, permTimeoutMs: 1000, reapIntervalMs: 1000, previewBytes: 1024, useTmux: false, allowedOrigins: [], projectRoots: [], projectScanDepth: 4, projectScanTtlMs: 0, // default OFF so each test re-scans deterministically projectDirtyCheck: false, ...overrides, } } function makeLive(over: { id: string; cwd: string | null } & Partial): LiveSessionInfo { return { id: over.id, createdAt: over.createdAt ?? 1000, clientCount: over.clientCount ?? 1, status: over.status ?? 'unknown', exited: over.exited ?? false, cwd: over.cwd, cols: over.cols ?? 80, rows: over.rows ?? 24, } } async function makeRepo(dir: string, branch: string): Promise { await fs.mkdir(path.join(dir, '.git'), { recursive: true }) await fs.writeFile(path.join(dir, '.git', 'HEAD'), `ref: refs/heads/${branch}\n`) } async function gitAvailable(): Promise { try { await execFileP('git', ['--version']) return true } catch { return false } } let tmp: string beforeEach(async () => { _clearProjectCache() mockListSessions.mockReset() mockListSessions.mockResolvedValue([]) tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-')) }) afterEach(async () => { _clearProjectCache() await fs.rm(tmp, { recursive: true, force: true }) }) // ── parseGitHead (pure) ────────────────────────────────────────────────────────── describe('parseGitHead', () => { it('extracts a simple branch name', () => { expect(parseGitHead('ref: refs/heads/main\n')).toBe('main') }) it('extracts a branch name with slashes', () => { expect(parseGitHead('ref: refs/heads/feature/x\n')).toBe('feature/x') }) it('returns null for a detached HEAD (bare 40-hex sha)', () => { expect(parseGitHead('a'.repeat(40) + '\n')).toBeNull() }) it('returns null for empty / junk / non-heads refs', () => { expect(parseGitHead('')).toBeNull() expect(parseGitHead('garbage\n')).toBeNull() expect(parseGitHead('ref: refs/tags/v1\n')).toBeNull() }) }) // ── buildProjects: discovery (real temp dir) ───────────────────────────────────── describe('buildProjects — discovery', () => { it('finds git repos with correct names/branches, skips node_modules, respects depth', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') // depth 1 await makeRepo(path.join(tmp, 'sub', 'repoB'), 'dev') // depth 2 await makeRepo(path.join(tmp, 'sub', 'deep', 'repoC'), 'x') // depth 3 -> excluded await makeRepo(path.join(tmp, 'node_modules', 'pkg'), 'y') // node_modules -> skipped const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) const out = await buildProjects(cfg, []) const byName = new Map(out.map((p) => [p.name, p])) expect([...byName.keys()].sort()).toEqual(['repoA', 'repoB']) expect(byName.get('repoA')).toMatchObject({ isGit: true, branch: 'main', path: path.join(tmp, 'repoA'), sessions: [], }) expect(byName.get('repoB')!.branch).toBe('dev') expect(byName.has('repoC')).toBe(false) // too deep expect(byName.has('pkg')).toBe(false) // node_modules }) it('records a root that is itself a git repo and does not descend into it', async () => { await makeRepo(tmp, 'main') await makeRepo(path.join(tmp, 'nested'), 'dev') // inside a repo -> not descended const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 4, projectDirtyCheck: false }) const out = await buildProjects(cfg, []) expect(out.map((p) => p.path)).toEqual([tmp]) expect(out[0]!.branch).toBe('main') }) it('leaves branch undefined when .git/HEAD is missing/unreadable', async () => { await fs.mkdir(path.join(tmp, 'bare', '.git'), { recursive: true }) // .git dir, no HEAD const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) const out = await buildProjects(cfg, []) const bare = out.find((p) => p.name === 'bare')! expect(bare.isGit).toBe(true) expect(bare.branch).toBeUndefined() }) it('does not follow symlinks', async () => { const other = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-link-')) try { await makeRepo(path.join(other, 'repoS'), 'main') try { await fs.symlink(other, path.join(tmp, 'linked'), 'dir') } catch { return // symlinks not permitted in this environment } const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 4, projectDirtyCheck: false }) const out = await buildProjects(cfg, []) expect(out.some((p) => p.name === 'repoS')).toBe(false) } finally { await fs.rm(other, { recursive: true, force: true }) } }) it('returns [] for an unreadable / missing root (never throws)', async () => { const cfg = makeCfg({ projectRoots: [path.join(tmp, 'does-not-exist')], projectScanDepth: 2, projectDirtyCheck: false, }) await expect(buildProjects(cfg, [])).resolves.toEqual([]) }) }) // ── buildProjects: live-session merge ──────────────────────────────────────────── describe('buildProjects — session merge', () => { it('merges sessions into the owning project by cwd === path or under path', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') const repoPath = path.join(tmp, 'repoA') const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) const live: LiveSessionInfo[] = [ makeLive({ id: 's-exact', cwd: repoPath, status: 'working', clientCount: 2, createdAt: 111 }), makeLive({ id: 's-under', cwd: path.join(repoPath, 'src', 'http'), status: 'idle' }), makeLive({ id: 's-other', cwd: path.join(tmp, 'elsewhere') }), makeLive({ id: 's-null', cwd: null }), ] const out = await buildProjects(cfg, live) const repoA = out.find((p) => p.name === 'repoA')! expect(repoA.sessions.map((s) => s.id).sort()).toEqual(['s-exact', 's-under']) expect(repoA.sessions.find((s) => s.id === 's-exact')).toEqual({ id: 's-exact', title: 'repoA', status: 'working', clientCount: 2, createdAt: 111, exited: false, }) expect(repoA.sessions.find((s) => s.id === 's-under')!.title).toBe('http') }) it('re-merges live sessions FRESH on every call (discovery is cached)', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') const repoPath = path.join(tmp, 'repoA') const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false, projectScanTtlMs: 60_000, // keep discovery cached across calls }) const first = await buildProjects(cfg, []) expect(first.find((p) => p.name === 'repoA')!.sessions).toEqual([]) const second = await buildProjects(cfg, [makeLive({ id: 's1', cwd: repoPath })]) expect(second.find((p) => p.name === 'repoA')!.sessions.map((s) => s.id)).toEqual(['s1']) const third = await buildProjects(cfg, []) expect(third.find((p) => p.name === 'repoA')!.sessions).toEqual([]) }) }) // ── buildProjects: history merge + sorting ─────────────────────────────────────── describe('buildProjects — history merge & sorting', () => { it('adds history-only cwds as projects and sets lastActiveMs on discovered repos', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') const repoPath = path.join(tmp, 'repoA') const histDir = await fs.mkdtemp(path.join(os.tmpdir(), 'projtest-hist-')) try { mockListSessions.mockResolvedValue([ { id: 'h1', cwd: repoPath, project: 'repoA', mtimeMs: 5000, preview: '' }, { id: 'h2', cwd: repoPath, project: 'repoA', mtimeMs: 9000, preview: '' }, { id: 'h3', cwd: histDir, project: path.basename(histDir), mtimeMs: 3000, preview: '' }, { id: 'h4', cwd: '', project: 'unknown', mtimeMs: 1, preview: '' }, ]) const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) const out = await buildProjects(cfg, []) const repoA = out.find((p) => p.name === 'repoA')! expect(repoA.lastActiveMs).toBe(9000) // newest of h1/h2 const hist = out.find((p) => p.path === histDir)! expect(hist).toBeDefined() expect(hist.isGit).toBe(false) expect(hist.lastActiveMs).toBe(3000) expect(out.some((p) => p.path === '')).toBe(false) // empty cwd ignored expect(out[0]!.name).toBe('repoA') // 9000 > 3000 } finally { await fs.rm(histDir, { recursive: true, force: true }) } }) it('sorts by lastActiveMs desc, undefined last, name asc tiebreak', async () => { await makeRepo(path.join(tmp, 'zzz'), 'main') // no history -> undefined await makeRepo(path.join(tmp, 'aaa'), 'main') // no history -> undefined await makeRepo(path.join(tmp, 'mmm'), 'main') // has history -> first mockListSessions.mockResolvedValue([ { id: 'h', cwd: path.join(tmp, 'mmm'), project: 'mmm', mtimeMs: 1000, preview: '' }, ]) const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) const out = await buildProjects(cfg, []) expect(out.map((p) => p.name)).toEqual(['mmm', 'aaa', 'zzz']) }) }) // ── buildProjects: in-flight promise deduplication (Finding 4) ───────────────────── describe('buildProjects — concurrent cache-miss deduplication', () => { it('two concurrent cold-cache calls return consistent results (no corrupt state)', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') await makeRepo(path.join(tmp, 'repoB'), 'dev') const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) // Both calls start concurrently while cache is cold. const [a, b] = await Promise.all([buildProjects(cfg, []), buildProjects(cfg, [])]) const aNames = a.map((p) => p.name).sort() const bNames = b.map((p) => p.name).sort() expect(aNames).toEqual(['repoA', 'repoB']) expect(bNames).toEqual(aNames) }) it('second concurrent call gets the same result as the first (in-flight reuse)', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false, projectScanTtlMs: 60_000, }) // Start first call, immediately start second — second should share the in-flight promise. const first = buildProjects(cfg, []) const second = buildProjects(cfg, []) // cache still cold at this synchronous point const [r1, r2] = await Promise.all([first, second]) expect(r1.map((p) => p.name)).toContain('repoA') expect(r2.map((p) => p.name)).toEqual(r1.map((p) => p.name)) }) }) // ── buildProjects: dirty check (real git, when available) ───────────────────────── describe('buildProjects — dirty check', () => { it('computes dirty via git status only when projectDirtyCheck is true', async () => { if (!(await gitAvailable())) return // git not installed -> skip const repo = path.join(tmp, 'gitrepo') await fs.mkdir(repo, { recursive: true }) await execFileP('git', ['init', '-q'], { cwd: repo }) await execFileP('git', ['config', 'user.email', 't@t.local'], { cwd: repo }) await execFileP('git', ['config', 'user.name', 'tester'], { cwd: repo }) const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: true }) // clean repo -> dirty false let out = await buildProjects(cfg, []) const clean = out.find((p) => p.name === 'gitrepo')! expect(clean.dirty).toBe(false) expect(clean.branch).toBeTruthy() // untracked file -> dirty true await fs.writeFile(path.join(repo, 'a.txt'), 'hi') _clearProjectCache() out = await buildProjects(cfg, []) expect(out.find((p) => p.name === 'gitrepo')!.dirty).toBe(true) }) it('never spawns git (dirty undefined) when projectDirtyCheck is false', async () => { await makeRepo(path.join(tmp, 'repoA'), 'main') const cfg = makeCfg({ projectRoots: [tmp], projectScanDepth: 2, projectDirtyCheck: false }) const out = await buildProjects(cfg, []) expect(out.find((p) => p.name === 'repoA')!.dirty).toBeUndefined() }) })