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:
144
test/integration/projects-endpoint.test.ts
Normal file
144
test/integration/projects-endpoint.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user