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:
165
test/digest.test.ts
Normal file
165
test/digest.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user