Files
web-terminal/test/statusline.test.ts
Yaojia Wang d6809c65c4 feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
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.
2026-06-30 17:42:18 +02:00

263 lines
10 KiB
TypeScript

/**
* test/statusline.test.ts — N-statusline-script tests (B2)
*
* Tests the pure exported functions from scripts/statusline.mjs:
* - parseRaw: parse raw Claude Code statusLine JSON
* - buildSummary: format one-line status string
* - postToServer: POST to /hook/status (mocked fetch)
*
* readStdin is not tested here (it wraps process.stdin which blocks in tests).
* Integration-level acceptance (actual POST to /hook/status) is covered by V-integration.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// vitest/esbuild handles .mjs imports; tsc does not check test/ files
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import { parseRaw, buildSummary, postToServer } from '../scripts/statusline.mjs'
// ── parseRaw ──────────────────────────────────────────────────────────────────
describe('parseRaw', () => {
it('returns null for invalid JSON', () => {
expect(parseRaw('not-json')).toBeNull()
expect(parseRaw('')).toBeNull()
expect(parseRaw('{')).toBeNull()
})
it('returns null for non-object JSON values', () => {
expect(parseRaw('null')).toBeNull()
expect(parseRaw('"string"')).toBeNull()
expect(parseRaw('42')).toBeNull()
expect(parseRaw('true')).toBeNull()
})
it('returns null for JSON arrays', () => {
expect(parseRaw('[]')).toBeNull()
expect(parseRaw('[1,2,3]')).toBeNull()
})
it('returns an object (not null) for empty JSON object', () => {
const result = parseRaw('{}')
expect(result).not.toBeNull()
expect(result).toEqual({ contextUsedPct: undefined, costUsd: undefined, model: undefined })
})
it('extracts contextUsedPct from context_window.used_percentage', () => {
const raw = JSON.stringify({ context_window: { used_percentage: 45.7 } })
const result = parseRaw(raw)
expect(result?.contextUsedPct).toBe(45.7)
})
it('returns undefined contextUsedPct when context_window is missing', () => {
const result = parseRaw('{}')
expect(result?.contextUsedPct).toBeUndefined()
})
it('returns undefined contextUsedPct when used_percentage is not a number', () => {
const raw = JSON.stringify({ context_window: { used_percentage: '45%' } })
expect(parseRaw(raw)?.contextUsedPct).toBeUndefined()
})
it('returns undefined contextUsedPct for Infinity and NaN', () => {
const raw = JSON.stringify({ context_window: { used_percentage: null } })
expect(parseRaw(raw)?.contextUsedPct).toBeUndefined()
})
it('returns undefined contextUsedPct when context_window is not an object', () => {
const raw = JSON.stringify({ context_window: 'bad' })
expect(parseRaw(raw)?.contextUsedPct).toBeUndefined()
})
it('extracts costUsd from cost.total_cost_usd', () => {
const raw = JSON.stringify({ cost: { total_cost_usd: 0.532 } })
expect(parseRaw(raw)?.costUsd).toBe(0.532)
})
it('returns undefined costUsd when cost is missing', () => {
expect(parseRaw('{}')?.costUsd).toBeUndefined()
})
it('returns undefined costUsd for non-numeric values', () => {
const raw = JSON.stringify({ cost: { total_cost_usd: 'expensive' } })
expect(parseRaw(raw)?.costUsd).toBeUndefined()
})
it('returns undefined costUsd when cost is not an object', () => {
const raw = JSON.stringify({ cost: 42 })
expect(parseRaw(raw)?.costUsd).toBeUndefined()
})
it('extracts model from model.display_name', () => {
const raw = JSON.stringify({ model: { display_name: 'claude-opus-4-5' } })
expect(parseRaw(raw)?.model).toBe('claude-opus-4-5')
})
it('extracts model from model string (fallback)', () => {
const raw = JSON.stringify({ model: 'claude-sonnet' })
expect(parseRaw(raw)?.model).toBe('claude-sonnet')
})
it('returns undefined model when model is missing', () => {
expect(parseRaw('{}')?.model).toBeUndefined()
})
it('returns undefined model for empty model string', () => {
const raw = JSON.stringify({ model: '' })
expect(parseRaw(raw)?.model).toBeUndefined()
})
it('returns undefined model when model.display_name is not a string', () => {
const raw = JSON.stringify({ model: { display_name: 42 } })
expect(parseRaw(raw)?.model).toBeUndefined()
})
it('truncates model to 100 characters', () => {
const longModel = 'a'.repeat(150)
const raw = JSON.stringify({ model: { display_name: longModel } })
expect(parseRaw(raw)?.model).toHaveLength(100)
})
it('extracts all three fields together (full statusLine JSON)', () => {
const raw = JSON.stringify({
context_window: { used_percentage: 60.0 },
cost: { total_cost_usd: 1.23, total_lines_added: 100, total_lines_removed: 20 },
model: { display_name: 'claude-opus-4-5' },
effort: { level: 'high' },
})
const result = parseRaw(raw)
expect(result).toEqual({
contextUsedPct: 60.0,
costUsd: 1.23,
model: 'claude-opus-4-5',
})
})
it('handles partial fields gracefully (only model provided)', () => {
const raw = JSON.stringify({ model: { display_name: 'claude-haiku' } })
const result = parseRaw(raw)
expect(result?.contextUsedPct).toBeUndefined()
expect(result?.costUsd).toBeUndefined()
expect(result?.model).toBe('claude-haiku')
})
it('does not throw on any input (never-throw contract)', () => {
const cases = [null, undefined, 42, [], {}, 'random', '{"a":1}']
for (const input of cases) {
expect(() => parseRaw(String(input))).not.toThrow()
}
})
})
// ── buildSummary ──────────────────────────────────────────────────────────────
describe('buildSummary', () => {
it('returns empty string for null parsed', () => {
expect(buildSummary(null)).toBe('')
})
it('returns empty string when all fields are undefined', () => {
expect(buildSummary({ contextUsedPct: undefined, costUsd: undefined, model: undefined })).toBe('')
})
it('formats contextUsedPct as ctx:XX%', () => {
expect(buildSummary({ contextUsedPct: 45.7 })).toBe('ctx:46%')
})
it('rounds contextUsedPct to nearest integer', () => {
expect(buildSummary({ contextUsedPct: 45.4 })).toBe('ctx:45%')
expect(buildSummary({ contextUsedPct: 99.9 })).toBe('ctx:100%')
expect(buildSummary({ contextUsedPct: 0 })).toBe('ctx:0%')
})
it('formats costUsd as $X.XX', () => {
expect(buildSummary({ costUsd: 0.532 })).toBe('$0.53')
expect(buildSummary({ costUsd: 1.0 })).toBe('$1.00')
expect(buildSummary({ costUsd: 12.345 })).toBe('$12.35')
})
it('formats model as plain string', () => {
expect(buildSummary({ model: 'claude-opus-4-5' })).toBe('claude-opus-4-5')
})
it('combines all fields with " · " separator', () => {
const result = buildSummary({ contextUsedPct: 45, costUsd: 0.53, model: 'claude-opus' })
expect(result).toBe('ctx:45% · $0.53 · claude-opus')
})
it('combines only present fields (ctx + model, no cost)', () => {
const result = buildSummary({ contextUsedPct: 20, model: 'claude-haiku' })
expect(result).toBe('ctx:20% · claude-haiku')
})
it('combines only present fields (cost + model, no ctx)', () => {
const result = buildSummary({ costUsd: 0.01, model: 'claude-sonnet' })
expect(result).toBe('$0.01 · claude-sonnet')
})
it('omits empty model string', () => {
expect(buildSummary({ model: '' })).toBe('')
expect(buildSummary({ costUsd: 1.0, model: '' })).toBe('$1.00')
})
})
// ── postToServer ──────────────────────────────────────────────────────────────
describe('postToServer', () => {
const mockFetch = vi.fn()
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch)
mockFetch.mockResolvedValue({ ok: true, status: 204 })
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})
it('calls fetch with POST method and correct URL', async () => {
await postToServer('http://127.0.0.1:3000/hook/status', 'session-1', '{}')
expect(mockFetch).toHaveBeenCalledOnce()
const [url, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('http://127.0.0.1:3000/hook/status')
expect(opts.method).toBe('POST')
})
it('sends Content-Type: application/json header', async () => {
await postToServer('http://127.0.0.1:3000/hook/status', 'session-1', '{}')
const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
expect((opts.headers as Record<string, string>)['Content-Type']).toBe('application/json')
})
it('sends X-Webterm-Session header with the sessionId', async () => {
await postToServer('http://127.0.0.1:3000/hook/status', 'my-session-id', '{}')
const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
expect((opts.headers as Record<string, string>)['X-Webterm-Session']).toBe('my-session-id')
})
it('sends the raw body verbatim', async () => {
const body = '{"context_window":{"used_percentage":50}}'
await postToServer('http://127.0.0.1:3000/hook/status', 's1', body)
const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(opts.body).toBe(body)
})
it('does not throw when fetch rejects (server down)', async () => {
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'))
await expect(postToServer('http://127.0.0.1:3000/hook/status', 's1', '{}')).resolves.toBeUndefined()
})
it('does not throw on network timeout (AbortError)', async () => {
mockFetch.mockRejectedValue(new DOMException('Aborted', 'AbortError'))
await expect(postToServer('http://127.0.0.1:3000/hook/status', 's1', '{}', 1)).resolves.toBeUndefined()
})
it('passes an AbortSignal to fetch', async () => {
await postToServer('http://127.0.0.1:3000/hook/status', 's1', '{}')
const [, opts] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(opts.signal).toBeDefined()
})
})