98 lines · 3.8 KB · TypeScript
RawDownload
// CLI/TUI surface: a real PTY via terminal-control. The scenario drives the// session (type/press/waitForText) and asserts on the rendered screen with// vitest; pass `record` to save an asciicast v2 the viewer can replay. The// recording is written in release so a timeout still leaves the evidence.import { writeFileSync } from "node:fs";import { dirname } from "node:path"; import { Effect } from "effect";import { TerminalControl, type Session } from "@kitlangton/terminal-control"; import { beat, enterFocus, markRecordingStart } from "../timeline"; export interface CliSurface {  readonly session: <T>(    command: readonly [string, ...string[]],    drive: (session: Session) => Promise<T>,    options?: {      readonly cwd?: string;      readonly env?: Record<string, string>;      /** Path to write an asciicast v2 (.cast) of the whole session. */      readonly record?: string;      readonly viewport?: { readonly cols: number; readonly rows: number };    },  ) => Effect.Effect<T>;} /** terminal-control's JSONL recording → asciicast v2 (what asciinema plays). */const toAsciicast = (recording: Uint8Array): string => {  const events = new TextDecoder()    .decode(recording)    .split("\n")    .filter(Boolean)    .map(      (line) =>        JSON.parse(line) as {          type: string;          cols?: number;          rows?: number;          at_ms?: number;          bytes?: number[];        },    );  const header = events.find((event) => event.type === "header");  const lines = [    JSON.stringify({ version: 2, width: header?.cols ?? 80, height: header?.rows ?? 24 }),  ];  // One streaming decoder so multi-byte UTF-8 split across events survives.  const decoder = new TextDecoder();  for (const event of events) {    if (event.type !== "output") continue;    const text = decoder.decode(Uint8Array.from(event.bytes ?? []), { stream: true });    if (text) lines.push(JSON.stringify([(event.at_ms ?? 0) / 1000, "o", text]));  }  return `${lines.join("\n")}\n`;}; // acquireUseRelease so a vitest timeout (fiber interruption) still tears the// PTY down instead of leaking the child process.export const makeCliSurface = (): CliSurface => ({  session: (command, drive, options) =>    Effect.acquireUseRelease(      Effect.promise(async () => {        const tc = await TerminalControl.make();        const session: Session = await tc.launch({          command,          cwd: options?.cwd,          env: options?.env,          record: options?.record ? true : undefined,          viewport: options?.viewport,        });        // Anchor the terminal recording on the run's focus timeline (symmetric        // with the browser surface), so a combined cli+browser scenario gets the        // synced SessionPlayer view (terminal/browser cuts + URL bar) for free.        const runDir = options?.record ? dirname(options.record) : null;        if (runDir) markRecordingStart(runDir, "terminal");        return { tc, session, runDir };      }),      ({ session, runDir }) =>        Effect.promise(async () => {          if (runDir) await enterFocus(runDir, "terminal");          const result = await drive(session);          // Hold the terminal's final frame (e.g. "connected") before the          // recording stops, so it isn't a single flash in the film. Filming          // only — fast runs return immediately.          await beat();          return result;        }),      ({ tc, session }) =>        Effect.promise(async () => {          if (options?.record) {            const recording = await session.recording().catch(() => undefined);            if (recording) writeFileSync(options.record, toAsciicast(recording));          }          await session.stop().catch(() => {});          await tc[Symbol.asyncDispose]();        }),    ),});