125 lines · 5.0 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>;} interface AsciicastEvent {  type: string;  cols?: number;  rows?: number;  at_ms?: number;  bytes?: number[];} // E2E subprocesses must never report product usage or fetch the production// integrations catalog. Keep these after caller-provided values below so a// scenario cannot accidentally turn outbound telemetry back on.const E2E_OUTBOUND_OPT_OUT = {  DO_NOT_TRACK: "1",  EXECUTOR_DISABLE_ANALYTICS: "1",  EXECUTOR_DISABLE_INTEGRATIONS_FETCH: "1",} as const; /** 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)    .flatMap((line): AsciicastEvent[] => {      // The PTY can be killed mid-write on teardown (e.g. a vitest timeout      // interrupts the fiber), leaving a truncated final JSONL line. A line      // that doesn't parse is one dropped frame, never a failed test: skip it      // instead of throwing.      // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: lenient parse of a best-effort recording artifact      try {        return [JSON.parse(line) as AsciicastEvent];      } catch {        return [];      }    });  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, ...E2E_OUTBOUND_OPT_OUT },          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);            // Writing the cast is a best-effort film artifact (symmetric with the            // fetch above): a serialization or IO hiccup in teardown must never            // fail an otherwise-passing test.            // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: best-effort artifact write            try {              if (recording) writeFileSync(options.record, toAsciicast(recording));            } catch {              // drop the cast; the test result stands            }          }          await session.stop().catch(() => {});          await tc[Symbol.asyncDispose]();        }),    ),});