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.
This commit is contained in:
237
test/gh-chip.test.ts
Normal file
237
test/gh-chip.test.ts
Normal file
@@ -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> = {}): 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)
|
||||
})
|
||||
})
|
||||
285
test/http/gh.test.ts
Normal file
285
test/http/gh.test.ts
Normal file
@@ -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; <script> in a PR title survives verbatim.
|
||||
* 2. getPrStatus with an INJECTED fake runner (no real gh) against a throwaway
|
||||
* .git/HEAD in os.tmpdir — cache TTL, in-flight dedupe, branch-keyed busting,
|
||||
* and the GH_ENABLED=false short-circuit.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs/promises'
|
||||
import {
|
||||
summarizeChecks,
|
||||
parsePrView,
|
||||
classifyGhFailure,
|
||||
getPrStatus,
|
||||
_clearPrCache,
|
||||
type GhExecResult,
|
||||
type GhOptions,
|
||||
type GhRunner,
|
||||
} from '../../src/http/gh.js'
|
||||
|
||||
const CFG: GhOptions = {
|
||||
ghEnabled: true,
|
||||
ghTimeoutMs: 8000,
|
||||
projectScanTtlMs: 10_000,
|
||||
diffMaxBytes: 2 * 1024 * 1024,
|
||||
}
|
||||
|
||||
// ── summarizeChecks ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('summarizeChecks', () => {
|
||||
it('counts a completed CheckRun SUCCESS as passing', () => {
|
||||
const s = summarizeChecks([{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' }])
|
||||
expect(s).toEqual({ total: 1, passing: 1, failing: 0, pending: 0 })
|
||||
})
|
||||
|
||||
it('counts failing CheckRun conclusions as failing', () => {
|
||||
for (const conclusion of ['FAILURE', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED']) {
|
||||
const s = summarizeChecks([{ __typename: 'CheckRun', status: 'COMPLETED', conclusion }])
|
||||
expect(s.failing).toBe(1)
|
||||
expect(s.total).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('counts an in-progress / queued CheckRun (null conclusion) as pending', () => {
|
||||
for (const status of ['IN_PROGRESS', 'QUEUED']) {
|
||||
const s = summarizeChecks([{ __typename: 'CheckRun', status, conclusion: null }])
|
||||
expect(s.pending).toBe(1)
|
||||
expect(s.total).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('treats NEUTRAL and SKIPPED conclusions as passing (do not block)', () => {
|
||||
for (const conclusion of ['NEUTRAL', 'SKIPPED']) {
|
||||
const s = summarizeChecks([{ __typename: 'CheckRun', status: 'COMPLETED', conclusion }])
|
||||
expect(s.passing).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('buckets StatusContext by state', () => {
|
||||
expect(summarizeChecks([{ __typename: 'StatusContext', state: 'SUCCESS' }]).passing).toBe(1)
|
||||
expect(summarizeChecks([{ __typename: 'StatusContext', state: 'PENDING' }]).pending).toBe(1)
|
||||
expect(summarizeChecks([{ __typename: 'StatusContext', state: 'FAILURE' }]).failing).toBe(1)
|
||||
expect(summarizeChecks([{ __typename: 'StatusContext', state: 'ERROR' }]).failing).toBe(1)
|
||||
})
|
||||
|
||||
it('returns all-zero for an empty / undefined / non-array rollup', () => {
|
||||
const zero = { total: 0, passing: 0, failing: 0, pending: 0 }
|
||||
expect(summarizeChecks([])).toEqual(zero)
|
||||
expect(summarizeChecks(undefined)).toEqual(zero)
|
||||
expect(summarizeChecks(null)).toEqual(zero)
|
||||
expect(summarizeChecks('nope')).toEqual(zero)
|
||||
})
|
||||
|
||||
it('counts an unknown-shape item in total, treated as pending', () => {
|
||||
const s = summarizeChecks([{ __typename: 'Mystery', foo: 1 }, 42, null])
|
||||
expect(s.total).toBe(3)
|
||||
expect(s.pending).toBe(3)
|
||||
})
|
||||
|
||||
it('keeps total === passing + failing + pending across a mixed rollup', () => {
|
||||
const s = summarizeChecks([
|
||||
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' },
|
||||
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'FAILURE' },
|
||||
{ __typename: 'CheckRun', status: 'IN_PROGRESS', conclusion: null },
|
||||
{ __typename: 'StatusContext', state: 'SUCCESS' },
|
||||
{ __typename: 'StatusContext', state: 'PENDING' },
|
||||
{ weird: true },
|
||||
])
|
||||
expect(s.total).toBe(6)
|
||||
expect(s.passing + s.failing + s.pending).toBe(s.total)
|
||||
expect(s).toEqual({ total: 6, passing: 2, failing: 1, pending: 3 })
|
||||
})
|
||||
|
||||
it('buckets a bare CheckRun conclusion (no status field) by conclusion', () => {
|
||||
expect(summarizeChecks([{ conclusion: 'FAILURE' }]).failing).toBe(1)
|
||||
expect(summarizeChecks([{ conclusion: 'SUCCESS' }]).passing).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ── parsePrView ───────────────────────────────────────────────────────────────
|
||||
|
||||
const okJson = JSON.stringify({
|
||||
number: 12,
|
||||
state: 'OPEN',
|
||||
title: 'Add the thing',
|
||||
url: 'https://github.com/o/r/pull/12',
|
||||
isDraft: false,
|
||||
mergeable: 'MERGEABLE',
|
||||
headRefName: 'feature/x',
|
||||
baseRefName: 'main',
|
||||
statusCheckRollup: [
|
||||
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' },
|
||||
{ __typename: 'CheckRun', status: 'COMPLETED', conclusion: 'SUCCESS' },
|
||||
],
|
||||
})
|
||||
|
||||
describe('parsePrView', () => {
|
||||
it('maps a valid PR JSON to availability:ok with lower-cased state/mergeable', () => {
|
||||
const s = parsePrView(okJson)
|
||||
expect(s.availability).toBe('ok')
|
||||
expect(s.number).toBe(12)
|
||||
expect(s.title).toBe('Add the thing')
|
||||
expect(s.url).toBe('https://github.com/o/r/pull/12')
|
||||
expect(s.state).toBe('open')
|
||||
expect(s.isDraft).toBe(false)
|
||||
expect(s.mergeable).toBe('mergeable')
|
||||
expect(s.headRefName).toBe('feature/x')
|
||||
expect(s.baseRefName).toBe('main')
|
||||
expect(s.checks).toEqual({ total: 2, passing: 2, failing: 0, pending: 0 })
|
||||
})
|
||||
|
||||
it('keeps availability:ok for a draft PR and lower-cases UNKNOWN mergeable', () => {
|
||||
const s = parsePrView(
|
||||
JSON.stringify({ number: 3, state: 'OPEN', isDraft: true, mergeable: 'UNKNOWN' }),
|
||||
)
|
||||
expect(s.availability).toBe('ok')
|
||||
expect(s.isDraft).toBe(true)
|
||||
expect(s.mergeable).toBe('unknown')
|
||||
})
|
||||
|
||||
it('degrades malformed / non-object JSON to availability:error (never throws)', () => {
|
||||
expect(parsePrView('not json {').availability).toBe('error')
|
||||
expect(parsePrView('42').availability).toBe('error')
|
||||
expect(parsePrView('null').availability).toBe('error')
|
||||
expect(parsePrView('[1,2,3]').availability).toBe('error')
|
||||
expect(parsePrView('').availability).toBe('error')
|
||||
})
|
||||
|
||||
it('carries a <script> PR title VERBATIM (no parsing-side mangling — SEC-H4)', () => {
|
||||
const evil = '<script>alert(1)</script>'
|
||||
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<GhRunner>(async () => result)
|
||||
return { runner: spy, calls: () => spy.mock.calls.length }
|
||||
}
|
||||
|
||||
async function writeHead(repoPath: string, branch: string): Promise<void> {
|
||||
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' })
|
||||
})
|
||||
})
|
||||
175
test/integration/pr-status.test.ts
Normal file
175
test/integration/pr-status.test.ts
Normal file
@@ -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<number> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<void> } | 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<string, string> = {}): Promise<string> {
|
||||
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<void>((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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user