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).
This commit is contained in:
Yaojia Wang
2026-06-30 11:04:49 +02:00
parent dc5d073374
commit 7b4adf5072
12 changed files with 2060 additions and 35 deletions

View File

@@ -384,3 +384,57 @@ describe('loadConfig — returned object is frozen', () => {
expect(Object.isFrozen(cfg.allowedOrigins)).toBe(true)
})
})
// ── v0.6 project discovery config ───────────────────────────────────────────────
describe('loadConfig — project discovery (v0.6)', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('defaults: projectRoots=[homeDir], depth 4, ttl 10000, dirtyCheck true', () => {
const cfg = loadConfig({})
expect(cfg.projectRoots).toEqual(['/home/testuser'])
expect(cfg.projectScanDepth).toBe(4)
expect(cfg.projectScanTtlMs).toBe(10_000)
expect(cfg.projectDirtyCheck).toBe(true)
expect(Object.isFrozen(cfg.projectRoots)).toBe(true)
})
it('PROJECT_ROOTS: comma-separated absolute paths, trimmed', () => {
const cfg = loadConfig({ PROJECT_ROOTS: '/a, /b/c ,/d' })
expect(cfg.projectRoots).toEqual(['/a', '/b/c', '/d'])
})
it('PROJECT_ROOTS: expands ~ and ~/sub to homeDir', () => {
const cfg = loadConfig({ PROJECT_ROOTS: '~,~/code' })
expect(cfg.projectRoots).toEqual(['/home/testuser', '/home/testuser/code'])
})
it('PROJECT_ROOTS: empty / whitespace falls back to [homeDir]', () => {
expect(loadConfig({ PROJECT_ROOTS: '' }).projectRoots).toEqual(['/home/testuser'])
expect(loadConfig({ PROJECT_ROOTS: ' ' }).projectRoots).toEqual(['/home/testuser'])
})
it('PROJECT_ROOTS: a relative path fails fast with a clear error', () => {
expect(() => loadConfig({ PROJECT_ROOTS: 'relative/dir' })).toThrow(/absolute path/)
expect(() => loadConfig({ PROJECT_ROOTS: '/ok,./bad' })).toThrow(/PROJECT_ROOTS/)
})
it('PROJECT_SCAN_DEPTH / PROJECT_SCAN_TTL override and validate', () => {
const cfg = loadConfig({ PROJECT_SCAN_DEPTH: '2', PROJECT_SCAN_TTL: '0' })
expect(cfg.projectScanDepth).toBe(2)
expect(cfg.projectScanTtlMs).toBe(0)
expect(() => loadConfig({ PROJECT_SCAN_DEPTH: 'deep' })).toThrow(/PROJECT_SCAN_DEPTH/)
expect(() => loadConfig({ PROJECT_SCAN_TTL: '-1' })).toThrow(/PROJECT_SCAN_TTL/)
})
it('PROJECT_DIRTY_CHECK: parses on/off forms, defaults true', () => {
expect(loadConfig({ PROJECT_DIRTY_CHECK: '0' }).projectDirtyCheck).toBe(false)
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'off' }).projectDirtyCheck).toBe(false)
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'false' }).projectDirtyCheck).toBe(false)
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'on' }).projectDirtyCheck).toBe(true)
expect(loadConfig({ PROJECT_DIRTY_CHECK: '1' }).projectDirtyCheck).toBe(true)
expect(loadConfig({ PROJECT_DIRTY_CHECK: 'garbage' }).projectDirtyCheck).toBe(true)
})
})

View File

@@ -0,0 +1,144 @@
/**
* Integration test for GET /projects (P4 — v0.6 Project Manager).
*
* Starts a real HTTP server against a temp directory containing one fake git
* repo, then asserts that GET /projects returns HTTP 200 and a JSON array
* containing that repo with name + isGit:true.
*
* Config overrides used for speed and determinism:
* - PROJECT_ROOTS → a mkdtemp temp dir (one fake git repo inside)
* - PROJECT_DIRTY_CHECK='0' → skip `git status` subprocess calls
* - PROJECT_SCAN_TTL='0' → no caching between test runs
* - USE_TMUX='0' → no tmux (not needed)
* - ALLOWED_ORIGINS → loopback only
*/
import fs from 'node:fs/promises'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { loadConfig } from '../../src/config.js'
import { startServer } from '../../src/server.js'
import { _clearProjectCache } from '../../src/http/projects.js'
import type { ProjectInfo } from '../../src/types.js'
// ── helpers ───────────────────────────────────────────────────────────────────
/** Pick a free port on 127.0.0.1 by briefly binding to port 0. */
function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer()
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address()
if (addr === null || typeof addr === 'string') {
srv.close()
reject(new Error('unexpected address type'))
return
}
const port = addr.port
srv.close(() => resolve(port))
})
srv.on('error', reject)
})
}
/** Create a minimal fake git repo at `dir/<repoName>/`. Returns the repo path. */
async function makeFakeGitRepo(parentDir: string, repoName: string): Promise<string> {
const repoPath = path.join(parentDir, repoName)
const gitDir = path.join(repoPath, '.git')
await fs.mkdir(gitDir, { recursive: true })
// Write a .git/HEAD so branch parsing works
await fs.writeFile(path.join(gitDir, 'HEAD'), 'ref: refs/heads/main\n', 'utf8')
return repoPath
}
// ── test suite ────────────────────────────────────────────────────────────────
describe('GET /projects — integration', () => {
let port: number
let tmpRoot: string
let repoPath: string
let serverHandle: { close(): Promise<void> }
beforeAll(async () => {
// Ensure a clean discovery cache from any sibling tests.
_clearProjectCache()
port = await getFreePort()
// Create a temp root with one fake git repo inside.
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-projects-test-'))
repoPath = await makeFakeGitRepo(tmpRoot, 'fake-repo')
const cfg = loadConfig({
PORT: String(port),
BIND_HOST: '127.0.0.1',
SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh',
ALLOWED_ORIGINS: `http://127.0.0.1:${port}`,
USE_TMUX: '0',
IDLE_TTL: '86400',
// Project-discovery overrides
PROJECT_ROOTS: tmpRoot,
PROJECT_DIRTY_CHECK: '0', // skip git-status subprocess
PROJECT_SCAN_TTL: '0', // no caching; every call is fresh
PROJECT_SCAN_DEPTH: '2', // shallow — temp dir is only 1 level deep
})
serverHandle = startServer(cfg)
// Give the server time to bind before firing requests.
await new Promise<void>((r) => setTimeout(r, 100))
})
afterAll(async () => {
await serverHandle.close()
// Remove the temp dir (best-effort — test runner cleans /tmp anyway).
await fs.rm(tmpRoot, { recursive: true, force: true })
_clearProjectCache()
})
it('returns HTTP 200 with a JSON array', async () => {
const res = await fetch(`http://127.0.0.1:${port}/projects`)
expect(res.status).toBe(200)
const body = (await res.json()) as unknown
expect(Array.isArray(body)).toBe(true)
})
it('includes the fake repo with correct name and isGit:true', async () => {
const res = await fetch(`http://127.0.0.1:${port}/projects`)
expect(res.status).toBe(200)
const projects = (await res.json()) as ProjectInfo[]
const found = projects.find((p) => p.path === repoPath)
expect(found).toBeDefined()
expect(found!.name).toBe('fake-repo')
expect(found!.isGit).toBe(true)
})
it('includes the branch parsed from .git/HEAD', async () => {
const res = await fetch(`http://127.0.0.1:${port}/projects`)
const projects = (await res.json()) as ProjectInfo[]
const found = projects.find((p) => p.path === repoPath)
expect(found).toBeDefined()
expect(found!.branch).toBe('main')
})
it('returns an empty sessions array for a repo with no live sessions', async () => {
const res = await fetch(`http://127.0.0.1:${port}/projects`)
const projects = (await res.json()) as ProjectInfo[]
const found = projects.find((p) => p.path === repoPath)
expect(found).toBeDefined()
expect(Array.isArray(found!.sessions)).toBe(true)
expect(found!.sessions).toHaveLength(0)
})
it('responds even if buildProjects throws (graceful fallback to [])', async () => {
// The server catches errors from buildProjects and returns [].
// This is tested implicitly above — the server stays up across all sub-tests.
// (A deeper unit test of the error path is in test/projects.test.ts.)
const res = await fetch(`http://127.0.0.1:${port}/projects`)
expect(res.status).toBe(200)
})
})

273
test/projects-panel.test.ts Normal file
View File

@@ -0,0 +1,273 @@
// @vitest-environment jsdom
/**
* test/projects-panel.test.ts — unit tests for pure helpers in public/projects.ts.
*
* Covers filterProjects, sortProjects, toggleFav, getFavs/saveFavs.
* localStorage is provided by the jsdom environment.
* mountProjects (DOM wiring) is thin glue and not unit-tested here.
*
* @xterm/xterm is mocked because projects.ts imports preview-grid.ts which
* uses Terminal. The mock must be declared before the dynamic import below.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { ProjectInfo } from '../src/types.js'
// ── Stub @xterm/xterm (transitively required via preview-grid.ts) ─────────────
class FakeTerminal {
open = vi.fn()
dispose = vi.fn()
}
vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal }))
// Dynamic import AFTER mock declaration so the factory is in place.
const { filterProjects, sortProjects, getFavs, saveFavs, toggleFav, normalizeProject } = await import(
'../public/projects.js'
)
/* ── Helpers ───────────────────────────────────────────────────────────────── */
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
return {
name: 'test-repo',
path: '/home/user/test-repo',
isGit: true,
sessions: [],
...overrides,
}
}
/* ── filterProjects ────────────────────────────────────────────────────────── */
describe('filterProjects', () => {
const projects: ProjectInfo[] = [
makeProject({ name: 'web-terminal', path: '/home/user/web-terminal' }),
makeProject({ name: 'api-service', path: '/home/user/projects/api-service' }),
makeProject({ name: 'utils', path: '/home/user/utils' }),
]
it('returns all projects when query is empty string', () => {
expect(filterProjects(projects, '')).toHaveLength(3)
})
it('returns all projects when query is only whitespace', () => {
expect(filterProjects(projects, ' ')).toHaveLength(3)
})
it('filters by name substring (case-insensitive)', () => {
const result = filterProjects(projects, 'WEB')
expect(result).toHaveLength(1)
expect(result[0]!.name).toBe('web-terminal')
})
it('filters by path substring', () => {
const result = filterProjects(projects, 'projects')
expect(result).toHaveLength(1)
expect(result[0]!.name).toBe('api-service')
})
it('is case-insensitive on path', () => {
const result = filterProjects(projects, 'UTILS')
expect(result).toHaveLength(1)
expect(result[0]!.name).toBe('utils')
})
it('returns empty array when no project matches', () => {
expect(filterProjects(projects, 'zzz-no-match-at-all')).toHaveLength(0)
})
it('does not mutate the input array', () => {
const original = [...projects]
filterProjects(projects, 'web')
expect(projects).toEqual(original)
})
it('returns a new array each time', () => {
const r1 = filterProjects(projects, '')
const r2 = filterProjects(projects, '')
expect(r1).not.toBe(r2) // different references
})
})
/* ── sortProjects ──────────────────────────────────────────────────────────── */
describe('sortProjects', () => {
const a = makeProject({ name: 'a', path: '/a', lastActiveMs: 1000 })
const b = makeProject({ name: 'b', path: '/b', lastActiveMs: 3000 })
const c = makeProject({ name: 'c', path: '/c', lastActiveMs: 2000 })
it('places favourites before non-favourites', () => {
const favs = new Set(['/c'])
const sorted = sortProjects([a, b, c], favs)
expect(sorted[0]!.path).toBe('/c')
})
it('among non-favourites sorts by lastActiveMs descending', () => {
const sorted = sortProjects([a, b, c], new Set())
expect(sorted[0]!.path).toBe('/b') // 3000 ms
expect(sorted[1]!.path).toBe('/c') // 2000 ms
expect(sorted[2]!.path).toBe('/a') // 1000 ms
})
it('among favourites also sorts by lastActiveMs descending', () => {
const favs = new Set(['/a', '/b'])
const sorted = sortProjects([a, b, c], favs)
expect(sorted[0]!.path).toBe('/b') // fav, 3000 ms
expect(sorted[1]!.path).toBe('/a') // fav, 1000 ms
expect(sorted[2]!.path).toBe('/c') // non-fav, 2000 ms
})
it('handles missing lastActiveMs (treats as 0)', () => {
const noMs = makeProject({ path: '/x' })
const withMs = makeProject({ path: '/y', lastActiveMs: 500 })
const sorted = sortProjects([noMs, withMs], new Set())
expect(sorted[0]!.path).toBe('/y')
expect(sorted[1]!.path).toBe('/x')
})
it('does not mutate the input array', () => {
const arr = [a, b, c]
const origFirst = arr[0]!.path
sortProjects(arr, new Set())
expect(arr[0]!.path).toBe(origFirst)
})
it('returns a new array', () => {
const arr = [a, b, c]
const sorted = sortProjects(arr, new Set())
expect(sorted).not.toBe(arr)
})
it('returns empty array for empty input', () => {
expect(sortProjects([], new Set())).toEqual([])
})
})
/* ── toggleFav ─────────────────────────────────────────────────────────────── */
describe('toggleFav', () => {
it('adds a path that is not present', () => {
const original = new Set(['/a'])
const next = toggleFav(original, '/b')
expect(next.has('/b')).toBe(true)
expect(next.has('/a')).toBe(true)
})
it('removes a path that is already present', () => {
const original = new Set(['/a', '/b'])
const next = toggleFav(original, '/a')
expect(next.has('/a')).toBe(false)
expect(next.has('/b')).toBe(true)
})
it('does not mutate the original set', () => {
const original = new Set(['/a'])
toggleFav(original, '/a')
expect(original.has('/a')).toBe(true) // unchanged
})
it('returns a new Set instance', () => {
const original = new Set(['/a'])
const next = toggleFav(original, '/b')
expect(next).not.toBe(original)
})
it('works on an empty set (add case)', () => {
const next = toggleFav(new Set(), '/x')
expect(next.has('/x')).toBe(true)
expect(next.size).toBe(1)
})
})
/* ── getFavs / saveFavs (localStorage persistence) ─────────────────────────── */
describe('getFavs / saveFavs', () => {
beforeEach(() => {
localStorage.clear()
})
it('getFavs returns empty set when no data is stored', () => {
expect(getFavs().size).toBe(0)
})
it('saveFavs + getFavs round-trips a set of paths', () => {
const paths = new Set(['/home/user/proj-a', '/home/user/proj-b'])
saveFavs(paths)
const loaded = getFavs()
expect(loaded.has('/home/user/proj-a')).toBe(true)
expect(loaded.has('/home/user/proj-b')).toBe(true)
expect(loaded.size).toBe(2)
})
it('getFavs returns empty set on malformed JSON', () => {
localStorage.setItem('web-terminal:proj-favs', 'NOT_JSON{{{')
expect(getFavs().size).toBe(0)
})
it('getFavs returns empty set when stored value is not an array', () => {
localStorage.setItem('web-terminal:proj-favs', JSON.stringify({ not: 'an-array' }))
expect(getFavs().size).toBe(0)
})
it('getFavs returns empty set when stored null', () => {
localStorage.setItem('web-terminal:proj-favs', 'null')
expect(getFavs().size).toBe(0)
})
it('saveFavs persists an empty set (removes all favs)', () => {
saveFavs(new Set(['/a']))
saveFavs(new Set())
expect(getFavs().size).toBe(0)
})
it('getFavs drops non-string entries from a corrupt/tampered array', () => {
localStorage.setItem('web-terminal:proj-favs', JSON.stringify(['/a', 42, null, '/b', { x: 1 }]))
const favs = getFavs()
expect([...favs].sort()).toEqual(['/a', '/b'])
})
})
/* ── normalizeProject (untrusted /projects element coercion) ────────────────── */
describe('normalizeProject', () => {
it('passes a well-formed project through, defaulting sessions to []', () => {
const p = normalizeProject({ name: 'web', path: '/home/u/web', isGit: true })
expect(p).toEqual({ name: 'web', path: '/home/u/web', isGit: true, sessions: [] })
})
it('preserves a sessions array and optional fields', () => {
const sess = { id: 's1', status: 'idle', clientCount: 0, createdAt: 1, exited: false }
const p = normalizeProject({
name: 'web', path: '/p', isGit: true, branch: 'main', dirty: true, lastActiveMs: 99, sessions: [sess],
})
expect(p?.branch).toBe('main')
expect(p?.dirty).toBe(true)
expect(p?.lastActiveMs).toBe(99)
expect(p?.sessions).toHaveLength(1)
})
it('coerces a missing sessions field to [] (prevents card-render TypeError)', () => {
const p = normalizeProject({ name: 'web', path: '/p', isGit: true, sessions: undefined })
expect(p?.sessions).toEqual([])
})
it('returns null for a non-object', () => {
expect(normalizeProject(null)).toBeNull()
expect(normalizeProject('x')).toBeNull()
expect(normalizeProject(42)).toBeNull()
})
it('returns null when name or path is missing/non-string', () => {
expect(normalizeProject({ path: '/p' })).toBeNull()
expect(normalizeProject({ name: 'web' })).toBeNull()
expect(normalizeProject({ name: 1, path: '/p' })).toBeNull()
})
it('drops optional fields of the wrong type', () => {
const p = normalizeProject({ name: 'web', path: '/p', branch: 5, dirty: 'yes', lastActiveMs: 'x' })
expect(p?.branch).toBeUndefined()
expect(p?.dirty).toBeUndefined()
expect(p?.lastActiveMs).toBeUndefined()
expect(p?.isGit).toBe(false)
})
})

349
test/projects.test.ts Normal file
View File

@@ -0,0 +1,349 @@
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()
})
})

View File

@@ -2,10 +2,10 @@
/**
* test/tabs.test.ts — frontend TabApp unit tests.
*
* Mocks TerminalSession + the launcher so we test TabApp's tab lifecycle in
* isolation: the v0.5 "close last tab → launcher" invariant and that addEntry
* builds a real (non-null) session before pushing the entry (Phase 1#5 — the
* `null as unknown as TerminalSession` hole is gone).
* Mocks TerminalSession + the launcher + projects panel so we test TabApp's
* tab lifecycle in isolation: the v0.5 "close last tab → launcher" invariant,
* that addEntry builds a real (non-null) session before pushing the entry
* (Phase 1#5), and the v0.6 openProject / home segmented-control wiring (P6).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
@@ -19,6 +19,8 @@ class FakeTerminalSession {
claudeStatus = 'unknown'
cwd: string | null = null
pendingApproval = false
/** Captured so tests can assert initialInput ends with \r (Finding 1). */
initialInput: string | undefined = undefined
connect = vi.fn()
dispose = vi.fn()
show = vi.fn()
@@ -41,13 +43,16 @@ class FakeTerminalSession {
static instances: FakeTerminalSession[] = []
constructor(opts: {
sessionId: string | null
initialInput?: string
onClaudeStatus?: (s: string, d?: string) => void
onStatus?: (s: string) => void
onActivity?: () => void
onTitle?: (t: string) => void
[key: string]: unknown
}) {
constructed += 1
this.id = opts.sessionId
this.initialInput = opts.initialInput
this.el = document.createElement('div')
this.cbs = opts
FakeTerminalSession.instances.push(this)
@@ -66,6 +71,16 @@ const mountLauncher = vi.fn(() => ({
}))
vi.mock('../public/launcher.js', () => ({ mountLauncher }))
// ── Mock the projects panel (records visibility) ──────────────────────────────
const projectsVisible = { value: false }
const mountProjects = vi.fn(() => ({
setVisible: (v: boolean) => {
projectsVisible.value = v
},
refresh: vi.fn(),
}))
vi.mock('../public/projects.js', () => ({ mountProjects }))
// settings.js is light but imports nothing heavy; let it load for real.
const { TabApp } = await import('../public/tabs.js')
@@ -81,6 +96,7 @@ beforeEach(() => {
constructed = 0
FakeTerminalSession.instances = []
launcherVisible.value = false
projectsVisible.value = false
document.body.replaceChildren()
localStorage.clear()
})
@@ -201,6 +217,22 @@ describe('TabApp — multi-tab lifecycle', () => {
expect(app.snapshot()).toHaveLength(1)
})
it('newTabForResume initialInput ends with \\r so the shell auto-executes it (Finding 1)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTabForResume('/work', '33333333-3333-4333-8333-333333333333')
const session = FakeTerminalSession.instances[0]!
expect(session.initialInput).toBe('claude --resume 33333333-3333-4333-8333-333333333333\r')
})
it('openProject default initialInput is "claude\\r" so the shell auto-executes it (A3/Finding 1)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/path/to/myrepo', 'myrepo')
const session = FakeTerminalSession.instances[0]!
expect(session.initialInput).toBe('claude\r')
})
it('snapshot reflects per-tab connection + claude status fields', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
@@ -346,3 +378,88 @@ describe('TabApp — DOM interactions on the tab bar', () => {
expect(app.snapshot()).toHaveLength(2)
})
})
describe('TabApp — P6 openProject + home segmented control', () => {
it('openProject creates a tab with the repo name as its displayed title', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/path/to/myrepo', 'myrepo')
expect(app.snapshot()).toHaveLength(1)
expect(app.snapshot()[0]!.title).toBe('myrepo')
})
it('a second openProject with the same name gets a #2 suffix', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/path/to/myrepo', 'myrepo')
app.openProject('/path/to/myrepo', 'myrepo')
const snap = app.snapshot()
expect(snap).toHaveLength(2)
expect(snap[0]!.title).toBe('myrepo')
expect(snap[1]!.title).toBe('myrepo #2')
})
it('countOpenWithTitlePrefix increments correctly for three tabs with the same base name', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/p', 'proj')
app.openProject('/p', 'proj')
app.openProject('/p', 'proj')
const snap = app.snapshot()
expect(snap[0]!.title).toBe('proj')
expect(snap[1]!.title).toBe('proj #2')
expect(snap[2]!.title).toBe('proj #3')
})
it('openProject hides the segmented control and both panels (tab open)', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.openProject('/p/repo', 'repo')
const seg = paneHost.querySelector('.home-seg') as HTMLElement
expect(seg).not.toBeNull()
expect(seg.style.display).toBe('none')
expect(launcherVisible.value).toBe(false)
expect(projectsVisible.value).toBe(false)
})
it('home segmented control shows both panels depending on selected view', () => {
const { paneHost, tabBar } = makeHosts()
new TabApp(paneHost, tabBar)
// Initially: sessions view → launcher visible, projects hidden.
expect(launcherVisible.value).toBe(true)
expect(projectsVisible.value).toBe(false)
// Click the "Projects" button (second .home-seg-btn).
const projBtn = paneHost.querySelectorAll('.home-seg-btn')[1] as HTMLButtonElement
projBtn.click()
expect(launcherVisible.value).toBe(false)
expect(projectsVisible.value).toBe(true)
// Click the "Sessions" button (first .home-seg-btn).
const sessBtn = paneHost.querySelectorAll('.home-seg-btn')[0] as HTMLButtonElement
sessBtn.click()
expect(launcherVisible.value).toBe(true)
expect(projectsVisible.value).toBe(false)
})
it('closing the last tab shows the segmented control and restores home view', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
app.newTab()
const seg = paneHost.querySelector('.home-seg') as HTMLElement
expect(seg.style.display).toBe('none') // hidden while tab open
app.closeTab(0)
expect(seg.style.display).not.toBe('none') // back to home
expect(launcherVisible.value).toBe(true) // sessions view by default
})
it('openProject with an empty repoPath still opens a tab', () => {
const { paneHost, tabBar } = makeHosts()
const app = new TabApp(paneHost, tabBar)
expect(() => app.openProject('', 'bare-repo')).not.toThrow()
expect(app.snapshot()).toHaveLength(1)
expect(app.snapshot()[0]!.title).toBe('bare-repo')
})
})