Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.
Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.
New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).
Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).
Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
306 lines
11 KiB
TypeScript
306 lines
11 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()
|
|
})
|
|
|
|
// ── 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)
|
|
}
|
|
})
|
|
})
|