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.
374 lines
13 KiB
TypeScript
374 lines
13 KiB
TypeScript
/**
|
|
* test/http/statusline.test.ts — TDD tests for parseStatusLine (N-statusline-be).
|
|
*
|
|
* Acceptance: AC-B2.6 — full/missing/dirty/empty bodies; never throw; ≥80% coverage.
|
|
* Security: SEC-M7 — unknown + narrowing, string length limits.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
import { parseStatusLine } from '../../src/http/statusline.js'
|
|
import type { StatusTelemetry } from '../../src/types.js'
|
|
|
|
// Representative full Claude Code statusLine JSON body.
|
|
// Field mapping (R0 → StatusTelemetry): context_window.used_percentage→contextUsedPct,
|
|
// cost.total_cost_usd→costUsd, cost.total_lines_added/removed→linesAdded/Removed,
|
|
// model.display_name→model, effort.level→effort, pr→pr, rate_limits→rate.
|
|
const FULL_BODY = {
|
|
context_window: { used_percentage: 42.5 },
|
|
cost: { total_cost_usd: 0.05, total_lines_added: 100, total_lines_removed: 50 },
|
|
model: { display_name: 'Claude 3.5 Sonnet' },
|
|
effort: { level: 'normal' },
|
|
pr: { number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' },
|
|
rate_limits: { fiveHourPct: 10, sevenDayPct: 20 },
|
|
}
|
|
|
|
const FIXED_NOW = 1_700_000_000_000
|
|
|
|
describe('parseStatusLine — null for non-objects', () => {
|
|
it('returns null for null', () => {
|
|
expect(parseStatusLine(null)).toBeNull()
|
|
})
|
|
|
|
it('returns null for undefined', () => {
|
|
expect(parseStatusLine(undefined)).toBeNull()
|
|
})
|
|
|
|
it('returns null for a string', () => {
|
|
expect(parseStatusLine('hello')).toBeNull()
|
|
})
|
|
|
|
it('returns null for a number', () => {
|
|
expect(parseStatusLine(42)).toBeNull()
|
|
})
|
|
|
|
it('returns null for a boolean', () => {
|
|
expect(parseStatusLine(true)).toBeNull()
|
|
})
|
|
|
|
it('returns null for an array (typeof is "object" but is not a plain object)', () => {
|
|
expect(parseStatusLine([])).toBeNull()
|
|
expect(parseStatusLine([1, 2, 3])).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('parseStatusLine — full valid body', () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(Date, 'now').mockReturnValue(FIXED_NOW)
|
|
})
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('maps all fields correctly from a fully-populated body', () => {
|
|
const result = parseStatusLine(FULL_BODY)
|
|
|
|
expect(result).not.toBeNull()
|
|
const t = result as StatusTelemetry
|
|
|
|
expect(t.contextUsedPct).toBe(42.5)
|
|
expect(t.costUsd).toBe(0.05)
|
|
expect(t.linesAdded).toBe(100)
|
|
expect(t.linesRemoved).toBe(50)
|
|
expect(t.model).toBe('Claude 3.5 Sonnet')
|
|
expect(t.effort).toBe('normal')
|
|
expect(t.pr).toEqual({ number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' })
|
|
expect(t.rate).toEqual({ fiveHourPct: 10, sevenDayPct: 20 })
|
|
expect(t.at).toBe(FIXED_NOW)
|
|
})
|
|
})
|
|
|
|
describe('parseStatusLine — empty/minimal body', () => {
|
|
beforeEach(() => {
|
|
vi.spyOn(Date, 'now').mockReturnValue(FIXED_NOW)
|
|
})
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('returns a StatusTelemetry with only at for an empty object', () => {
|
|
const result = parseStatusLine({})
|
|
|
|
expect(result).not.toBeNull()
|
|
expect(result?.at).toBe(FIXED_NOW)
|
|
expect(result?.contextUsedPct).toBeUndefined()
|
|
expect(result?.costUsd).toBeUndefined()
|
|
expect(result?.linesAdded).toBeUndefined()
|
|
expect(result?.linesRemoved).toBeUndefined()
|
|
expect(result?.model).toBeUndefined()
|
|
expect(result?.effort).toBeUndefined()
|
|
expect(result?.pr).toBeUndefined()
|
|
expect(result?.rate).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('parseStatusLine — at timestamp', () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('sets at to Date.now() on each call', () => {
|
|
const t1 = 1000
|
|
const t2 = 2000
|
|
vi.spyOn(Date, 'now').mockReturnValueOnce(t1).mockReturnValueOnce(t2)
|
|
|
|
expect(parseStatusLine({})?.at).toBe(t1)
|
|
expect(parseStatusLine({})?.at).toBe(t2)
|
|
})
|
|
})
|
|
|
|
describe('parseStatusLine — dirty number fields', () => {
|
|
it('omits contextUsedPct when used_percentage is NaN', () => {
|
|
expect(
|
|
parseStatusLine({ context_window: { used_percentage: NaN } })?.contextUsedPct,
|
|
).toBeUndefined()
|
|
})
|
|
|
|
it('omits contextUsedPct when used_percentage is Infinity', () => {
|
|
expect(
|
|
parseStatusLine({ context_window: { used_percentage: Infinity } })?.contextUsedPct,
|
|
).toBeUndefined()
|
|
})
|
|
|
|
it('omits contextUsedPct when used_percentage is a string', () => {
|
|
expect(
|
|
parseStatusLine({ context_window: { used_percentage: '42' } })?.contextUsedPct,
|
|
).toBeUndefined()
|
|
})
|
|
|
|
it('omits costUsd when total_cost_usd is Infinity', () => {
|
|
expect(parseStatusLine({ cost: { total_cost_usd: Infinity } })?.costUsd).toBeUndefined()
|
|
})
|
|
|
|
it('omits costUsd when total_cost_usd is NaN', () => {
|
|
expect(parseStatusLine({ cost: { total_cost_usd: NaN } })?.costUsd).toBeUndefined()
|
|
})
|
|
|
|
it('omits linesAdded when total_lines_added is a string', () => {
|
|
expect(parseStatusLine({ cost: { total_lines_added: 'bad' } })?.linesAdded).toBeUndefined()
|
|
})
|
|
|
|
it('omits linesRemoved when total_lines_removed is null', () => {
|
|
expect(parseStatusLine({ cost: { total_lines_removed: null } })?.linesRemoved).toBeUndefined()
|
|
})
|
|
|
|
it('parses partial cost fields independently', () => {
|
|
const result = parseStatusLine({ cost: { total_cost_usd: 1.5 } })
|
|
|
|
expect(result?.costUsd).toBe(1.5)
|
|
expect(result?.linesAdded).toBeUndefined()
|
|
expect(result?.linesRemoved).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('parseStatusLine — context_window not an object', () => {
|
|
it('omits contextUsedPct when context_window is a string', () => {
|
|
expect(parseStatusLine({ context_window: 'bad' })?.contextUsedPct).toBeUndefined()
|
|
})
|
|
|
|
it('omits contextUsedPct when context_window is null', () => {
|
|
expect(parseStatusLine({ context_window: null })?.contextUsedPct).toBeUndefined()
|
|
})
|
|
|
|
it('omits contextUsedPct when context_window is a number', () => {
|
|
expect(parseStatusLine({ context_window: 42 })?.contextUsedPct).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('parseStatusLine — cost not an object', () => {
|
|
it('omits all cost fields when cost is a string', () => {
|
|
const result = parseStatusLine({ cost: 'bad' })
|
|
|
|
expect(result?.costUsd).toBeUndefined()
|
|
expect(result?.linesAdded).toBeUndefined()
|
|
expect(result?.linesRemoved).toBeUndefined()
|
|
})
|
|
|
|
it('omits all cost fields when cost is null', () => {
|
|
const result = parseStatusLine({ cost: null })
|
|
|
|
expect(result?.costUsd).toBeUndefined()
|
|
expect(result?.linesAdded).toBeUndefined()
|
|
expect(result?.linesRemoved).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('parseStatusLine — dirty string fields (SEC-M7 length caps)', () => {
|
|
it('omits model when display_name exceeds 200 characters', () => {
|
|
expect(
|
|
parseStatusLine({ model: { display_name: 'a'.repeat(201) } })?.model,
|
|
).toBeUndefined()
|
|
})
|
|
|
|
it('accepts model at exactly 200 characters', () => {
|
|
const model200 = 'a'.repeat(200)
|
|
expect(parseStatusLine({ model: { display_name: model200 } })?.model).toBe(model200)
|
|
})
|
|
|
|
it('omits model when model field is not an object', () => {
|
|
expect(parseStatusLine({ model: 'bad' })?.model).toBeUndefined()
|
|
expect(parseStatusLine({ model: null })?.model).toBeUndefined()
|
|
expect(parseStatusLine({ model: 42 })?.model).toBeUndefined()
|
|
})
|
|
|
|
it('omits effort when level is a number', () => {
|
|
expect(parseStatusLine({ effort: { level: 42 } })?.effort).toBeUndefined()
|
|
})
|
|
|
|
it('omits effort when level exceeds 100 characters', () => {
|
|
expect(
|
|
parseStatusLine({ effort: { level: 'x'.repeat(101) } })?.effort,
|
|
).toBeUndefined()
|
|
})
|
|
|
|
it('accepts effort at exactly 100 characters', () => {
|
|
const effort100 = 'e'.repeat(100)
|
|
expect(parseStatusLine({ effort: { level: effort100 } })?.effort).toBe(effort100)
|
|
})
|
|
|
|
it('omits effort when effort field is not an object', () => {
|
|
expect(parseStatusLine({ effort: 42 })?.effort).toBeUndefined()
|
|
expect(parseStatusLine({ effort: null })?.effort).toBeUndefined()
|
|
expect(parseStatusLine({ effort: [] })?.effort).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('parseStatusLine — pr parsing', () => {
|
|
it('parses pr with all fields including optional reviewState', () => {
|
|
const result = parseStatusLine({
|
|
pr: { number: 42, url: 'https://github.com/foo/bar/pull/42', reviewState: 'pending' },
|
|
})
|
|
|
|
expect(result?.pr).toEqual({
|
|
number: 42,
|
|
url: 'https://github.com/foo/bar/pull/42',
|
|
reviewState: 'pending',
|
|
})
|
|
})
|
|
|
|
it('parses pr without optional reviewState', () => {
|
|
const result = parseStatusLine({ pr: { number: 5, url: 'https://github.com/pr/5' } })
|
|
|
|
expect(result?.pr).toBeDefined()
|
|
expect(result?.pr?.number).toBe(5)
|
|
expect(result?.pr?.url).toBe('https://github.com/pr/5')
|
|
expect(result?.pr?.reviewState).toBeUndefined()
|
|
})
|
|
|
|
it('omits pr when pr.number is missing', () => {
|
|
expect(parseStatusLine({ pr: { url: 'https://example.com' } })?.pr).toBeUndefined()
|
|
})
|
|
|
|
it('omits pr when pr.url is missing', () => {
|
|
expect(parseStatusLine({ pr: { number: 1 } })?.pr).toBeUndefined()
|
|
})
|
|
|
|
it('omits pr when both required fields are missing', () => {
|
|
expect(parseStatusLine({ pr: {} })?.pr).toBeUndefined()
|
|
})
|
|
|
|
it('omits pr when pr is not an object', () => {
|
|
expect(parseStatusLine({ pr: 'not-an-object' })?.pr).toBeUndefined()
|
|
expect(parseStatusLine({ pr: null })?.pr).toBeUndefined()
|
|
expect(parseStatusLine({ pr: 42 })?.pr).toBeUndefined()
|
|
})
|
|
|
|
it('omits pr when pr.url exceeds 2000 characters', () => {
|
|
const longUrl = 'https://example.com/' + 'x'.repeat(1990)
|
|
expect(parseStatusLine({ pr: { number: 1, url: longUrl } })?.pr).toBeUndefined()
|
|
})
|
|
|
|
it('omits pr.reviewState but keeps pr when reviewState exceeds 100 characters', () => {
|
|
const result = parseStatusLine({
|
|
pr: {
|
|
number: 1,
|
|
url: 'https://github.com/pr/1',
|
|
reviewState: 'x'.repeat(101),
|
|
},
|
|
})
|
|
|
|
expect(result?.pr).toBeDefined()
|
|
expect(result?.pr?.number).toBe(1)
|
|
expect(result?.pr?.url).toBe('https://github.com/pr/1')
|
|
expect(result?.pr?.reviewState).toBeUndefined()
|
|
})
|
|
|
|
it('omits pr when pr.number is NaN', () => {
|
|
expect(parseStatusLine({ pr: { number: NaN, url: 'https://github.com' } })?.pr).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('parseStatusLine — rate_limits parsing', () => {
|
|
it('parses rate_limits with both fields', () => {
|
|
const result = parseStatusLine({ rate_limits: { fiveHourPct: 25, sevenDayPct: 50 } })
|
|
|
|
expect(result?.rate).toEqual({ fiveHourPct: 25, sevenDayPct: 50 })
|
|
})
|
|
|
|
it('parses rate_limits with only fiveHourPct', () => {
|
|
const result = parseStatusLine({ rate_limits: { fiveHourPct: 25 } })
|
|
|
|
expect(result?.rate?.fiveHourPct).toBe(25)
|
|
expect(result?.rate?.sevenDayPct).toBeUndefined()
|
|
})
|
|
|
|
it('parses rate_limits with only sevenDayPct', () => {
|
|
const result = parseStatusLine({ rate_limits: { sevenDayPct: 75 } })
|
|
|
|
expect(result?.rate?.fiveHourPct).toBeUndefined()
|
|
expect(result?.rate?.sevenDayPct).toBe(75)
|
|
})
|
|
|
|
it('omits rate when rate_limits is not an object', () => {
|
|
expect(parseStatusLine({ rate_limits: 'bad' })?.rate).toBeUndefined()
|
|
expect(parseStatusLine({ rate_limits: null })?.rate).toBeUndefined()
|
|
expect(parseStatusLine({ rate_limits: 42 })?.rate).toBeUndefined()
|
|
})
|
|
|
|
it('returns rate object with undefined sub-fields when rate_limits values are invalid', () => {
|
|
const result = parseStatusLine({ rate_limits: { fiveHourPct: NaN, sevenDayPct: Infinity } })
|
|
|
|
// rate_limits IS a valid object, so rate is returned; but both sub-fields are invalid
|
|
expect(result?.rate).toBeDefined()
|
|
expect(result?.rate?.fiveHourPct).toBeUndefined()
|
|
expect(result?.rate?.sevenDayPct).toBeUndefined()
|
|
})
|
|
|
|
it('omits invalid sub-fields and keeps valid ones', () => {
|
|
const result = parseStatusLine({
|
|
rate_limits: { fiveHourPct: 'not-a-number', sevenDayPct: 80 },
|
|
})
|
|
|
|
expect(result?.rate?.fiveHourPct).toBeUndefined()
|
|
expect(result?.rate?.sevenDayPct).toBe(80)
|
|
})
|
|
})
|
|
|
|
describe('parseStatusLine — never throw (SEC-M7)', () => {
|
|
it('never throws for wildly malformed bodies', () => {
|
|
const malformed: unknown[] = [
|
|
null,
|
|
undefined,
|
|
42,
|
|
'string',
|
|
true,
|
|
[],
|
|
{ context_window: 'not-an-object' },
|
|
{ context_window: { used_percentage: {} } },
|
|
{ cost: null },
|
|
{ model: 42 },
|
|
{ effort: [] },
|
|
{ pr: 'not-an-object' },
|
|
{ pr: { number: 'bad', url: {} } },
|
|
{ rate_limits: 'bad' },
|
|
{ rate_limits: { fiveHourPct: Infinity, sevenDayPct: -Infinity } },
|
|
{ pr: { number: NaN, url: '' } },
|
|
{ context_window: { used_percentage: null } },
|
|
{ cost: { total_cost_usd: {}, total_lines_added: [], total_lines_removed: undefined } },
|
|
]
|
|
|
|
for (const body of malformed) {
|
|
expect(() => parseStatusLine(body)).not.toThrow()
|
|
}
|
|
})
|
|
})
|