diff --git a/test/tui/tui-e2e.test.ts b/test/tui/tui-e2e.test.ts new file mode 100644 index 00000000..3bda7d96 --- /dev/null +++ b/test/tui/tui-e2e.test.ts @@ -0,0 +1,354 @@ +/** + * @fileoverview End-to-end test for `codeman tui` in a real terminal. + * + * The dashboard is spawned under node-pty against a fake API server, so this + * covers everything the pure tests cannot: raw-mode key decoding, the frame + * actually reaching a terminal, SSE-driven refresh, and the exit sequence that + * has to restore the user's screen. Frames are addressed absolutely rather than + * newline-separated, so the assertions parse the LAST frame out of the captured + * bytes and read its list column. + * + * The child gets its own data dir and a tmux socket name nothing runs on, which + * keeps the enumeration that degraded mode and the attach path use from seeing + * the machine's real sessions. Nothing here attaches, kills or writes anything. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { spawn } from 'node:child_process'; +import http from 'node:http'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import * as pty from 'node-pty'; +import { computeLayout } from '../../src/tui/tui-layout.js'; +import type { UnifiedSessionItem } from '../../src/services/unified-session-service.js'; + +const PORT = 3244; +const BASE_URL = `http://127.0.0.1:${PORT}`; +const ROOT = resolve(import.meta.dirname, '..', '..'); +const COLS = 100; +const ROWS = 30; +const LIST_WIDTH = computeLayout(COLS, ROWS).list.width; + +const NOW = Date.now(); + +/** Mutable so a test can add a session and announce it over SSE. */ +let sessions: UnifiedSessionItem[] = []; + +function resetSessions(): void { + sessions = [ + { + sessionId: 'bbbb2222-0000-0000-0000-000000000000', + name: 'w2-beta', + mode: 'claude', + sources: ['live'], + isWorking: true, + workingDir: '/tmp/beta', + createdAt: NOW - 600_000, + lastActivityAt: NOW, + }, + { + sessionId: 'aaaa1111-0000-0000-0000-000000000000', + name: 'w1-alpha', + mode: 'claude', + sources: ['live'], + status: 'idle', + workingDir: '/tmp/alpha', + createdAt: NOW - 900_000, + lastActivityAt: NOW - 60_000, + }, + { + sessionId: 'cccc3333-0000-0000-0000-000000000000', + name: 'w3-gamma', + sources: ['history'], + workingDir: '/tmp/gamma', + lastActivityAt: NOW - 3_600_000, + }, + ]; +} + +let server: http.Server; +const sseClients = new Set(); +let dataDir = ''; + +function sendJson(res: http.ServerResponse, payload: unknown): void { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); +} + +function pushEvent(event: string, data: unknown): void { + for (const client of sseClients) client.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); +} + +function childEnv(): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(process.env)) if (value !== undefined) env[key] = value; + // The suite runs inside a Codeman-managed tmux pane, whose environment would + // otherwise point the child at the LIVE server and make it think it is nested. + delete env.TMUX; + delete env.TMUX_PANE; + delete env.CODEMAN_SESSION_ID; + delete env.FORCE_COLOR; + delete env.CODEMAN_PORT; + return { + ...env, + CODEMAN_API_URL: BASE_URL, + CODEMAN_DATA_DIR: dataDir, + CODEMAN_TMUX_SOCKET: 'codeman-tui-e2e', + CODEMAN_TUI_GLYPHS: 'ascii', + NO_COLOR: '1', + TERM: 'xterm-256color', + LANG: 'C.UTF-8', + }; +} + +beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'codeman-tui-e2e-')); + resetSessions(); + server = http.createServer((req, res) => { + const url = req.url ?? ''; + if (url.startsWith('/api/events')) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + res.write(`event: init\ndata: ${JSON.stringify({ version: '9.9.9', planUsage: null })}\n\n`); + sseClients.add(res); + req.on('close', () => sseClients.delete(res)); + return; + } + if (url.startsWith('/api/status')) return sendJson(res, { success: true, data: { version: '9.9.9' } }); + if (url.startsWith('/api/sessions/unified')) return sendJson(res, { success: true, data: { sessions } }); + if (url.startsWith('/api/approvals')) return sendJson(res, { success: true, data: { approvals: [] } }); + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'no route', errorCode: 'NOT_FOUND' })); + }); + await new Promise((done) => server.listen(PORT, '127.0.0.1', done)); +}); + +afterAll(async () => { + for (const client of sseClients) client.destroy(); + sseClients.clear(); + await new Promise((done) => server.close(() => done())); + if (dataDir) rmSync(dataDir, { recursive: true, force: true }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Frame parsing +// ───────────────────────────────────────────────────────────────────────────── + +const ANSI = /\u001b\[[0-9;?]*[a-zA-Z]/g; + +/** + * The last COMPLETE frame, one entry per terminal row. Frames start at + * `ESC [ 1;1 H` and address every row absolutely, so splitting on the + * addressing sequences reconstructs the lines. The pty delivers a frame in + * several chunks, so the newest one is often half-written: taking it would make + * every assertion that indexes a line racy. + */ +function frameLines(raw: string): string[] { + const frames = raw.split('\u001b[1;1H').slice(1); + for (let i = frames.length - 1; i >= 0; i--) { + const lines = frames[i].split(/\u001b\[\d+;1H/).map((line) => line.replace(ANSI, '').replace(/\s+$/, '')); + if (lines.length >= ROWS) return lines.slice(0, ROWS); + } + return []; +} + +/** Just the sidebar column, so a name in the preview pane cannot answer for a row. */ +function listLines(raw: string): string[] { + return frameLines(raw).map((line) => line.slice(0, LIST_WIDTH).replace(/\s+$/, '')); +} + +function rowFor(raw: string, name: string): string { + return listLines(raw).find((line) => line.includes(name)) ?? ''; +} + +async function waitFor(predicate: () => boolean, what: string, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (predicate()) return; + if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`); + await new Promise((done) => setTimeout(done, 50)); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// The dashboard +// ───────────────────────────────────────────────────────────────────────────── + +describe('codeman tui (under a pty)', () => { + let term: pty.IPty; + let output = ''; + let exitCode: number | null = null; + + beforeAll(async () => { + term = pty.spawn('npx', ['tsx', 'src/index.ts', 'tui'], { + name: 'xterm-256color', + cols: COLS, + rows: ROWS, + cwd: ROOT, + env: childEnv(), + }); + term.onData((data) => { + output += data; + }); + term.onExit(({ exitCode: code }) => { + exitCode = code; + }); + await waitFor(() => output.includes('w2-beta'), 'the first frame', 25_000); + }, 40_000); + + afterAll(() => { + if (exitCode === null) term.kill(); + }); + + it('enters the alternate screen and hides the cursor', () => { + expect(output).toContain('\u001b[?1049h'); + expect(output).toContain('\u001b[?25l'); + }); + + it('groups the sessions the way the dashboard promises', () => { + const lines = listLines(output); + const index = (needle: string) => lines.findIndex((line) => line.includes(needle)); + expect(index('WORKING')).toBeGreaterThan(0); + expect(index('WORKING')).toBeLessThan(index('w2-beta')); + expect(index('w2-beta')).toBeLessThan(index('IDLE')); + expect(index('IDLE')).toBeLessThan(index('w1-alpha')); + expect(index('w1-alpha')).toBeLessThan(index('RECENT')); + expect(index('RECENT')).toBeLessThan(index('w3-gamma')); + }); + + it('shows the header facts and only the keys that work', () => { + const lines = frameLines(output); + expect(lines[0]).toContain('codeman'); + expect(lines[0]).toContain('v9.9.9'); + // Two live rows; the history row is not a session you have open. + expect(lines[0]).toContain('2 sessions'); + const footer = lines[ROWS - 1]; + expect(footer).toContain('attach'); + expect(footer).toContain('x kill'); + expect(footer).not.toContain('search'); + }); + + it('holds the preview seam open instead of pretending to load one', () => { + expect(frameLines(output).join('\n')).toContain('live preview is not wired up yet'); + }); + + it('starts with the first row selected and moves the cursor with j / k', async () => { + expect(rowFor(output, 'w2-beta').startsWith('>')).toBe(true); + + term.write('j'); + await waitFor(() => rowFor(output, 'w1-alpha').startsWith('>'), 'j to select the next row'); + expect(rowFor(output, 'w2-beta').startsWith('>')).toBe(false); + + term.write('k'); + await waitFor(() => rowFor(output, 'w2-beta').startsWith('>'), 'k to select the previous row'); + }); + + it('moves the cursor with the arrow keys', async () => { + term.write('\u001b[B'); + await waitFor(() => rowFor(output, 'w1-alpha').startsWith('>'), 'the down arrow to move the cursor'); + term.write('\u001b[A'); + await waitFor(() => rowFor(output, 'w2-beta').startsWith('>'), 'the up arrow to move the cursor'); + }); + + it('picks up a session announced over SSE', async () => { + sessions = [ + ...sessions, + { + sessionId: 'dddd4444-0000-0000-0000-000000000000', + name: 'w4-delta', + mode: 'shell', + sources: ['live'], + status: 'idle', + workingDir: '/tmp/delta', + createdAt: NOW, + lastActivityAt: NOW, + }, + ]; + pushEvent('session:created', { id: 'dddd4444-0000-0000-0000-000000000000' }); + await waitFor(() => listLines(output).some((line) => line.includes('w4-delta')), 'the new session to appear'); + expect(frameLines(output)[0]).toContain('3 sessions'); + }); + + it('opens and closes the help overlay', async () => { + term.write('?'); + await waitFor(() => frameLines(output).some((line) => line.includes('Keys')), 'the help overlay'); + expect(frameLines(output).join('\n')).toContain('kill (typed confirmation)'); + + term.write('\u001b'); + await waitFor(() => !frameLines(output).some((line) => line.includes('Keys')), 'escape to close the overlay'); + }); + + it('asks for the session name before killing anything', async () => { + term.write('x'); + await waitFor(() => frameLines(output).some((line) => line.includes('Kill session')), 'the kill confirmation'); + const overlay = frameLines(output).join('\n'); + expect(overlay).toContain('Type the name to confirm'); + expect(overlay).toContain('w2-beta'); + + term.write('\u001b'); + await waitFor(() => !frameLines(output).some((line) => line.includes('Kill session')), 'escape to cancel the kill'); + }); + + it('quits on q and restores the screen it took over', async () => { + term.write('q'); + await waitFor(() => exitCode !== null, 'the TUI to exit', 10_000); + expect(exitCode).toBe(0); + expect(output).toContain('\u001b[?25h'); + expect(output).toContain('\u001b[?1049l'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// The non-interactive fast paths +// ───────────────────────────────────────────────────────────────────────────── + +interface RunResult { + code: number | null; + stdout: string; + stderr: string; +} + +/** Run the CLI with pipes, which is exactly the "not a TTY" case. */ +function runPiped(args: string[]): Promise { + return new Promise((done) => { + const child = spawn('npx', ['tsx', 'src/index.ts', ...args], { cwd: ROOT, env: childEnv() }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf-8'); + child.stderr.setEncoding('utf-8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('close', (code) => done({ code, stdout, stderr })); + }); +} + +describe('codeman tui --list', () => { + it('prints the numbered list and exits 0 when piped', async () => { + const result = await runPiped(['tui', '--list']); + expect(result.code).toBe(0); + expect(result.stdout).toContain('w2-beta'); + expect(result.stdout).toContain('w1-alpha'); + expect(result.stdout).toContain('/tmp/alpha'); + // Same ordering as the dashboard: WORKING first, history last. + expect(result.stdout.indexOf('w2-beta')).toBeLessThan(result.stdout.indexOf('w1-alpha')); + expect(result.stdout.indexOf('w1-alpha')).toBeLessThan(result.stdout.indexOf('w3-gamma')); + expect(result.stdout).toMatch(/^\s+1\s+working\s+w2-beta/m); + }, 30_000); +}); + +describe('codeman tui without a terminal', () => { + it('refuses to open the dashboard and points at the fast paths', async () => { + const result = await runPiped(['tui']); + expect(result.code).toBe(1); + expect(result.stderr).toContain('interactive terminal'); + expect(result.stderr).toContain('--list'); + expect(result.stdout).not.toContain('\u001b[?1049h'); + }, 30_000); +});