/** * Integration test for GET /live-sessions/grouped (W5 fan-out board) and the * /config/ui maxFanoutLanes field. * * Starts a real HTTP server. The grouped route is read-only (no Origin guard) and * derives groups purely from cwd — the empty-manager path needs no PTY. The * real-PTY case (two sessions under one *-worktrees dir clustering into one group) * is itPty-gated: posix_spawn is blocked in the sandbox, so it auto-skips there * and runs in CI. */ import net from 'node:net' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { afterAll, afterEach, beforeAll, 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' import type { SessionGroup, UiConfig } from '../../src/types.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('unexpected address type')) return } const port = addr.port srv.close(() => resolve(port)) }) srv.on('error', reject) }) } function waitForOpen(ws: WebSocket, timeoutMs = 3_000): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('WS open timeout')), timeoutMs) ws.once('open', () => { clearTimeout(timer) resolve() }) ws.once('error', (err) => { clearTimeout(timer) reject(err) }) }) } /** Resolve once an 'attached' frame arrives (output frames may interleave). */ function waitForAttached(ws: WebSocket, timeoutMs = 4_000): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('attached timeout')), timeoutMs) const onMsg = (raw: WebSocket.RawData): void => { let msg: { type?: string } try { msg = JSON.parse(raw.toString('utf8')) } catch { return } if (msg.type === 'attached') { clearTimeout(timer) ws.off('message', onMsg) resolve() } } ws.on('message', onMsg) }) } describe('GET /live-sessions/grouped + /config/ui maxFanoutLanes — integration', () => { let port: number let serverHandle: { close(): Promise } const origin = (): string => `http://127.0.0.1:${port}` beforeAll(async () => { 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', MAX_FANOUT_LANES: '5', }) serverHandle = startServer(cfg) await new Promise((r) => setTimeout(r, 100)) }) afterAll(async () => { await serverHandle.close() }) it('returns 200 [] on an empty manager, WITHOUT requiring an Origin header', async () => { const res = await fetch(`${origin()}/live-sessions/grouped`) expect(res.status).toBe(200) // read-only: not 403 even with no Origin const body = (await res.json()) as SessionGroup[] expect(Array.isArray(body)).toBe(true) expect(body).toHaveLength(0) }) it('does not shadow "grouped" as a session :id (returns an array, not an error)', async () => { const res = await fetch(`${origin()}/live-sessions/grouped`) const body = await res.json() expect(Array.isArray(body)).toBe(true) }) it('exposes maxFanoutLanes on /config/ui', async () => { const res = await fetch(`${origin()}/config/ui`) expect(res.status).toBe(200) const body = (await res.json()) as UiConfig expect(body.maxFanoutLanes).toBe(5) }) // ── real-PTY: two sessions under one *-worktrees dir cluster into one group ── let tmpRepo: string | null = null const sockets: WebSocket[] = [] afterEach(() => { for (const ws of sockets.splice(0)) { try { ws.close() } catch { // ignore } } }) itPty('clusters two worktree-lane sessions into a single group', async () => { tmpRepo = await fs.mkdtemp(path.join(os.tmpdir(), 'fanout-')) const laneDir = path.join(tmpRepo, 'proj-worktrees') const lane1 = path.join(laneDir, 'lane-1') const lane2 = path.join(laneDir, 'lane-2') await fs.mkdir(lane1, { recursive: true }) await fs.mkdir(lane2, { recursive: true }) for (const cwd of [lane1, lane2]) { const ws = new WebSocket(`ws://127.0.0.1:${port}/term`, { origin: origin() }) sockets.push(ws) await waitForOpen(ws) ws.send(JSON.stringify({ type: 'attach', sessionId: null, cwd })) await waitForAttached(ws) } // Give the sessions a beat to register their cwd. await new Promise((r) => setTimeout(r, 100)) const res = await fetch(`${origin()}/live-sessions/grouped`) const groups = (await res.json()) as SessionGroup[] const proj = groups.find((g) => g.repoRoot === path.join(tmpRepo!, 'proj')) expect(proj).toBeDefined() expect(proj!.label).toBe('proj') expect(proj!.sessions.length).toBe(2) }) })