// @vitest-environment jsdom /** * test/digest.test.ts (W3 quick-wins c) — reconnect digest banner (public/digest.ts). * * Pure helpers (normalizeDigest / digestSummary / renderDigestBanner) + the * mountDigest wiring with a mocked fetch: fetches with the stored last-seen, * shows a banner only when something happened, dismiss advances last-seen + hides, * and a fetch failure shows no banner (best-effort). localStorage comes from the * shared jsdom polyfill (vitest.config setupFiles). */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { DigestResult } from '../src/types.js' import { normalizeDigest, digestSummary, digestHighlightCount, renderDigestBanner, getLastSeen, setLastSeen, mountDigest, } from '../public/digest.js' function makeDigest(over: Partial = {}): DigestResult { return { since: 0, generatedAt: 5000, total: 0, finished: 0, needsInput: 0, stuck: 0, working: 0, totalCostUsd: 0, sessions: [], ...over, } } /** Install a fetch mock returning `body` (or rejecting when `body` is null). */ function mockFetch(body: unknown, ok = true): ReturnType { const fn = vi.fn(async () => { if (body === null) throw new Error('network down') return { ok, json: async () => body } as Response }) vi.stubGlobal('fetch', fn) return fn } beforeEach(() => { localStorage.clear() document.body.innerHTML = '' }) afterEach(() => { vi.unstubAllGlobals() }) // ── pure helpers ────────────────────────────────────────────────────────────── describe('normalizeDigest', () => { it('returns null for a non-object / missing generatedAt', () => { expect(normalizeDigest(null)).toBeNull() expect(normalizeDigest('x')).toBeNull() expect(normalizeDigest({ total: 1 })).toBeNull() }) it('coerces a well-formed response', () => { const d = normalizeDigest(makeDigest({ finished: 2, totalCostUsd: 1.5 })) expect(d?.finished).toBe(2) expect(d?.totalCostUsd).toBe(1.5) expect(Array.isArray(d?.sessions)).toBe(true) }) it('defaults missing numeric counts to 0 and non-array sessions to []', () => { const d = normalizeDigest({ generatedAt: 10 }) expect(d?.finished).toBe(0) expect(d?.sessions).toEqual([]) }) }) describe('digestSummary / digestHighlightCount', () => { it('counts finished + needsInput + stuck', () => { expect(digestHighlightCount(makeDigest({ finished: 2, needsInput: 1, stuck: 1, working: 9 }))).toBe(4) }) it('builds a compact summary of only the non-zero buckets', () => { const s = digestSummary(makeDigest({ finished: 2, stuck: 1 })) expect(s).toContain('2 finished') expect(s).toContain('1 stuck') expect(s).not.toContain('waiting') }) }) describe('renderDigestBanner', () => { it('returns null when nothing worth showing', () => { expect(renderDigestBanner(makeDigest({ working: 3 }), () => {})).toBeNull() }) it('renders a dismissible banner and wires the × button', () => { const onDismiss = vi.fn() const banner = renderDigestBanner(makeDigest({ finished: 1 }), onDismiss) expect(banner?.className).toContain('wya-banner') ;(banner?.querySelector('.wya-dismiss') as HTMLButtonElement).click() expect(onDismiss).toHaveBeenCalled() }) it('renders an attacker-influenced summary as inert text (SEC-H5)', () => { const banner = renderDigestBanner(makeDigest({ finished: 1 }), () => {}) expect(banner?.querySelectorAll('script').length).toBe(0) }) }) describe('getLastSeen / setLastSeen', () => { it('defaults to 0 and round-trips a value', () => { expect(getLastSeen()).toBe(0) setLastSeen(1234) expect(getLastSeen()).toBe(1234) }) }) // ── mountDigest (wiring) ────────────────────────────────────────────────────── describe('mountDigest', () => { it('fetches with the stored last-seen watermark', async () => { setLastSeen(500) const fetchFn = mockFetch(makeDigest({ finished: 1 })) await mountDigest(document.body) expect(fetchFn).toHaveBeenCalledWith('/digest?since=500') }) it('shows a banner when something happened', async () => { mockFetch(makeDigest({ finished: 1 })) await mountDigest(document.body) expect(document.body.querySelector('.wya-banner')).not.toBeNull() }) it('shows NO banner when nothing happened', async () => { mockFetch(makeDigest({ working: 2 })) await mountDigest(document.body) expect(document.body.querySelector('.wya-banner')).toBeNull() }) it('advances last-seen to generatedAt after a fetch', async () => { mockFetch(makeDigest({ generatedAt: 8888, finished: 1 })) await mountDigest(document.body) expect(getLastSeen()).toBe(8888) }) it('dismiss updates last-seen and removes the banner', async () => { mockFetch(makeDigest({ generatedAt: 7777, needsInput: 1 })) await mountDigest(document.body) const banner = document.body.querySelector('.wya-banner') expect(banner).not.toBeNull() ;(banner!.querySelector('.wya-dismiss') as HTMLButtonElement).click() expect(document.body.querySelector('.wya-banner')).toBeNull() expect(getLastSeen()).toBe(7777) }) it('shows no banner on a fetch failure (best-effort)', async () => { mockFetch(null) const banner = await mountDigest(document.body) expect(banner).toBeNull() expect(document.body.querySelector('.wya-banner')).toBeNull() }) })