Files
web-terminal/test/digest.test.ts
Yaojia Wang 1dd12b035a feat(cockpit): quick wins — sync chip, cost budget guard, digest, recent commits (W3)
Four small, high-delight features that turn passive capture into glanceable signals.

- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
  into the existing concurrent per-repo metadata pass (git rev-list --count
  --left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
  (Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
  on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
  frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
  /config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
  totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
  GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.

All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
2026-07-12 21:27:20 +02:00

166 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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> = {}): 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<typeof vi.fn> {
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()
})
})