/** * 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)['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)['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() }) })