Repository Public
1/**2 * Test-only: a browser stand-in for the terminal WebSocket. Every binary frame goes into a3 * headless xterm (the same parser as the web client), so tests assert on what a browser would4 * hold in its buffer, scrollback included. Imported exclusively by `*.test.ts`.5 */6import xtermHeadless from '@xterm/headless';7import WebSocket from 'ws';8import { waitUntil, type TestEnv } from './core-testkit.js';910const { Terminal } = xtermHeadless;11export type Term = InstanceType<typeof Terminal>;1213export const newTerm = (cols: number, rows: number): Term => new Terminal({ cols, rows, scrollback: 50_000, allowProposedApi: true });14export const flush = (term: Term, data: string | Uint8Array = ''): Promise<void> => new Promise((resolve) => term.write(data, resolve));15export function allLines(term: Term): string[] {16 const b = term.buffer.active;17 const out: string[] = [];18 for (let i = 0; i < b.length; i++) out.push(b.getLine(i)?.translateToString(true) ?? '');19 return out;20}21export const screen = (term: Term): string[] => allLines(term).slice(term.buffer.active.baseY);22/** A browser stand-in: every binary frame goes into a headless xterm, like the web controller. */23export class XtermClient {24 readonly term: Term;25 bytes = 0;26 closed = false;27 private constructor(readonly ws: WebSocket, cols: number, rows: number) {28 this.term = newTerm(cols, rows);29 ws.on('message', (data: WebSocket.RawData, isBinary: boolean) => {30 if (!isBinary) return;31 const buf = Buffer.isBuffer(data) ? data : Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);32 this.bytes += buf.length;33 this.term.write(buf);34 });35 ws.on('close', () => (this.closed = true));36 }37 static async open(env: TestEnv, id: string, cols = 100, rows = 30): Promise<XtermClient> {38 const ws = new WebSocket(`ws+unix:${env.config.socketPath}:/sessions/${id}/attach?cols=${cols}&rows=${rows}`, { headers: { authorization: `Bearer ${env.config.token}` } });39 const c = new XtermClient(ws, cols, rows);40 await new Promise<void>((resolve, reject) => {41 ws.once('open', () => resolve());42 ws.once('error', reject);43 });44 return c;45 }46 type(text: string): void {47 this.ws.send(Buffer.from(text, 'utf8'), { binary: true });48 }49 resize(cols: number, rows: number): void {50 this.term.resize(cols, rows); // the browser terminal resizes itself, then tells the server51 this.ws.send(JSON.stringify({ t: 'resize', cols, rows }));52 }53 /** Waits until the terminal content satisfies `fn` (all received bytes parsed first). */54 async until<T>(fn: (term: Term) => T, what: string, timeoutMs = 15_000): Promise<NonNullable<T>> {55 try {56 return await waitUntil(async () => {57 await flush(this.term);58 return fn(this.term);59 }, timeoutMs, what);60 } catch (err) {61 await flush(this.term);62 throw new Error(`${String(err)}; baseY=${this.term.buffer.active.baseY} lines=${this.term.buffer.active.length} screen=${JSON.stringify(screen(this.term).filter(Boolean).slice(-6))}`);63 }64 }65 async close(): Promise<void> {66 if (!this.closed) this.ws.close();67 await waitUntil(() => this.closed, 5000, 'ws close');68 this.term.dispose();69 }70}7172