Files
web-terminal/test/projects.test.ts
Yaojia Wang 7b4adf5072 feat(v0.6): Project Manager — repo discovery + Projects panel + tab wiring
Home screen gains a Sessions↔Projects segmented control. The Projects view
discovers the host's git repos and, on click, opens a tab named after the
repo, spawned in the repo dir, auto-running `claude` (1:N sessions per project).

Backend (P2/P4):
- src/http/projects.ts — parseGitHead + buildProjects(cfg, liveSessions):
  depth-capped BFS for .git (skips node_modules/dotdirs/symlinks, stops
  descending at a repo), .git/HEAD branch parse, rate-limited `git status`
  dirty check (execFile, no shell, 2s timeout, concurrency 8), merge of
  ~/.claude/projects cwds (reuses history.listSessions), cwd-prefix session
  merge (fresh each call), TTL discovery cache with in-flight dedup.
- src/server.ts — read-only GET /projects (no Origin guard, []-fallback).

Frontend (P5/P6):
- public/projects.ts — mountProjects: 1:N cards (branch chip, dirty dot,
  session rows, "+ start claude here"), live search, localStorage favourites;
  pure helpers extracted for unit tests.
- public/tabs.ts — openProject (#n suffix for dup names, sends 'claude\r'),
  countOpenWithTitlePrefix, Sessions↔Projects home toggle (updateHomeView).
- public/style.css — Projects panel + .home-seg control styles.

Config (P3, hardened): PROJECT_ROOTS/SCAN_DEPTH/SCAN_TTL/DIRTY_CHECK already
added; this commit adds fail-fast rejection of relative PROJECT_ROOTS.

Review hardening (4 confirmed HIGH + boundary validation):
- carriage-return fix so claude auto-executes (A3); seg-control z-order;
  parallelised history merge; concurrent-cache-miss dedup.
- normalizeProject guards the /projects response; getFavs filters non-strings.

Tests: +57 (398 total). Backend src/http/projects.ts 93.4%, config.ts 98%.
Verified: both typechecks clean, full suite green, build:web OK, coverage
≥80×4, real-browser smoke (83 repos, branch/dirty correct, click→claude tab).
2026-06-30 11:04:49 +02:00

350 lines
14 KiB
TypeScript

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<HistorySession[]> => [])
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>): 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>): 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<void> {
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<boolean> {
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()
})
})