// @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) }) })