/** * T-server-wire — integration tests for the A4 timeline, B2 statusLine ingest, * and the GET /config/ui route. * * Covers (against a real startServer): * - GET /live-sessions/:id/events → 404 unknown id; [] when TIMELINE_ENABLED=0; * real timeline entries after a /hook POST * - POST /hook/status (loopback) → 400 on garbage; 204 + telemetry broadcast * - GET /config/ui → mirrors cfg.allowAutoMode */ import net from 'node:net' import { afterEach, describe, expect, it } from 'vitest' import WebSocket from 'ws' import * as nodePty from 'node-pty' import { loadConfig } from '../../src/config.js' import { startServer } from '../../src/server.js' const PTY_AVAILABLE = (() => { try { const p = nodePty.spawn(process.env['SHELL'] ?? '/bin/sh', [], { cols: 80, rows: 24 }) p.kill() return true } catch { return false } })() const itPty = PTY_AVAILABLE ? it : it.skip function getFreePort(): Promise { return new Promise((resolve, reject) => { const srv = net.createServer() srv.listen(0, '127.0.0.1', () => { const addr = srv.address() if (addr === null || typeof addr === 'string') { srv.close() reject(new Error('bad addr')) return } const port = addr.port srv.close(() => resolve(port)) }) srv.on('error', reject) }) } const handles: { close(): Promise }[] = [] async function spawnServer( overrides: Record = {}, ): Promise<{ port: number; origin: string }> { const port = await getFreePort() const cfg = loadConfig({ PORT: String(port), BIND_HOST: '127.0.0.1', SHELL_PATH: process.env['SHELL'] ?? '/bin/zsh', ALLOWED_ORIGINS: `http://127.0.0.1:${port}`, USE_TMUX: '0', IDLE_TTL: '86400', ...overrides, }) const handle = startServer(cfg) handles.push(handle) await new Promise((r) => setTimeout(r, 80)) return { port, origin: `http://127.0.0.1:${port}` } } function waitForOpen(ws: WebSocket): Promise { return new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error('open timeout')), 3000) ws.once('open', () => { clearTimeout(t) resolve() }) ws.once('error', (e) => { clearTimeout(t) reject(e) }) }) } function waitForMessage(ws: WebSocket, pred: (m: Record) => boolean): Promise> { return new Promise((resolve, reject) => { const t = setTimeout(() => { ws.off('message', onMsg) reject(new Error('message timeout')) }, 5000) const onMsg = (raw: WebSocket.RawData): void => { let m: Record try { m = JSON.parse(raw.toString('utf8')) as Record } catch { return } if (pred(m)) { clearTimeout(t) ws.off('message', onMsg) resolve(m) } } ws.on('message', onMsg) }) } const isAttached = (m: Record): boolean => m['type'] === 'attached' afterEach(async () => { while (handles.length > 0) await handles.pop()?.close() await new Promise((r) => setTimeout(r, 30)) }) const UNKNOWN = '00000000-0000-4000-8000-000000000000' // ── GET /live-sessions/:id/events ──────────────────────────────────────────────── describe('GET /live-sessions/:id/events (A4)', () => { it('returns 404 for an unknown session id when the timeline is enabled', async () => { const { port } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/live-sessions/${UNKNOWN}/events`) expect(res.status).toBe(404) }) it('returns an empty array when TIMELINE_ENABLED=0 (AC-A4.6)', async () => { const { port } = await spawnServer({ TIMELINE_ENABLED: '0' }) const res = await fetch(`http://127.0.0.1:${port}/live-sessions/${UNKNOWN}/events`) expect(res.status).toBe(200) expect(await res.json()).toEqual([]) }) itPty('returns derived timeline entries after a /hook POST', async () => { const { port, origin } = await spawnServer() const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } }) await waitForOpen(ws) ws.send(JSON.stringify({ type: 'attach', sessionId: null })) const attached = await waitForMessage(ws, isAttached) const sessionId = attached['sessionId'] as string const hookRes = await fetch(`http://127.0.0.1:${port}/hook`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, body: JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash' }), }) expect(hookRes.status).toBe(204) const evRes = await fetch(`http://127.0.0.1:${port}/live-sessions/${sessionId}/events`) expect(evRes.status).toBe(200) const events = (await evRes.json()) as { class: string; label: string; toolName?: string }[] expect(Array.isArray(events)).toBe(true) const tool = events.find((e) => e.class === 'tool') expect(tool).toBeDefined() expect(tool!.label).toContain('Bash') ws.close() }) }) // ── POST /hook/status (B2) ─────────────────────────────────────────────────────── describe('POST /hook/status (B2)', () => { it('rejects a non-object body with 400', async () => { const { port } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/hook/status`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': UNKNOWN }, body: JSON.stringify('not-an-object'), }) expect(res.status).toBe(400) }) itPty('ingests telemetry from loopback and broadcasts it to the attached client', async () => { const { port, origin } = await spawnServer() const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { headers: { Origin: origin } }) await waitForOpen(ws) ws.send(JSON.stringify({ type: 'attach', sessionId: null })) const attached = await waitForMessage(ws, isAttached) const sessionId = attached['sessionId'] as string const telemetryPromise = waitForMessage(ws, (m) => m['type'] === 'telemetry') const res = await fetch(`http://127.0.0.1:${port}/hook/status`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Webterm-Session': sessionId }, body: JSON.stringify({ context_window: { used_percentage: 42 }, cost: { total_cost_usd: 1.23 }, model: { display_name: 'opus' }, }), }) expect(res.status).toBe(204) const msg = (await telemetryPromise) as { telemetry: Record } expect(msg.telemetry['contextUsedPct']).toBe(42) expect(msg.telemetry['costUsd']).toBe(1.23) expect(msg.telemetry['model']).toBe('opus') ws.close() }) }) // ── GET /config/ui (review #4) ──────────────────────────────────────────────────── describe('GET /config/ui', () => { it('reports allowAutoMode:false by default', async () => { const { port } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/config/ui`) expect(res.status).toBe(200) expect(await res.json()).toEqual({ allowAutoMode: false, maxFanoutLanes: 6 }) }) it('reports allowAutoMode:true when ALLOW_AUTO_MODE=1', async () => { const { port } = await spawnServer({ ALLOW_AUTO_MODE: '1' }) const res = await fetch(`http://127.0.0.1:${port}/config/ui`) expect(await res.json()).toEqual({ allowAutoMode: true, maxFanoutLanes: 6 }) }) it('omits costBudgetUsd when the budget is unset (W3 b)', async () => { const { port } = await spawnServer() const res = await fetch(`http://127.0.0.1:${port}/config/ui`) const body = (await res.json()) as Record expect(body).not.toHaveProperty('costBudgetUsd') }) it('reports costBudgetUsd when COST_BUDGET_USD is set (W3 b)', async () => { const { port } = await spawnServer({ COST_BUDGET_USD: '7.5' }) const res = await fetch(`http://127.0.0.1:${port}/config/ui`) expect(await res.json()).toEqual({ allowAutoMode: false, costBudgetUsd: 7.5, maxFanoutLanes: 6 }) }) })