Four small, high-delight features that turn passive capture into glanceable signals.
- Sync chip on project cards: ahead/behind vs upstream + last-commit time, folded
into the existing concurrent per-repo metadata pass (git rev-list --count
--left-right @{u}...HEAD + git log -1 --format=%ct; no upstream → undefined, no route).
- Cost budget guard: COST_BUDGET_USD env (0 = off); a per-session one-shot latch
(Session.budgetNotified, cost is monotonic so never re-armed) fires a single push
on threshold crossing in manager.handleStatusLine; the already-broadcast telemetry
frame carries the warn (tg-cost-warn styling derived from costUsd>=budget via
/config/ui — no new ServerMessage). web-push title added to sw-push.js.
- "While you were away" digest: GET /digest?since= → {finished, needsInput, stuck,
totalCostUsd, sessions[]} aggregate over manager.list(); FE banner on reconnect.
- Recent commits per project: src/http/git-log.ts (NUL-delimited git log → CommitInfo[]),
GET /projects/log?path= (isValidGitDir), textContent-inert render in project detail.
All git via execFile (no shell) + validated cwd; new routes read-only; commit
messages rendered via textContent. Verified: typecheck + build:web clean, 1904 pass
at --test-timeout=30000 (two default-5s failures are slow-sandbox real-subprocess
timeout flakes — the known ring-buffer test + a new real-git-clone sync test — not
logic regressions).
231 lines
8.2 KiB
TypeScript
231 lines
8.2 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 })
|
|
})
|
|
|
|
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 })
|
|
})
|
|
|
|
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 })
|
|
})
|
|
})
|