Files
web-terminal/test/integration/timeline-events.test.ts
Yaojia Wang 9683a16f4f feat(fanout): worktree fan-out board — race N agent lanes of one repo (W5)
Fan ONE prompt across N branch/agent lanes: N worktrees, N Claude sessions on the
same prompt, watched side-by-side in the split-grid board, approve/kill per lane,
🏆 keep the winner (losers' worktrees removed). ~90% composition of shipped parts.

- src/http/session-groups.ts (new, pure, no git exec): deriveRepoRoot /
  groupSessionsByRepo (cluster sessions by their <repo>-worktrees parent).
- GET /live-sessions/grouped (read-only; registered BEFORE /live-sessions/:id so
  "grouped" isn't captured as an id). MAX_FANOUT_LANES env (default 6, = grid-6 cap).
- public/fanout.ts (new, pure): buildFanoutCmd shell-quotes the prompt (single-quote
  wrap with '\''-escaping so $(...)/backticks/;/&& can't execute) + collapses newlines
  + caps 4000 chars; laneBranch/slugify. Effective N = min(lanes, maxFanoutLanes, 6),
  ≥2; the maxSessions cap is enforced server-side ("Started K of N" banner).
- public/tabs.ts launchFanout (N× createWorktree → openProject w/ prompt pre-injected
  via the existing initialInput) + keepFanoutWinner; public/projects.ts renderFanoutForm
  + extracted shared createWorktreeReq (DRY). Byte-shuttle preserved (lane = own PTY).
  One-click merge deferred (winner session stays open for a manual merge).

Reused unchanged: createWorktree/removeWorktree, addEntry/initialInput, split-grid +
per-quadrant approve/maximize/monitor + gauges. Verified: typecheck + build:web clean,
2063 pass at --test-timeout=30000 (only the known tmux/PTY flake red). Fixed 3
/config/ui exact-shape tests to include the new maxFanoutLanes field.
2026-07-13 05:09:19 +02:00

231 lines
8.3 KiB
TypeScript

/**
* 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<number> {
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<void> }[] = []
async function spawnServer(
overrides: Record<string, string | undefined> = {},
): 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<void>((r) => setTimeout(r, 80))
return { port, origin: `http://127.0.0.1:${port}` }
}
function waitForOpen(ws: WebSocket): Promise<void> {
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<string, unknown>) => boolean): Promise<Record<string, unknown>> {
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<string, unknown>
try {
m = JSON.parse(raw.toString('utf8')) as Record<string, unknown>
} catch {
return
}
if (pred(m)) {
clearTimeout(t)
ws.off('message', onMsg)
resolve(m)
}
}
ws.on('message', onMsg)
})
}
const isAttached = (m: Record<string, unknown>): boolean => m['type'] === 'attached'
afterEach(async () => {
while (handles.length > 0) await handles.pop()?.close()
await new Promise<void>((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<string, unknown> }
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<string, unknown>
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 })
})
})