SPB Git

Repository Public

core-xterm-testkit.ts 3.2 KB 72 lines typescript

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
Files main
Menu

Theme