mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
Spawns the real command in a pseudo-terminal against a fake API server (canned status/unified/approvals plus an SSE stream the test pushes into), which is the only way to cover raw-mode key decoding, frames reaching a terminal, SSE-driven refresh and the exit sequence that has to restore the user's screen. Two details the assertions depend on: frames are addressed absolutely rather than newline-separated, so the parser takes the last COMPLETE frame (the pty delivers one in several chunks, and reading a half-written frame would be racy), and it reads the sidebar column only, or a name echoed in the preview pane could answer for a row. The child gets its own data dir and a tmux socket name nothing runs on, so nothing here can see or touch the machine's real sessions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
355 lines
14 KiB
TypeScript
355 lines
14 KiB
TypeScript
/**
|
|
* @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<http.ServerResponse>();
|
|
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<string, string> {
|
|
const env: Record<string, string> = {};
|
|
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<void>((done) => server.listen(PORT, '127.0.0.1', done));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
for (const client of sseClients) client.destroy();
|
|
sseClients.clear();
|
|
await new Promise<void>((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<void> {
|
|
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<RunResult> {
|
|
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);
|
|
});
|