/** * Integration test for GET /projects/pr (W3 PR + CI status chip). * * Starts a real HTTP server against a temp git repo and stubs `gh` with a PATH * shim: an executable script named `gh` in a temp bin/ dir prepended to PATH, so * the server's execFile('gh', …) resolves OUR script (which echoes canned JSON or * exits 1 with a canned stderr). Asserts: * - missing ?path → 400 * - non-git ?path → 404 (isValidGitDir three-prong) * - gh emits valid PR → 200 {availability:'ok', checks:…} * - gh exits 1 (no PR) → 200 {availability:'no-pr'} * - GH_ENABLED=0 → 200 {availability:'disabled'} and gh is NEVER spawned * * Determinism: PROJECT_SCAN_TTL=0 disables the gh module cache; _clearPrCache() * runs in afterEach; each test writes the exact gh shim it needs. */ import fs from 'node:fs/promises' import net from 'node:net' import os from 'node:os' import path from 'node:path' import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest' import { loadConfig } from '../../src/config.js' import { startServer } from '../../src/server.js' import { _clearPrCache } from '../../src/http/gh.js' import type { PrStatus } from '../../src/types.js' // ── helpers ─────────────────────────────────────────────────────────────────── 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) }) } async function makeFakeGitRepo(parentDir: string, repoName: string): Promise { const repoPath = path.join(parentDir, repoName) await fs.mkdir(path.join(repoPath, '.git'), { recursive: true }) await fs.writeFile(path.join(repoPath, '.git', 'HEAD'), 'ref: refs/heads/main\n', 'utf8') return repoPath } /** Write an executable `gh` shim into binDir with the given /bin/sh body. */ async function writeGhShim(binDir: string, body: string): Promise { const file = path.join(binDir, 'gh') await fs.writeFile(file, `#!/bin/sh\n${body}\n`, 'utf8') await fs.chmod(file, 0o755) } const okJson = JSON.stringify({ number: 7, state: 'OPEN', title: 'A pull request', url: 'https://github.com/o/r/pull/7', isDraft: false, mergeable: 'MERGEABLE', headRefName: 'main', baseRefName: 'main', statusCheckRollup: [ { __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' }, { __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' }, { __typename: 'CheckRun', status: 'IN_PROGRESS', conclusion: null }, ], }) // ── suite ───────────────────────────────────────────────────────────────────── describe('GET /projects/pr — integration', () => { let tmpRoot: string let repoPath: string let binDir: string let originalPath: string let serverHandle: { close(): Promise } | null = null beforeAll(async () => { tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-pr-test-')) repoPath = await makeFakeGitRepo(tmpRoot, 'fake-repo') binDir = path.join(tmpRoot, 'bin') await fs.mkdir(binDir, { recursive: true }) // Prepend our shim dir so execFile('gh', …) resolves OUR gh, not the host's. originalPath = process.env['PATH'] ?? '' process.env['PATH'] = `${binDir}${path.delimiter}${originalPath}` }) afterAll(async () => { process.env['PATH'] = originalPath await fs.rm(tmpRoot, { recursive: true, force: true }) }) afterEach(async () => { if (serverHandle !== null) { await serverHandle.close() serverHandle = null } _clearPrCache() }) /** Start a server with the given env overrides; returns its base URL. */ async function start(overrides: Record = {}): Promise { const port = await getFreePort() 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_SCAN_TTL: '0', // no gh cache between requests ...overrides, }) serverHandle = startServer(cfg) await new Promise((r) => setTimeout(r, 100)) return `http://127.0.0.1:${port}` } it('returns 400 when ?path is missing', async () => { const base = await start() const res = await fetch(`${base}/projects/pr`) expect(res.status).toBe(400) }) it('returns 404 for a non-git directory path', async () => { const base = await start() const res = await fetch(`${base}/projects/pr?path=${encodeURIComponent(tmpRoot)}`) expect(res.status).toBe(404) }) it('returns 200 {availability:ok} with a checks summary when gh emits PR JSON', async () => { await writeGhShim(binDir, `cat <<'JSON'\n${okJson}\nJSON`) const base = await start() const res = await fetch(`${base}/projects/pr?path=${encodeURIComponent(repoPath)}`) expect(res.status).toBe(200) const body = (await res.json()) as PrStatus expect(body.availability).toBe('ok') expect(body.number).toBe(7) expect(body.state).toBe('open') expect(body.checks).toEqual({ total: 3, passing: 2, failing: 0, pending: 1 }) }) it('returns 200 {availability:no-pr} when gh exits 1 with a no-PR stderr', async () => { await writeGhShim(binDir, 'echo "no pull requests found for branch \\"main\\"" 1>&2\nexit 1') const base = await start() const res = await fetch(`${base}/projects/pr?path=${encodeURIComponent(repoPath)}`) expect(res.status).toBe(200) const body = (await res.json()) as PrStatus expect(body.availability).toBe('no-pr') }) it('returns 200 {availability:disabled} and never spawns gh when GH_ENABLED=0', async () => { const marker = path.join(tmpRoot, 'gh-was-spawned') await fs.rm(marker, { force: true }) // This shim would create a marker if ever invoked. await writeGhShim(binDir, `touch "${marker}"\necho '{}'`) const base = await start({ GH_ENABLED: '0' }) const res = await fetch(`${base}/projects/pr?path=${encodeURIComponent(repoPath)}`) expect(res.status).toBe(200) const body = (await res.json()) as PrStatus expect(body).toEqual({ availability: 'disabled' }) // gh must never have run. await expect(fs.stat(marker)).rejects.toBeDefined() }) })