From 7551f8a4b2c86bb203fc6d8c2098f70efcff1269 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sun, 12 Jul 2026 21:01:18 +0200 Subject: [PATCH] feat(projects): PR + CI/checks status chip via gh (W3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-project chip: PR state · N checks passing · mergeable — glance from the phone, re-engage only when it's red, instead of dropping into a terminal for `gh pr checks`. - src/http/gh.ts (new): single `gh pr view --json number,state,title,url,isDraft, mergeable,headRefName,baseRefName,statusCheckRollup` (execFile, no shell, cwd = isValidGitDir repo, timeout + maxBuffer). summarizeChecks rolls the mixed CheckRun/StatusContext rollup into {total,passing,failing,pending}. Never throws — degrades to not-installed (ENOENT) / unauthenticated / no-pr / error / disabled. Cache keyed by repoPath+branch (reuses projectScanTtlMs) with in-flight dedupe. - src/types.ts: additive PrStatus/PrAvailability/PrCheckSummary; config GH_ENABLED (default on) + GH_TIMEOUT_MS (8s). - GET /projects/pr?path= (read-only, isValidGitDir); public/gh-chip.ts render-only chip mounted in the project detail header (git repos only). Read-only, host's own authed gh (same trust as the shell). No untrusted argv (only the validated cwd); gh stdout/token never logged; the attacker-controllable PR title is rendered inert via textContent (SEC-H4). Verified: typecheck + build:web clean, 1816 pass (gh tests 118). The 1 red is the known real-PTY ring-buffer timeout flake. --- public/gh-chip.ts | 262 ++++++++++++++++++++++++ public/projects.ts | Bin 36895 -> 37360 bytes public/style.css | 58 ++++++ src/config.ts | 8 + src/http/gh.ts | 316 +++++++++++++++++++++++++++++ src/server.ts | 27 +++ src/types.ts | 36 ++++ test/gh-chip.test.ts | 237 ++++++++++++++++++++++ test/http/gh.test.ts | 285 ++++++++++++++++++++++++++ test/integration/pr-status.test.ts | 175 ++++++++++++++++ test/projects-panel.test.ts | 14 ++ test/worktree-form.test.ts | 5 + 12 files changed, 1423 insertions(+) create mode 100644 public/gh-chip.ts create mode 100644 src/http/gh.ts create mode 100644 test/gh-chip.test.ts create mode 100644 test/http/gh.test.ts create mode 100644 test/integration/pr-status.test.ts diff --git a/public/gh-chip.ts b/public/gh-chip.ts new file mode 100644 index 0000000..c922ff8 --- /dev/null +++ b/public/gh-chip.ts @@ -0,0 +1,262 @@ +/** + * public/gh-chip.ts (W3 PR + CI status chip) — render-only, mirrors public/diff.ts. + * + * Receives a pre-parsed PrStatus from the server (GET /projects/pr) and renders a + * compact chip: PR state · N checks passing · mergeable. Zero PR parsing lives + * here (parsing is in src/http/gh.ts). It NEVER throws and degrades to a + * self-explaining chip when gh is missing / unauthenticated / has no PR. + * + * Security: SEC-H4 — ALL text content is set via textContent / el(). Zero + * innerHTML anywhere in this file. A PR `title` is attacker-controllable (anyone + * who can open a PR on a repo the host can read); it appears as literal text. + */ + +import type { PrAvailability, PrCheckSummary, PrStatus } from '../src/types.js' + +/* ── constants ───────────────────────────────────────────────────────────────── */ + +const GH_INSTALL_URL = 'https://cli.github.com' +const VALID_AVAILABILITY: ReadonlySet = new Set([ + 'ok', + 'no-pr', + 'not-installed', + 'unauthenticated', + 'disabled', + 'error', +]) + +/* ── DOM helper ──────────────────────────────────────────────────────────────── */ + +/** Create an element with an optional CSS class and text content. */ +function el( + tag: K, + cls?: string, + text?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag) + if (cls) node.className = cls + if (text !== undefined) node.textContent = text + return node +} + +/* ── normalizePrStatus ───────────────────────────────────────────────────────── */ + +function normalizeChecks(raw: unknown): PrCheckSummary | undefined { + if (raw === null || typeof raw !== 'object') return undefined + const o = raw as Record + if ( + typeof o['total'] !== 'number' || + typeof o['passing'] !== 'number' || + typeof o['failing'] !== 'number' || + typeof o['pending'] !== 'number' + ) { + return undefined + } + return { + total: o['total'], + passing: o['passing'], + failing: o['failing'], + pending: o['pending'], + } +} + +/** + * Coerce an untrusted API response into a PrStatus, or return null. Never throws. + * Mirrors normalizeDiffResult (diff.ts): a non-object or an unknown `availability` + * ⇒ null; otherwise the known optional fields are copied when well-typed. + */ +export function normalizePrStatus(raw: unknown): PrStatus | null { + if (raw === null || typeof raw !== 'object') return null + const o = raw as Record + if (typeof o['availability'] !== 'string' || !VALID_AVAILABILITY.has(o['availability'])) { + return null + } + const status: PrStatus = { availability: o['availability'] as PrAvailability } + if (typeof o['number'] === 'number') status.number = o['number'] + if (typeof o['title'] === 'string') status.title = o['title'] + if (typeof o['url'] === 'string') status.url = o['url'] + if (o['state'] === 'open' || o['state'] === 'closed' || o['state'] === 'merged') { + status.state = o['state'] + } + if (typeof o['isDraft'] === 'boolean') status.isDraft = o['isDraft'] + if ( + o['mergeable'] === 'mergeable' || + o['mergeable'] === 'conflicting' || + o['mergeable'] === 'unknown' + ) { + status.mergeable = o['mergeable'] + } + if (typeof o['headRefName'] === 'string') status.headRefName = o['headRefName'] + if (typeof o['baseRefName'] === 'string') status.baseRefName = o['baseRefName'] + const checks = normalizeChecks(o['checks']) + if (checks !== undefined) status.checks = checks + return status +} + +/* ── fetchPrStatus ───────────────────────────────────────────────────────────── */ + +/** + * Fetch the PR + CI status for a repo path. Returns null on any error or invalid + * response (mirrors fetchDiff) — the caller degrades that to an 'error' chip. + */ +export async function fetchPrStatus(repoPath: string): Promise { + try { + const res = await fetch(`/projects/pr?path=${encodeURIComponent(repoPath)}`) + if (!res.ok) return null + const data: unknown = await res.json() + return normalizePrStatus(data) + } catch { + return null + } +} + +/* ── chipText (pure) ─────────────────────────────────────────────────────────── */ + +/** The rendered chip's label, modifier CSS classes, and tooltip title. `null` + * when the chip should be hidden (availability 'disabled'). */ +export interface ChipText { + label: string + cls: string + title: string +} + +/** PR-number prefix, reflecting draft / merged / closed state. */ +function statePrefix(status: PrStatus): string { + const n = typeof status.number === 'number' ? `#${status.number}` : '' + if (status.isDraft === true) return `Draft ${n}`.trim() + if (status.state === 'merged') return `Merged ${n}`.trim() + if (status.state === 'closed') return `Closed ${n}`.trim() + return `PR ${n}`.trim() +} + +/** State → modifier class (draft wins over the raw open/closed/merged state). */ +function stateClass(status: PrStatus): string { + if (status.isDraft === true) return 'proj-pr-draft' + if (status.state === 'merged') return 'proj-pr-merged' + if (status.state === 'closed') return 'proj-pr-closed' + return 'proj-pr-open' +} + +/** Checks segment (glyph + passing/total) and its class; null when total === 0. */ +function checksSegment(checks: PrCheckSummary): { text: string; cls: string } | null { + if (checks.total <= 0) return null + if (checks.failing > 0) { + return { text: `✕ ${checks.passing}/${checks.total}`, cls: 'proj-pr-checks-fail' } + } + if (checks.pending > 0) { + return { text: `⧗ ${checks.passing}/${checks.total}`, cls: 'proj-pr-checks-pending' } + } + return { text: `✓ ${checks.passing}/${checks.total}`, cls: 'proj-pr-checks-ok' } +} + +const DEGRADED: Record, ChipText> = { + 'no-pr': { label: 'No PR', cls: 'proj-pr-none', title: 'No pull request for the current branch' }, + 'not-installed': { + label: 'gh not installed', + cls: 'proj-pr-unavailable', + title: `Install the GitHub CLI to see PR status: ${GH_INSTALL_URL}`, + }, + unauthenticated: { + label: 'gh auth login', + cls: 'proj-pr-unavailable', + title: 'Run `gh auth login` on the host to see PR status', + }, + error: { + label: 'PR status unavailable', + cls: 'proj-pr-unavailable', + title: 'Could not read PR status', + }, +} + +/** + * Pure map from a PrStatus to the chip's {label, cls, title}. Returns null for + * 'disabled' (the chip is hidden). For 'ok' the label combines the PR-state + * prefix, the checks glyph (✓ / ✕ / ⧗ passing/total), and a "⚠ conflicts" marker + * when mergeable is 'conflicting'; cls collects the matching modifier classes. + */ +export function chipText(status: PrStatus): ChipText | null { + if (status.availability === 'disabled') return null + if (status.availability !== 'ok') return DEGRADED[status.availability] + + const parts: string[] = [statePrefix(status)] + const classes: string[] = [stateClass(status)] + + if (status.checks !== undefined) { + const seg = checksSegment(status.checks) + if (seg !== null) { + parts.push(seg.text) + classes.push(seg.cls) + } + } + if (status.mergeable === 'conflicting') { + parts.push('⚠ conflicts') + classes.push('proj-pr-conflict') + } + + return { + label: parts.join(' '), + cls: classes.join(' '), + title: status.title !== undefined && status.title !== '' ? status.title : parts[0] ?? 'PR', + } +} + +/* ── renderPrChip ────────────────────────────────────────────────────────────── */ + +/** + * Render a PrStatus into a chip element. 'disabled' ⇒ an empty display:none span. + * + * Security: SEC-H4 — all text via textContent. The PR `title` (attacker- + * controllable) is set as the tooltip attribute AND appended as an inert text + * span; it can never inject an element. + */ +export function renderPrChip(status: PrStatus): HTMLElement { + const info = chipText(status) + if (info === null) { + const hidden = el('span', 'proj-pr-chip proj-pr-hidden') + hidden.style.display = 'none' + return hidden + } + + const chip = el('span', `proj-pr-chip ${info.cls}`) + chip.title = info.title // attribute — never HTML-parsed (SEC-H4) + chip.append(el('span', 'proj-pr-label', info.label)) + + // The PR title is attacker-controllable; render it as literal text (SEC-H4). + if (status.availability === 'ok' && typeof status.title === 'string' && status.title !== '') { + chip.append(el('span', 'proj-pr-title', status.title)) + } + return chip +} + +/* ── mountPrChip ─────────────────────────────────────────────────────────────── */ + +/** Handle returned by mountPrChip for cleanup. */ +export interface PrChipHandle { + destroy(): void +} + +/** + * Mount a PR-status chip into `container`: show a loading placeholder, fetch the + * status, then swap in the resolved chip. A fetch failure degrades to an 'error' + * chip (never throws). destroy() removes the node and cancels the swap. + */ +export function mountPrChip(container: HTMLElement, repoPath: string): PrChipHandle { + let destroyed = false + + container.textContent = '' + container.append(el('span', 'proj-pr-chip proj-pr-loading', '…')) + + void (async () => { + const status = await fetchPrStatus(repoPath) + if (destroyed) return + container.textContent = '' + container.append(renderPrChip(status ?? { availability: 'error' })) + })() + + return { + destroy() { + destroyed = true + container.textContent = '' + }, + } +} diff --git a/public/projects.ts b/public/projects.ts index 5d76add4c79826845184a2910833c51ce137d754..b4a7f874dd85def844022c711c9167ef10e5f1b1 100644 GIT binary patch delta 364 zcmXYtF-rq67>4l_2WMSed~s=Odv}9d#i>i3lrCLFnqI!U1g}X*a>XJd;vW!h6%_pg zLI?kcKScZqroB#i6Q1vV`11B1{e4DXTRYt=I}pKR%|T8KCc z(DHZt=jIT|v~nKCjwtWr9-_aQt3RgSk+Jp5^+7n6!)y>erYXj!0T`i+!3CUWzH(T# zptVbO + +// ── gh runner (injectable seam — default real, overridable in tests) ────────── + +/** Normalized outcome of one gh spawn. `code` carries a spawn/system error code + * ('ENOENT', 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') or the numeric exit code. */ +export interface GhExecResult { + ok: boolean // process exited 0 + stdout: string + stderr: string + code?: string | number +} + +/** A gh runner: same seam idea as getDiff's runGit, so getPrStatus is unit-testable + * without spawning gh. */ +export type GhRunner = ( + repoPath: string, + args: readonly string[], + timeoutMs: number, + maxBytes: number, +) => Promise + +function asString(v: unknown): string { + return typeof v === 'string' ? v : '' +} + +/** + * Real gh runner: execFile('gh', [...]) with NO shell, bounded by timeout + + * maxBuffer. A spawn ENOENT (gh not installed), a non-zero exit (unauth / no PR), + * a timeout, or a maxBuffer overflow all resolve (never reject) with ok:false so + * the classifier can degrade — mirroring diff.ts's best-effort house style. + */ +async function realRunGh( + repoPath: string, + args: readonly string[], + timeoutMs: number, + maxBytes: number, +): Promise { + try { + const { stdout, stderr } = await execFileAsync('gh', [...args], { + cwd: repoPath, + timeout: timeoutMs, + maxBuffer: maxBytes, + }) + return { ok: true, stdout, stderr, code: 0 } + } catch (err: unknown) { + const e = err as { code?: unknown; stdout?: unknown; stderr?: unknown } + const code = + typeof e.code === 'string' || typeof e.code === 'number' ? e.code : undefined + return { ok: false, stdout: asString(e.stdout), stderr: asString(e.stderr), code } + } +} + +// ── classifyGhFailure (pure) ────────────────────────────────────────────────── + +/** A gh non-zero / spawn failure, reduced to what the classifier reads. */ +export interface GhFailure { + code?: string | number + stderr: string +} + +/** + * Map a gh failure to a degrade reason (regex on lower-cased stderr). ENOENT ⇒ + * gh isn't installed; an auth pattern ⇒ not logged in; a "no PR / no remote" + * pattern ⇒ no PR for the branch; anything else (timeout, maxBuffer overflow, + * unknown) ⇒ generic error. Never throws. + */ +export function classifyGhFailure(f: GhFailure): PrAvailability { + if (f.code === 'ENOENT') return 'not-installed' + const s = asString(f.stderr).toLowerCase() + if (/gh auth login|not logged|authentication|http 401/.test(s)) return 'unauthenticated' + if (/no pull requests found|no default remote|no git remote/.test(s)) return 'no-pr' + return 'error' +} + +// ── summarizeChecks (pure) ──────────────────────────────────────────────────── + +type CheckBucket = 'passing' | 'failing' | 'pending' + +const CHECKRUN_PASS = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']) +const CHECKRUN_FAIL = new Set([ + 'FAILURE', + 'TIMED_OUT', + 'CANCELLED', + 'ACTION_REQUIRED', + 'STARTUP_FAILURE', + 'STALE', +]) +const CONTEXT_FAIL = new Set(['FAILURE', 'ERROR']) + +/** A CheckRun's status/conclusion → bucket. A non-COMPLETED status (QUEUED, + * IN_PROGRESS, WAITING, PENDING, REQUESTED) is pending regardless of conclusion; + * a completed/absent status is bucketed by conclusion (null/unknown → pending). */ +function bucketCheckRun(status: unknown, conclusion: unknown): CheckBucket { + const st = asString(status).toUpperCase() + if (st !== '' && st !== 'COMPLETED') return 'pending' + const c = asString(conclusion).toUpperCase() + if (CHECKRUN_PASS.has(c)) return 'passing' + if (CHECKRUN_FAIL.has(c)) return 'failing' + return 'pending' +} + +/** A StatusContext's state → bucket (SUCCESS pass, FAILURE/ERROR fail, else pending). */ +function bucketContext(state: unknown): CheckBucket { + const s = asString(state).toUpperCase() + if (s === 'SUCCESS') return 'passing' + if (CONTEXT_FAIL.has(s)) return 'failing' + return 'pending' // PENDING, EXPECTED, unknown +} + +/** Classify one rollup item. Prefers __typename, else infers from present keys. + * Anything unrecognized still counts toward total, treated as pending. */ +function bucketItem(raw: unknown): CheckBucket { + if (raw === null || typeof raw !== 'object') return 'pending' + const o = raw as Record + const typename = asString(o['__typename']) + const isContext = + typename === 'StatusContext' || + (typename !== 'CheckRun' && + o['state'] !== undefined && + o['status'] === undefined && + o['conclusion'] === undefined) + if (isContext) return bucketContext(o['state']) + if (typename === 'CheckRun' || o['status'] !== undefined || o['conclusion'] !== undefined) { + return bucketCheckRun(o['status'], o['conclusion']) + } + return 'pending' +} + +/** + * Roll up gh's `statusCheckRollup` (a mix of CheckRun + StatusContext items) into + * total / passing / failing / pending counts. Non-array / empty / undefined ⇒ + * all-zero. Every item lands in exactly one bucket so total === pass+fail+pending. + * Never throws. + */ +export function summarizeChecks(rollup: unknown): PrCheckSummary { + const summary: PrCheckSummary = { total: 0, passing: 0, failing: 0, pending: 0 } + if (!Array.isArray(rollup)) return summary + for (const item of rollup) { + summary.total += 1 + summary[bucketItem(item)] += 1 + } + return summary +} + +// ── parsePrView (pure) ──────────────────────────────────────────────────────── + +function mapState(v: unknown): PrStatus['state'] { + if (typeof v !== 'string') return undefined + const s = v.toLowerCase() + return s === 'open' || s === 'closed' || s === 'merged' ? s : undefined +} + +function mapMergeable(v: unknown): PrStatus['mergeable'] { + if (typeof v !== 'string') return undefined + const s = v.toLowerCase() + return s === 'mergeable' || s === 'conflicting' || s === 'unknown' ? s : undefined +} + +/** + * Parse `gh pr view --json …` output into a PrStatus. Valid PR JSON ⇒ + * availability:'ok' with lower-cased state/mergeable and a checks summary; a + * malformed / non-object payload ⇒ {availability:'error'} (never throws — the + * diff.ts "never throws" house style). Attacker-controllable strings (title/url) + * are carried VERBATIM — the FE renders them inert via textContent (SEC-H4). + */ +export function parsePrView(json: string): PrStatus { + let raw: unknown + try { + raw = JSON.parse(json) + } catch { + return { availability: 'error' } + } + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + return { availability: 'error' } + } + const o = raw as Record + const status: PrStatus = { availability: 'ok' } + if (typeof o['number'] === 'number') status.number = o['number'] + if (typeof o['title'] === 'string') status.title = o['title'] + if (typeof o['url'] === 'string') status.url = o['url'] + const state = mapState(o['state']) + if (state !== undefined) status.state = state + if (typeof o['isDraft'] === 'boolean') status.isDraft = o['isDraft'] + const mergeable = mapMergeable(o['mergeable']) + if (mergeable !== undefined) status.mergeable = mergeable + if (typeof o['headRefName'] === 'string') status.headRefName = o['headRefName'] + if (typeof o['baseRefName'] === 'string') status.baseRefName = o['baseRefName'] + status.checks = summarizeChecks(o['statusCheckRollup']) + return status +} + +// ── current-branch read (cheap; cache-key input) ────────────────────────────── + +/** Current branch from `/.git/HEAD` (ref line only). Detached HEAD / junk / + * unreadable ⇒ null. Cheap: no spawn (mirrors projects.ts readBranch/parseGitHead). */ +async function readHeadBranch(repoPath: string): Promise { + try { + const head = await fs.readFile(path.join(repoPath, '.git', 'HEAD'), 'utf8') + const match = /^ref:\s+refs\/heads\/(.+)$/.exec(head.trim()) + const branch = match?.[1]?.trim() + return branch !== undefined && branch !== '' ? branch : null + } catch { + return null + } +} + +// ── getPrStatus (cached runner) ─────────────────────────────────────────────── + +interface PrCacheEntry { + expiresAt: number + status: PrStatus +} + +const prCache = new Map() +/** Shared in-flight promises so concurrent cache-miss callers join one gh run. */ +const inflightPr = new Map>() + +/** repoPath + branch: a branch switch changes the key, busting stale PR data. */ +function cacheKey(repoPath: string, branch: string | null): string { + return `${repoPath}\n${branch ?? ''}` +} + +async function runPrStatus( + repoPath: string, + cfg: GhOptions, + runner: GhRunner, +): Promise { + const exec = await runner( + repoPath, + ['pr', 'view', '--json', PR_VIEW_FIELDS], + cfg.ghTimeoutMs, + cfg.diffMaxBytes, + ) + if (exec.ok) return parsePrView(exec.stdout) + return { availability: classifyGhFailure({ code: exec.code, stderr: exec.stderr }) } +} + +/** + * Read a repo's PR + CI status. `repoPath` must already be a validated absolute + * git directory (route layer, SEC-H7). Best-effort: gh missing / unauthed / no PR + * / timeout all resolve to a PrStatus whose `availability` names the reason — + * NEVER throws, never blocks the caller. Cached at module scope with + * cfg.projectScanTtlMs and in-flight dedupe. `runner` is injectable for tests. + */ +export async function getPrStatus( + repoPath: string, + cfg: GhOptions, + runner: GhRunner = realRunGh, +): Promise { + if (!cfg.ghEnabled) return { availability: 'disabled' } + + const branch = await readHeadBranch(repoPath) + const key = cacheKey(repoPath, branch) + const now = Date.now() + + const cached = prCache.get(key) + if (cached !== undefined && cached.expiresAt > now) return cached.status + + const inflight = inflightPr.get(key) + if (inflight !== undefined) return inflight + + const promise = runPrStatus(repoPath, cfg, runner) + .then((status) => { + prCache.set(key, { expiresAt: Date.now() + cfg.projectScanTtlMs, status }) + inflightPr.delete(key) + return status + }) + .catch((e: unknown) => { + inflightPr.delete(key) + throw e + }) + inflightPr.set(key, promise) + return promise +} + +/** Test-only: drop the PR cache and any in-flight run so each test sees a fresh gh call. */ +export function _clearPrCache(): void { + prCache.clear() + inflightPr.clear() +} diff --git a/src/server.ts b/src/server.ts index 2d5d984..3dbab90 100644 --- a/src/server.ts +++ b/src/server.ts @@ -40,6 +40,7 @@ import { listSessions } from './http/history.js' import { buildProjects, buildProjectDetail } from './http/projects.js' import { openInEditor, openFileInEditor } from './http/editor.js' import { getDiff, isPlausibleRev } from './http/diff.js' +import { getPrStatus } from './http/gh.js' import { parseStatusLine } from './http/statusline.js' import { createWorktree } from './http/worktrees.js' import { createSessionManager } from './session/manager.js' @@ -819,6 +820,32 @@ export function startServer(cfg: Config): { close(): Promise } { } }) + // ── W3 read-only PR + CI status (no Origin guard; same threat model as /projects) ─ + // Out-of-band side-channel: spawns the host's `gh` CLI to read the current + // branch's PR + statusCheckRollup. Unlike the local git side-channels, gh makes + // a NETWORK call to GitHub using the host's own gh/GH_TOKEN credential — this + // route NEVER accepts or forwards a token, only triggers gh's own auth, and + // GH_ENABLED=0 disables it entirely. Always 200 on a valid git dir: every + // degrade (gh missing / unauthed / no PR / disabled) lives in the response body + // (PrStatus.availability), not the HTTP status, so the FE renders one chip. + app.get('/projects/pr', async (req, res) => { + const target = req.query['path'] + if (typeof target !== 'string' || target === '') { + res.status(400).json({ error: 'path query parameter is required' }) + return + } + if (!(await isValidGitDir(target))) { + res.status(404).json({ error: 'project not found' }) // SEC-H7 three-prong + return + } + try { + res.json(await getPrStatus(target, cfg)) + } catch (err) { + console.error('[server] /projects/pr failed:', err instanceof Error ? err.message : String(err)) + res.status(500).json({ error: 'failed to read PR status' }) + } + }) + // ── B2 statusLine telemetry ingest (loopback only, SEC-H1) ──────────────── app.post('/hook/status', express.json({ limit: '64kb' }), (req, res) => { if (!isLoopback(req.socket.remoteAddress ?? '')) { diff --git a/src/types.ts b/src/types.ts index 7a4a10c..7c909d7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -61,6 +61,9 @@ export interface Config { readonly diffTimeoutMs: number; // DIFF_TIMEOUT_MS, default 2000 readonly diffMaxBytes: number; // DIFF_MAX_BYTES, default 2MB readonly diffMaxFiles: number; // DIFF_MAX_FILES, default 300 + // W3 PR + CI status chip (gh) + readonly ghEnabled: boolean; // GH_ENABLED, default true (false → never spawns gh) + readonly ghTimeoutMs: number; // GH_TIMEOUT_MS, default 8000 (network — larger than diff) // B2 statusLine telemetry readonly statuslineTtlMs: number; // STATUSLINE_TTL_MS, default 30000 // B3 git worktree creation @@ -535,6 +538,39 @@ export interface DiffResult { base?: string; // echoed when the diff was against a base revision (?base=) } +/* ── W3 PR + CI status chip (gh) ── */ + +/** Why a PrStatus has (or lacks) PR data. Drives the FE chip's degraded text. */ +export type PrAvailability = + | 'ok' // a PR exists for the current branch; fields below are populated + | 'no-pr' // gh works but the branch has no PR (or no remote/default repo) + | 'not-installed' // `gh` binary not found on PATH (ENOENT) + | 'unauthenticated' // gh present but not logged in (needs `gh auth login`) + | 'disabled' // GH_ENABLED=0 — feature off, never spawns gh + | 'error'; // gh spawned but failed for another reason (timeout, etc.) + +/** Rolled-up CI check counts from gh's statusCheckRollup (CheckRun + StatusContext). */ +export interface PrCheckSummary { + total: number; + passing: number; // CheckRun conclusion SUCCESS/NEUTRAL/SKIPPED | StatusContext SUCCESS + failing: number; // FAILURE/TIMED_OUT/CANCELLED/ACTION_REQUIRED | ERROR/FAILURE + pending: number; // QUEUED/IN_PROGRESS/WAITING | PENDING/EXPECTED +} + +/** GET /projects/pr result. Only present-when-'ok' fields are optional. */ +export interface PrStatus { + availability: PrAvailability; + number?: number; + title?: string; + url?: string; + state?: 'open' | 'closed' | 'merged'; // lower-cased from gh OPEN/CLOSED/MERGED + isDraft?: boolean; + mergeable?: 'mergeable' | 'conflicting' | 'unknown'; // lower-cased from gh + headRefName?: string; + baseRefName?: string; + checks?: PrCheckSummary; +} + /* ── B3 worktree creation (§3.5) ── */ /** Result of POST /projects/worktree (B3). `error` carries a safe message only diff --git a/test/gh-chip.test.ts b/test/gh-chip.test.ts new file mode 100644 index 0000000..e5dfee9 --- /dev/null +++ b/test/gh-chip.test.ts @@ -0,0 +1,237 @@ +// @vitest-environment jsdom +/** + * test/gh-chip.test.ts (W3 PR + CI status chip) — render-only frontend module. + * + * Covers normalizePrStatus, chipText, renderPrChip, fetchPrStatus, mountPrChip. + * Security: SEC-H4 — all chip content via textContent; a PR title with markup is + * rendered as literal text (no element injection). + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { PrStatus } from '../src/types.js' + +const { + normalizePrStatus, + chipText, + renderPrChip, + fetchPrStatus, + mountPrChip, +} = await import('../public/gh-chip.js') + +function okStatus(over: Partial = {}): PrStatus { + return { + availability: 'ok', + number: 12, + title: 'A PR', + url: 'https://github.com/o/r/pull/12', + state: 'open', + isDraft: false, + mergeable: 'mergeable', + checks: { total: 5, passing: 5, failing: 0, pending: 0 }, + ...over, + } +} + +// ── normalizePrStatus ───────────────────────────────────────────────────────── + +describe('normalizePrStatus', () => { + it('round-trips a valid ok object', () => { + const s = normalizePrStatus(okStatus()) + expect(s).not.toBeNull() + expect(s!.availability).toBe('ok') + expect(s!.number).toBe(12) + expect(s!.state).toBe('open') + expect(s!.mergeable).toBe('mergeable') + expect(s!.checks).toEqual({ total: 5, passing: 5, failing: 0, pending: 0 }) + }) + + it('accepts every degrade availability', () => { + for (const availability of ['no-pr', 'not-installed', 'unauthenticated', 'disabled', 'error']) { + expect(normalizePrStatus({ availability })?.availability).toBe(availability) + } + }) + + it('returns null for a non-object or an unknown availability', () => { + expect(normalizePrStatus(null)).toBeNull() + expect(normalizePrStatus(42)).toBeNull() + expect(normalizePrStatus('ok')).toBeNull() + expect(normalizePrStatus({})).toBeNull() + expect(normalizePrStatus({ availability: 'bogus' })).toBeNull() + }) + + it('drops malformed optional fields but keeps availability', () => { + const s = normalizePrStatus({ availability: 'ok', number: 'nope', state: 'weird', checks: 5 }) + expect(s?.availability).toBe('ok') + expect(s?.number).toBeUndefined() + expect(s?.state).toBeUndefined() + expect(s?.checks).toBeUndefined() + }) +}) + +// ── chipText ────────────────────────────────────────────────────────────────── + +describe('chipText', () => { + it('renders an open PR with all checks passing', () => { + const t = chipText(okStatus())! + expect(t.label).toBe('PR #12 ✓ 5/5') + expect(t.cls).toContain('proj-pr-open') + expect(t.cls).toContain('proj-pr-checks-ok') + }) + + it('marks failing checks with ✕ and the fail class', () => { + const t = chipText(okStatus({ checks: { total: 5, passing: 3, failing: 2, pending: 0 } }))! + expect(t.label).toBe('PR #12 ✕ 3/5') + expect(t.cls).toContain('proj-pr-checks-fail') + }) + + it('marks pending checks with ⧗ and the pending class', () => { + const t = chipText(okStatus({ checks: { total: 4, passing: 1, failing: 0, pending: 3 } }))! + expect(t.label).toBe('PR #12 ⧗ 1/4') + expect(t.cls).toContain('proj-pr-checks-pending') + }) + + it('adds a "⚠ conflicts" marker + class for a conflicting PR', () => { + const t = chipText(okStatus({ mergeable: 'conflicting' }))! + expect(t.label).toContain('⚠ conflicts') + expect(t.cls).toContain('proj-pr-conflict') + }) + + it('labels a draft PR', () => { + const t = chipText(okStatus({ isDraft: true }))! + expect(t.label).toContain('Draft #12') + expect(t.cls).toContain('proj-pr-draft') + }) + + it('labels merged / closed PRs', () => { + expect(chipText(okStatus({ state: 'merged' }))!.label).toContain('Merged #12') + expect(chipText(okStatus({ state: 'closed' }))!.cls).toContain('proj-pr-closed') + }) + + it('omits the checks segment when there are zero checks', () => { + const t = chipText(okStatus({ checks: { total: 0, passing: 0, failing: 0, pending: 0 } }))! + expect(t.label).toBe('PR #12') + }) + + it('maps each degrade availability to its label', () => { + expect(chipText({ availability: 'no-pr' })!.label).toBe('No PR') + const notInstalled = chipText({ availability: 'not-installed' })! + expect(notInstalled.label).toBe('gh not installed') + expect(notInstalled.title).toContain('cli.github.com') + expect(chipText({ availability: 'unauthenticated' })!.label).toBe('gh auth login') + expect(chipText({ availability: 'error' })!.label).toBe('PR status unavailable') + }) + + it('returns null for a disabled status (chip hidden)', () => { + expect(chipText({ availability: 'disabled' })).toBeNull() + }) +}) + +// ── renderPrChip ────────────────────────────────────────────────────────────── + +describe('renderPrChip', () => { + it('renders a chip element with the label and state class', () => { + const chip = renderPrChip(okStatus()) + expect(chip.className).toContain('proj-pr-chip') + expect(chip.className).toContain('proj-pr-open') + expect(chip.querySelector('.proj-pr-label')?.textContent).toBe('PR #12 ✓ 5/5') + }) + + it('renders a hidden chip for a disabled status', () => { + const chip = renderPrChip({ availability: 'disabled' }) + expect(chip.style.display).toBe('none') + expect(chip.querySelector('.proj-pr-label')).toBeNull() + }) + + it('renders an attacker PR title as LITERAL text — no element injection (SEC-H4)', () => { + const evil = '' + const chip = renderPrChip(okStatus({ title: evil })) + expect(chip.textContent).toContain(evil) + expect(chip.querySelector('img')).toBeNull() + // The tooltip attribute is also inert (attributes are never HTML-parsed). + expect(chip.title).toBe(evil) + }) +}) + +// ── fetchPrStatus ───────────────────────────────────────────────────────────── + +describe('fetchPrStatus', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('normalizes a 200 JSON response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, json: async () => okStatus() })), + ) + const s = await fetchPrStatus('/p/repo') + expect(s?.availability).toBe('ok') + expect(s?.number).toBe(12) + }) + + it('returns null on a non-ok response', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: false, json: async () => ({}) }))) + expect(await fetchPrStatus('/p/repo')).toBeNull() + }) + + it('returns null when fetch rejects (never throws)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('network down') + }), + ) + expect(await fetchPrStatus('/p/repo')).toBeNull() + }) +}) + +// ── mountPrChip ─────────────────────────────────────────────────────────────── + +describe('mountPrChip', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + let host: HTMLElement + beforeEach(() => { + host = document.createElement('span') + document.body.append(host) + }) + + it('shows a loading placeholder, then swaps in the resolved chip', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, json: async () => okStatus() })), + ) + const handle = mountPrChip(host, '/p/repo') + expect(host.querySelector('.proj-pr-loading')).not.toBeNull() + await new Promise((r) => setTimeout(r, 0)) + expect(host.querySelector('.proj-pr-loading')).toBeNull() + expect(host.querySelector('.proj-pr-open')).not.toBeNull() + handle.destroy() + }) + + it('degrades to an error chip when fetch rejects (no throw)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + throw new Error('boom') + }), + ) + mountPrChip(host, '/p/repo') + await new Promise((r) => setTimeout(r, 0)) + expect(host.querySelector('.proj-pr-unavailable')).not.toBeNull() + expect(host.textContent).toContain('PR status unavailable') + }) + + it('destroy() removes the chip node', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ ok: true, json: async () => okStatus() })), + ) + const handle = mountPrChip(host, '/p/repo') + handle.destroy() + await new Promise((r) => setTimeout(r, 0)) + expect(host.childNodes.length).toBe(0) + }) +}) diff --git a/test/http/gh.test.ts b/test/http/gh.test.ts new file mode 100644 index 0000000..33f8e84 --- /dev/null +++ b/test/http/gh.test.ts @@ -0,0 +1,285 @@ +/** + * test/http/gh.test.ts (W3 PR + CI status chip) — pure parsers + getPrStatus. + * + * Two layers (mirrors diff.test.ts): + * 1. Pure, deterministic: summarizeChecks / parsePrView / classifyGhFailure fed + * canned gh output — the bulk of gh.ts logic, fully covered without spawning + * gh. Never throws; ' + const s = parsePrView(JSON.stringify({ number: 1, state: 'OPEN', title: evil })) + expect(s.availability).toBe('ok') + expect(s.title).toBe(evil) + }) +}) + +// ── classifyGhFailure ───────────────────────────────────────────────────────── + +describe('classifyGhFailure', () => { + it('maps a spawn ENOENT to not-installed', () => { + expect(classifyGhFailure({ code: 'ENOENT', stderr: '' })).toBe('not-installed') + }) + + it('maps auth-related stderr to unauthenticated', () => { + for (const stderr of [ + 'run gh auth login to authenticate', + 'you are not logged into any GitHub hosts', + 'authentication required', + 'HTTP 401: Bad credentials', + ]) { + expect(classifyGhFailure({ code: 1, stderr })).toBe('unauthenticated') + } + }) + + it('maps no-PR / no-remote stderr to no-pr', () => { + for (const stderr of [ + 'no pull requests found for branch "feature/x"', + 'no default remote repository has been set', + 'no git remotes found', + ]) { + expect(classifyGhFailure({ code: 1, stderr })).toBe('no-pr') + } + }) + + it('maps any other non-zero exit (incl. maxBuffer overflow) to error', () => { + expect(classifyGhFailure({ code: 1, stderr: 'something exploded' })).toBe('error') + expect(classifyGhFailure({ code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER', stderr: '' })).toBe('error') + expect(classifyGhFailure({ code: undefined, stderr: '' })).toBe('error') + }) +}) + +// ── getPrStatus (cache / dedupe / branch-key / disabled) ────────────────────── + +/** A runner that resolves canned exec results and records call count. */ +function fakeRunner(result: GhExecResult): { runner: GhRunner; calls: () => number } { + const spy = vi.fn(async () => result) + return { runner: spy, calls: () => spy.mock.calls.length } +} + +async function writeHead(repoPath: string, branch: string): Promise { + await fs.writeFile(path.join(repoPath, '.git', 'HEAD'), `ref: refs/heads/${branch}\n`, 'utf8') +} + +describe('getPrStatus', () => { + let repoPath: string + + beforeEach(async () => { + _clearPrCache() + repoPath = await fs.mkdtemp(path.join(os.tmpdir(), 'webterm-gh-test-')) + await fs.mkdir(path.join(repoPath, '.git'), { recursive: true }) + await writeHead(repoPath, 'main') + }) + + afterEach(async () => { + _clearPrCache() + await fs.rm(repoPath, { recursive: true, force: true }) + }) + + it('resolves availability:disabled WITHOUT invoking the runner when ghEnabled:false', async () => { + const { runner, calls } = fakeRunner({ ok: true, stdout: okJson, stderr: '', code: 0 }) + const s = await getPrStatus(repoPath, { ...CFG, ghEnabled: false }, runner) + expect(s).toEqual({ availability: 'disabled' }) + expect(calls()).toBe(0) + }) + + it('returns parsed status and caches it (second call does not re-run gh)', async () => { + const { runner, calls } = fakeRunner({ ok: true, stdout: okJson, stderr: '', code: 0 }) + const first = await getPrStatus(repoPath, CFG, runner) + expect(first.availability).toBe('ok') + expect(first.number).toBe(12) + await getPrStatus(repoPath, CFG, runner) + expect(calls()).toBe(1) // served from cache + }) + + it('shares one in-flight run across two concurrent calls for the same path', async () => { + const { runner, calls } = fakeRunner({ ok: true, stdout: okJson, stderr: '', code: 0 }) + const [a, b] = await Promise.all([ + getPrStatus(repoPath, CFG, runner), + getPrStatus(repoPath, CFG, runner), + ]) + expect(a.availability).toBe('ok') + expect(b.availability).toBe('ok') + expect(calls()).toBe(1) + }) + + it('re-runs gh after _clearPrCache', async () => { + const { runner, calls } = fakeRunner({ ok: true, stdout: okJson, stderr: '', code: 0 }) + await getPrStatus(repoPath, CFG, runner) + _clearPrCache() + await getPrStatus(repoPath, CFG, runner) + expect(calls()).toBe(2) + }) + + it('busts the cache when the branch (.git/HEAD) changes', async () => { + const { runner, calls } = fakeRunner({ ok: true, stdout: okJson, stderr: '', code: 0 }) + await getPrStatus(repoPath, CFG, runner) + await writeHead(repoPath, 'other-branch') + await getPrStatus(repoPath, CFG, runner) + expect(calls()).toBe(2) // key includes the branch + }) + + it('degrades to no-pr when gh exits non-zero with a no-PR stderr (never throws)', async () => { + const { runner } = fakeRunner({ + ok: false, + stdout: '', + stderr: 'no pull requests found for branch "main"', + code: 1, + }) + const s = await getPrStatus(repoPath, CFG, runner) + expect(s).toEqual({ availability: 'no-pr' }) + }) + + it('degrades to not-installed on a spawn ENOENT', async () => { + const { runner } = fakeRunner({ ok: false, stdout: '', stderr: '', code: 'ENOENT' }) + const s = await getPrStatus(repoPath, CFG, runner) + expect(s).toEqual({ availability: 'not-installed' }) + }) +}) diff --git a/test/integration/pr-status.test.ts b/test/integration/pr-status.test.ts new file mode 100644 index 0000000..220e3be --- /dev/null +++ b/test/integration/pr-status.test.ts @@ -0,0 +1,175 @@ +/** + * 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() + }) +}) diff --git a/test/projects-panel.test.ts b/test/projects-panel.test.ts index 95ae75a..d9ddea4 100644 --- a/test/projects-panel.test.ts +++ b/test/projects-panel.test.ts @@ -20,6 +20,10 @@ class FakeTerminal { } vi.mock('@xterm/xterm', () => ({ Terminal: FakeTerminal })) +// Stub the PR-status chip (W3) so renderProjectDetail's header mount doesn't hit +// the network; the chip's own logic is covered in test/gh-chip.test.ts. +vi.mock('../public/gh-chip.js', () => ({ mountPrChip: vi.fn(() => ({ destroy: vi.fn() })) })) + // Dynamic import AFTER mock declaration so the factory is in place. const { filterProjects, @@ -489,6 +493,16 @@ describe('renderProjectDetail', () => { expect(root.textContent).toContain('Not a git repository') }) + it('mounts a PR-status chip host in the header for a git repo (W3)', () => { + const root = renderProjectDetail(detail(), hooks(), cbs()) + expect(root.querySelector('.proj-pr-host')).not.toBeNull() + }) + + it('omits the PR-status chip host for a non-git directory (W3)', () => { + const root = renderProjectDetail(detail({ isGit: false, branch: undefined }), hooks(), cbs()) + expect(root.querySelector('.proj-pr-host')).toBeNull() + }) + it('shows CLAUDE.md content + an Update button when present', () => { const root = renderProjectDetail(detail({ hasClaudeMd: true, claudeMd: '# Rules\nbe nice' }), hooks(), cbs()) expect(root.querySelector('.proj-claudemd')?.textContent).toContain('be nice') diff --git a/test/worktree-form.test.ts b/test/worktree-form.test.ts index f4e6cfd..5615201 100644 --- a/test/worktree-form.test.ts +++ b/test/worktree-form.test.ts @@ -35,6 +35,11 @@ const mockTimelineHandle = { dispose: vi.fn() } const mockMountTimeline = vi.fn(() => mockTimelineHandle) vi.mock('../public/timeline.js', () => ({ mountTimeline: mockMountTimeline })) +// ── Stub PR-status chip (W3) — renderProjectDetail mounts it for git repos ────── +const mockPrChipHandle = { destroy: vi.fn() } +const mockMountPrChip = vi.fn(() => mockPrChipHandle) +vi.mock('../public/gh-chip.js', () => ({ mountPrChip: mockMountPrChip })) + // ── Import AFTER mocks ───────────────────────────────────────────────────────── const { validateBranchNameClient, renderNewWorktreeForm, renderProjectDetail } = await import('../public/projects.js')