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.
238 lines
8.6 KiB
TypeScript
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)
|
|
})
|
|
})
|