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).
336 lines
12 KiB
TypeScript
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)
|
|
}
|
|
})
|
|
})
|