diff --git a/e2e-harness/tui-capture.ts b/e2e-harness/tui-capture.ts index 0a7ac388..2df7bafd 100644 --- a/e2e-harness/tui-capture.ts +++ b/e2e-harness/tui-capture.ts @@ -9,6 +9,7 @@ import fsmod from 'fs'; import pathmod from 'path'; import * as pty from 'node-pty'; import { createRequire } from 'module'; +import type { IBufferLine } from '@xterm/headless'; // @xterm/headless ships CJS; its `module` field points at the full browser build, // so import the headless CJS entry directly to get a working Terminal in Node. @@ -37,6 +38,8 @@ function ensureSpawnHelper(): void { export interface TuiCapture { /** The current rendered screen as clean text (trailing blank lines trimmed). */ frame(): string; + /** The current screen serialized back to ANSI — colors and attributes kept. */ + frameAnsi(): string; /** Fires after each chunk of terminal output is applied. */ onData(cb: () => void): void; kill(): void; @@ -44,6 +47,44 @@ export interface TuiCapture { exited: Promise; } +// Serialize one buffer row back to ANSI: re-emit SGR (colors + attributes) each +// time the active style changes, then the cell's character. The inverse of +// xterm's parse, so a captured frame reproduces the colored CLI, not plain text. +function rowToAnsi(line: IBufferLine, cols: number): string { + let out = ''; + let active = ''; + for (let x = 0; x < cols; x++) { + const cell = line.getCell(x); + if (!cell || cell.getWidth() === 0) continue; // skip wide-char trailing cell + const codes: number[] = []; + if (cell.isBold()) codes.push(1); + if (cell.isDim()) codes.push(2); + if (cell.isItalic()) codes.push(3); + if (cell.isUnderline()) codes.push(4); + if (cell.isBlink()) codes.push(5); + if (cell.isInverse()) codes.push(7); + if (cell.isInvisible()) codes.push(8); + if (cell.isStrikethrough()) codes.push(9); + if (cell.isFgRGB()) { + const c = cell.getFgColor(); + codes.push(38, 2, (c >> 16) & 255, (c >> 8) & 255, c & 255); + } else if (cell.isFgPalette()) codes.push(38, 5, cell.getFgColor()); + if (cell.isBgRGB()) { + const c = cell.getBgColor(); + codes.push(48, 2, (c >> 16) & 255, (c >> 8) & 255, c & 255); + } else if (cell.isBgPalette()) codes.push(48, 5, cell.getBgColor()); + const sgr = codes.join(';'); + if (sgr !== active) { + out += '\x1b[0m'; + if (sgr) out += `\x1b[${sgr}m`; + active = sgr; + } + out += cell.getChars() || ' '; + } + if (active) out += '\x1b[0m'; + return out; +} + export function captureTui(opts: { cmd: string; args: string[]; @@ -92,6 +133,22 @@ export function captureTui(opts: { while (lines.length && !lines[lines.length - 1].trim()) lines.pop(); return lines.join('\n') + '\n'; }, + frameAnsi() { + const buf = term.buffer.active; + // Trailing blank rows trimmed by plain content (same shape as frame()). + let end = rows; + while (end > 0) { + const line = buf.getLine(end - 1); + if (line && line.translateToString(true).trim()) break; + end--; + } + const lines: string[] = []; + for (let i = 0; i < end; i++) { + const line = buf.getLine(i); + lines.push(line ? rowToAnsi(line, cols) : ''); + } + return lines.join('\n') + '\n'; + }, onData(cb) { cbs.push(cb); }, diff --git a/scripts/tui-snapshots.no-jest.ts b/scripts/tui-snapshots.no-jest.ts index 4c0ee881..c306fd01 100644 --- a/scripts/tui-snapshots.no-jest.ts +++ b/scripts/tui-snapshots.no-jest.ts @@ -2,8 +2,9 @@ * Fixed-route snapshots of the REAL TUI (Node, single-stack). * * Spawns the real-TUI host (MODE=fixed) in a PTY, lets it self-drive the fixed - * e2e profile through the real agent run, and writes the real rendered screen to - * SNAP_OUT/NN-.txt at each key moment the host signals. + * e2e profile through the real agent run, and writes the real rendered screen — + * serialized back to ANSI, colors preserved — to SNAP_OUT/NN-.ans at + * each key moment the host signals. * * SNAP_OUT=/tmp/snaps APP_DIR=/tmp/app POSTHOG_KEY_FILE=… PROJECT_ID=… \ * npx tsx scripts/tui-snapshots.no-jest.ts @@ -43,8 +44,8 @@ async function drainCtrl() { if (!label) continue; await sleep(200); // let xterm apply the final writes for this screen seq += 1; - const fn = path.join(OUT, `${String(seq).padStart(2, '0')}-${label}.txt`); - fs.writeFileSync(fn, cap.frame()); + const fn = path.join(OUT, `${String(seq).padStart(2, '0')}-${label}.ans`); + fs.writeFileSync(fn, cap.frameAnsi()); // eslint-disable-next-line no-console console.log('snap ->', path.basename(fn)); }