Files
web-terminal/test/gh-chip.test.ts
Yaojia Wang 7551f8a4b2 feat(projects): PR + CI/checks status chip via gh (W3)
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.
2026-07-12 21:01:18 +02:00

238 lines
8.6 KiB
TypeScript

// @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> = {}): 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 = '<img src=x onerror=alert(1)>'
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)
})
})