Files
web-terminal/test/telemetry-gauge.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

336 lines
12 KiB
TypeScript

// @vitest-environment jsdom
/**
* test/telemetry-gauge.test.ts — renderTelemetryGauge + renderStatusBadge + statusText('stuck')
*
* Covers T-preview-grid W1: telemetry gauge, status badge, and statusText extension.
* Security: SEC-H5 (textContent only), SEC-L5 (PR URL https scheme guard).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { StatusTelemetry, ClaudeStatus } from '../src/types.js'
// Stub xterm so jsdom does not fail on canvas operations (same as preview-grid.test.ts)
vi.mock('@xterm/xterm', () => ({
Terminal: class FakeTerm {
cols = 80
rows = 24
open = vi.fn()
reset = vi.fn()
resize = vi.fn()
dispose = vi.fn()
write = vi.fn((_d: string, cb?: () => void) => cb?.())
},
}))
const { renderTelemetryGauge, renderStatusBadge, statusText } = await import('../public/preview-grid.js')
/** Helper: build a StatusTelemetry with only `at` (everything else optional). */
function makeTelemetry(over: Partial<StatusTelemetry> = {}): StatusTelemetry {
return { at: Date.now(), ...over }
}
function makeContainer(): HTMLDivElement {
return document.createElement('div')
}
beforeEach(() => {
vi.restoreAllMocks()
})
// ─────────────────────── statusText — stuck extension ────────────────────────
describe('statusText — stuck extension', () => {
it('returns text containing ⚠ for stuck status', () => {
const text = statusText('stuck' as ClaudeStatus)
expect(text).toContain('⚠')
expect(text).toContain('stuck')
})
it('still maps existing statuses correctly (no regression)', () => {
expect(statusText('working')).toContain('working')
expect(statusText('waiting')).toContain('waiting')
expect(statusText('idle')).toContain('idle')
expect(statusText('unknown')).toBe('·')
})
})
// ─────────────────────── renderTelemetryGauge ────────────────────────────────
describe('renderTelemetryGauge', () => {
it('renders nothing for null telemetry', () => {
const c = makeContainer()
renderTelemetryGauge(c, null, 30_000)
expect(c.children.length).toBe(0)
})
it('is tolerant when telemetry has only the required at field (no optional fields)', () => {
const c = makeContainer()
expect(() => renderTelemetryGauge(c, makeTelemetry(), 30_000)).not.toThrow()
// No optional fields → nothing rendered beyond the stale logic
expect(c.children.length).toBe(0)
})
it('clears existing children on each call', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ model: 'first' }), 30_000)
renderTelemetryGauge(c, makeTelemetry({ model: 'second' }), 30_000)
const chips = c.querySelectorAll('.tg-model')
expect(chips.length).toBe(1)
expect(chips[0]?.textContent).toBe('second')
})
// ── context bar ──
it('renders a context bar when contextUsedPct is present', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ contextUsedPct: 50 }), 30_000)
expect(c.querySelector('.tg-ctx-bar')).not.toBeNull()
expect(c.querySelector('.tg-ctx-fill')).not.toBeNull()
})
it('adds warning class on context fill when contextUsedPct > 80', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ contextUsedPct: 85 }), 30_000)
expect(c.querySelector('.tg-ctx-fill')?.classList.contains('tg-ctx-warn')).toBe(true)
})
it('does NOT add warning class when contextUsedPct === 80', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ contextUsedPct: 80 }), 30_000)
expect(c.querySelector('.tg-ctx-fill')?.classList.contains('tg-ctx-warn')).toBe(false)
})
it('does NOT add warning class when contextUsedPct < 80', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ contextUsedPct: 60 }), 30_000)
expect(c.querySelector('.tg-ctx-fill')?.classList.contains('tg-ctx-warn')).toBe(false)
})
it('skips context bar when contextUsedPct is absent', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({}), 30_000)
expect(c.querySelector('.tg-ctx-bar')).toBeNull()
})
// ── cost chip ──
it('renders cost chip with $ prefix when costUsd is present', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ costUsd: 0.1234 }), 30_000)
const cost = c.querySelector('.tg-cost')
expect(cost).not.toBeNull()
expect(cost?.textContent).toContain('$')
expect(cost?.textContent).toContain('0.1234')
})
it('skips cost chip when costUsd is absent', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry(), 30_000)
expect(c.querySelector('.tg-cost')).toBeNull()
})
// ── cost budget warn (W3 b) ──
it('adds tg-cost-warn when costUsd >= costBudgetUsd', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ costUsd: 6 }), 30_000, 5)
expect(c.querySelector('.tg-cost')?.classList.contains('tg-cost-warn')).toBe(true)
})
it('adds tg-cost-warn exactly at the budget boundary (>=)', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ costUsd: 5 }), 30_000, 5)
expect(c.querySelector('.tg-cost')?.classList.contains('tg-cost-warn')).toBe(true)
})
it('does NOT add tg-cost-warn when costUsd < budget', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ costUsd: 3 }), 30_000, 5)
expect(c.querySelector('.tg-cost')?.classList.contains('tg-cost-warn')).toBe(false)
})
it('does NOT add tg-cost-warn when budget is 0 or undefined', () => {
const c1 = makeContainer()
renderTelemetryGauge(c1, makeTelemetry({ costUsd: 6 }), 30_000, 0)
expect(c1.querySelector('.tg-cost')?.classList.contains('tg-cost-warn')).toBe(false)
const c2 = makeContainer()
renderTelemetryGauge(c2, makeTelemetry({ costUsd: 6 }), 30_000)
expect(c2.querySelector('.tg-cost')?.classList.contains('tg-cost-warn')).toBe(false)
})
// ── model chip ──
it('renders model chip when model is present', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ model: 'claude-3-sonnet' }), 30_000)
expect(c.querySelector('.tg-model')?.textContent).toBe('claude-3-sonnet')
})
it('renders <script> model name as plain text — not injected into DOM (SEC-H5)', () => {
const c = makeContainer()
const xss = '<script>alert(1)</script>'
renderTelemetryGauge(c, makeTelemetry({ model: xss }), 30_000)
const chip = c.querySelector('.tg-model')
expect(chip?.textContent).toBe(xss)
// No actual <script> element should exist inside the container
expect(c.querySelectorAll('script').length).toBe(0)
})
it('skips model chip when model is absent', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry(), 30_000)
expect(c.querySelector('.tg-model')).toBeNull()
})
// ── PR badge ──
it('renders PR badge with link text for a PR', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 42, url: 'https://github.com/user/repo/pull/42' } }),
30_000,
)
const link = c.querySelector('.tg-pr-link') as HTMLAnchorElement | null
expect(link).not.toBeNull()
expect(link?.textContent).toContain('42')
})
it('sets href for https PR URL (SEC-L5)', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 42, url: 'https://github.com/user/repo/pull/42' } }),
30_000,
)
const link = c.querySelector('.tg-pr-link') as HTMLAnchorElement | null
expect(link?.href).toBe('https://github.com/user/repo/pull/42')
})
it('does NOT set href for http PR URL (SEC-L5)', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 99, url: 'http://insecure.example.com/pull/99' } }),
30_000,
)
const link = c.querySelector('.tg-pr-link') as HTMLAnchorElement | null
expect(link?.textContent).toContain('99')
// href attribute should not be set (attribute absent or empty string)
expect(link?.getAttribute('href') ?? '').toBe('')
})
it('does NOT set href for an invalid PR URL (SEC-L5)', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 1, url: 'not-a-valid-url' } }),
30_000,
)
const link = c.querySelector('.tg-pr-link') as HTMLAnchorElement | null
expect(link?.getAttribute('href') ?? '').toBe('')
})
it('renders PR reviewState as text when present', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 5, url: 'https://example.com/pull/5', reviewState: 'APPROVED' } }),
30_000,
)
expect(c.querySelector('.tg-pr-state')?.textContent).toBe('APPROVED')
})
it('skips PR reviewState element when reviewState is absent', () => {
const c = makeContainer()
renderTelemetryGauge(
c,
makeTelemetry({ pr: { number: 5, url: 'https://example.com/pull/5' } }),
30_000,
)
expect(c.querySelector('.tg-pr-state')).toBeNull()
})
it('skips PR badge when pr is absent', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry(), 30_000)
expect(c.querySelector('.tg-pr')).toBeNull()
})
// ── stale detection ──
it('adds tg-stale class when telemetry age exceeds staleTtlMs', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ at: Date.now() - 60_000 }), 30_000)
expect(c.classList.contains('tg-stale')).toBe(true)
})
it('does NOT add tg-stale class for fresh telemetry', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ at: Date.now() }), 30_000)
expect(c.classList.contains('tg-stale')).toBe(false)
})
it('removes tg-stale class when subsequent call has fresh telemetry', () => {
const c = makeContainer()
// First: stale
renderTelemetryGauge(c, makeTelemetry({ at: Date.now() - 60_000 }), 30_000)
expect(c.classList.contains('tg-stale')).toBe(true)
// Second: fresh
renderTelemetryGauge(c, makeTelemetry({ at: Date.now() }), 30_000)
expect(c.classList.contains('tg-stale')).toBe(false)
})
it('removes tg-stale class when called with null telemetry after stale', () => {
const c = makeContainer()
renderTelemetryGauge(c, makeTelemetry({ at: Date.now() - 60_000 }), 30_000)
expect(c.classList.contains('tg-stale')).toBe(true)
renderTelemetryGauge(c, null, 30_000)
expect(c.classList.contains('tg-stale')).toBe(false)
})
})
// ─────────────────────── renderStatusBadge ───────────────────────────────────
describe('renderStatusBadge', () => {
it('renders a badge for stuck with ⚠ (A5)', () => {
const c = makeContainer()
renderStatusBadge(c, 'stuck')
const badge = c.querySelector('.sb-badge')
expect(badge).not.toBeNull()
expect(badge?.textContent).toContain('⚠')
expect(badge?.textContent).toContain('stuck')
})
it('applies per-status CSS class for styling', () => {
const statuses: ClaudeStatus[] = ['working', 'waiting', 'idle', 'unknown', 'stuck']
for (const status of statuses) {
const c = makeContainer()
renderStatusBadge(c, status)
expect(c.querySelector(`.sb-${status}`)).not.toBeNull()
}
})
it('clears the container before rendering (replaces old badge)', () => {
const c = makeContainer()
renderStatusBadge(c, 'working')
renderStatusBadge(c, 'idle')
expect(c.querySelectorAll('.sb-badge').length).toBe(1)
expect(c.querySelector('.sb-badge')?.textContent).toContain('idle')
})
it('renders each non-stuck status with appropriate text', () => {
const cases: Array<[ClaudeStatus, string]> = [
['working', 'working'],
['waiting', 'waiting'],
['idle', 'idle'],
]
for (const [status, expected] of cases) {
const c = makeContainer()
renderStatusBadge(c, status)
expect(c.querySelector('.sb-badge')?.textContent).toContain(expected)
}
})
})