/** * 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 { 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//`. Returns the repo path. */ async function makeFakeGitRepo(parentDir: string, repoName: string): Promise { 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 } 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((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) }) })