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).
This commit is contained in:
Yaojia Wang
2026-07-12 21:27:20 +02:00
parent 7551f8a4b2
commit 1dd12b035a
30 changed files with 1911 additions and 8 deletions

165
test/digest.test.ts Normal file
View File

@@ -0,0 +1,165 @@
// @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()
})
})