From c2e25da69e30fa2582f09fd13243a83bc93e615c Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 20 Oct 2025 19:51:22 +0200 Subject: [PATCH 1/2] chore: terminal test helper --- bun.lock | 1 + packages/blink/package.json | 1 + packages/blink/src/cli/lib/terminal.test.ts | 15 ++ packages/blink/src/cli/lib/terminal.ts | 220 ++++++++++++++++++++ 4 files changed, 237 insertions(+) create mode 100644 packages/blink/src/cli/lib/terminal.test.ts create mode 100644 packages/blink/src/cli/lib/terminal.ts diff --git a/bun.lock b/bun.lock index 9f11eed..ec821fa 100644 --- a/bun.lock +++ b/bun.lock @@ -51,6 +51,7 @@ "@types/react": "^19.2.2", "@types/ws": "^8.18.1", "@whatwg-node/server": "^0.10.12", + "@xterm/headless": "^5.5.0", "chalk": "^5.6.2", "commander": "^14.0.0", "dotenv": "^17.2.3", diff --git a/packages/blink/package.json b/packages/blink/package.json index 3ce9658..22c1435 100644 --- a/packages/blink/package.json +++ b/packages/blink/package.json @@ -104,6 +104,7 @@ "@types/react": "^19.2.2", "@types/ws": "^8.18.1", "@whatwg-node/server": "^0.10.12", + "@xterm/headless": "^5.5.0", "chalk": "^5.6.2", "commander": "^14.0.0", "dotenv": "^17.2.3", diff --git a/packages/blink/src/cli/lib/terminal.test.ts b/packages/blink/src/cli/lib/terminal.test.ts new file mode 100644 index 0000000..305447c --- /dev/null +++ b/packages/blink/src/cli/lib/terminal.test.ts @@ -0,0 +1,15 @@ +import { test, expect } from "bun:test"; +import { render } from "./terminal"; +import { BLINK_COMMAND } from "./terminal"; + +test("escape codes are rendered", async () => { + using term = render( + `sh -c "echo 'Hello from the terminal! Here is some \x1b[31mred text\x1b[0m'!"` + ); + await term.waitUntil((screen) => screen.includes("Here is some red text!")); +}); + +test("blink command is rendered", async () => { + using term = render(`${BLINK_COMMAND} --help`); + await term.waitUntil((screen) => screen.includes("Usage: blink")); +}); diff --git a/packages/blink/src/cli/lib/terminal.ts b/packages/blink/src/cli/lib/terminal.ts new file mode 100644 index 0000000..cb3e1c5 --- /dev/null +++ b/packages/blink/src/cli/lib/terminal.ts @@ -0,0 +1,220 @@ +import { + spawn, + spawnSync, + ChildProcessWithoutNullStreams, +} from "node:child_process"; +import { Terminal } from "@xterm/headless"; +import { join } from "path"; + +export interface RenderOptions { + cols?: number; + rows?: number; + cwd?: string; + env?: Record; + timeout?: number; +} + +export interface TerminalInstance extends Disposable { + getScreen(): string; + getLine(index: number): string; + getLines(): string[]; + waitUntil( + condition: (screen: string) => boolean, + timeoutMs?: number + ): Promise; + write(data: string): void; + + /** Underlying Node child process */ + readonly child: ChildProcessWithoutNullStreams; + + /** Underlying xterm Terminal instance */ + readonly terminal: Terminal; +} + +class TerminalInstanceImpl implements TerminalInstance { + public readonly child: ChildProcessWithoutNullStreams; + public readonly terminal: Terminal; + private disposed = false; + private processExited = false; + private defaultTimeoutMs; + + constructor(command: string, options: RenderOptions = {}) { + const { + cols = 80, + rows = 24, + cwd = process.cwd(), + env = process.env as Record, + timeout = 10000, + } = options; + + this.defaultTimeoutMs = timeout; + + // xterm.js headless terminal buffer (no DOM) + this.terminal = new Terminal({ + cols, + rows, + allowProposedApi: true, + }); + + if (process.platform === "win32") { + throw new Error("Windows is not supported"); + } + + // Run the command under a PTY via `script(1)`: + // script -qf -c "" /dev/null + // -q (quiet), -f (flush), -c (run command) — output goes to stdout. + // This is a workaround for Bun not supporting node-pty. + const argv = [ + "-qf", + "-c", + `stty cols ${cols} rows ${rows}; exec ${command}`, + "/dev/null", + ]; + const child = spawn("script", argv, { + cwd, + env, + stdio: ["pipe", "pipe", "pipe"], // Node creates pipes for us + }) as ChildProcessWithoutNullStreams; + + this.child = child; + + // Stream stdout → xterm + child.stdout.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + this.terminal.write(chunk); + }); + + // Mirror stderr to the terminal too + child.stderr.setEncoding("utf8"); + child.stderr.on("data", (chunk: string) => { + this.terminal.write(chunk); + }); + + child.on("exit", (code, signal) => { + this.processExited = true; + if (!this.disposed && code !== 0) { + console.warn(`Process exited with code ${code}, signal ${signal}`); + } + }); + + child.on("error", (err) => { + console.error("Failed to spawn child process:", err); + }); + } + + private findScript(): string | null { + const r = spawnSync("which", ["script"], { encoding: "utf8" }); + if (r.status === 0 && r.stdout.trim()) { + return r.stdout.trim(); + } + return null; + } + + getScreen(): string { + const buffer = this.terminal.buffer.active; + const lines: string[] = []; + for (let i = 0; i < buffer.length; i++) { + const line = buffer.getLine(i); + if (line) lines.push(line.translateToString(true)); + } + return lines.join("\n"); + } + + getLine(index: number): string { + const buffer = this.terminal.buffer.active; + const line = buffer.getLine(index); + return line ? line.translateToString(true) : ""; + } + + getLines(): string[] { + const buffer = this.terminal.buffer.active; + const out: string[] = []; + for (let i = 0; i < buffer.length; i++) { + const line = buffer.getLine(i); + if (line) out.push(line.translateToString(true)); + } + return out; + } + + async waitUntil( + condition: (screen: string) => boolean, + timeoutMs?: number + ): Promise { + const pollInterval = 50; + + return new Promise((resolve, reject) => { + let pollTimer: ReturnType | null = null; + let timeoutId: ReturnType | null = null; + + const cleanup = () => { + if (pollTimer) clearInterval(pollTimer); + if (timeoutId) clearTimeout(timeoutId); + }; + + const check = () => { + if (condition(this.getScreen())) { + cleanup(); + resolve(); + return true; + } + return false; + }; + + if (check()) return; + + timeoutId = setTimeout(() => { + cleanup(); + reject( + new Error( + `Timeout after ${timeoutMs}ms\n\nCurrent screen:\n${this.getScreen()}` + ) + ); + }, timeoutMs ?? this.defaultTimeoutMs); + + pollTimer = setInterval(check, pollInterval); + }); + } + + write(data: string): void { + // Send keystrokes to the child’s stdin + this.child.stdin.write(data); + } + + [Symbol.dispose](): void { + this.dispose(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + + try { + // Politely end stdin; then kill if needed + this.child.stdin.end(); + } catch { + /* ignore */ + } + + try { + this.child.kill(); + } catch (e) { + console.warn("Error killing child:", e); + } + + try { + this.terminal.dispose(); + } catch (e) { + console.warn("Error disposing terminal:", e); + } + } +} + +const pathToCliEntrypoint = join(import.meta.dirname, "..", "index.ts"); +export const BLINK_COMMAND = `bun ${pathToCliEntrypoint}`; + +export function render( + command: string, + options?: RenderOptions +): TerminalInstance { + return new TerminalInstanceImpl(command, options); +} From 489cbb36a977feba9f536a2bd0a53ce6b8bee211 Mon Sep 17 00:00:00 2001 From: Hugo Dutka Date: Mon, 20 Oct 2025 20:09:08 +0200 Subject: [PATCH 2/2] add blink init happy path test --- packages/blink/src/cli/init.test.ts | 43 ++++++++++++++++++++++++++ packages/blink/src/cli/lib/terminal.ts | 27 +++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/packages/blink/src/cli/init.test.ts b/packages/blink/src/cli/init.test.ts index 1b64124..bcc0479 100644 --- a/packages/blink/src/cli/init.test.ts +++ b/packages/blink/src/cli/init.test.ts @@ -1,5 +1,8 @@ import { describe, it, expect } from "bun:test"; import { getFilesForTemplate } from "./init"; +import { render, BLINK_COMMAND, makeTmpDir, KEY_CODES } from "./lib/terminal"; +import { join } from "path"; +import { readFile } from "fs/promises"; const getFile = (files: Record, filename: string): string => { const fileContent = files[filename]; @@ -212,3 +215,43 @@ describe("getFilesForTemplate", () => { }); }); }); + +describe("init command", () => { + it("scratch template, happy path", async () => { + await using tempDir = await makeTmpDir(); + using term = render(`${BLINK_COMMAND} init`, { cwd: tempDir.path }); + await term.waitUntil((screen) => screen.includes("Scratch")); + // by default, the first option should be selected. Scratch is second in the list. + expect(term.getScreen()).not.toContain("Basic agent with example tool"); + term.write(KEY_CODES.DOWN); + await term.waitUntil((screen) => + screen.includes("Basic agent with example tool") + ); + term.write(KEY_CODES.ENTER); + await term.waitUntil((screen) => + screen.includes("Which AI provider do you want to use?") + ); + term.write(KEY_CODES.ENTER); + await term.waitUntil((screen) => + screen.includes("Enter your OpenAI API key:") + ); + term.write("sk-test-123"); + term.write(KEY_CODES.ENTER); + await term.waitUntil((screen) => + screen.includes("What package manager do you want to use?") + ); + const screen = term.getScreen(); + expect(screen).toContain("Bun"); + expect(screen).toContain("NPM"); + expect(screen).toContain("PNPM"); + expect(screen).toContain("Yarn"); + term.write(KEY_CODES.ENTER); + await term.waitUntil((screen) => + screen.includes("API key saved to .env.local") + ); + await term.waitUntil((screen) => screen.includes("To get started, run:")); + const envFilePath = join(tempDir.path, ".env.local"); + const envFileContent = await readFile(envFilePath, "utf-8"); + expect(envFileContent.split("\n")).toContain("OPENAI_API_KEY=sk-test-123"); + }); +}); diff --git a/packages/blink/src/cli/lib/terminal.ts b/packages/blink/src/cli/lib/terminal.ts index cb3e1c5..126c841 100644 --- a/packages/blink/src/cli/lib/terminal.ts +++ b/packages/blink/src/cli/lib/terminal.ts @@ -1,10 +1,12 @@ import { spawn, spawnSync, - ChildProcessWithoutNullStreams, + type ChildProcessWithoutNullStreams, } from "node:child_process"; import { Terminal } from "@xterm/headless"; import { join } from "path"; +import { mkdtemp, rm } from "fs/promises"; +import { tmpdir } from "os"; export interface RenderOptions { cols?: number; @@ -218,3 +220,26 @@ export function render( ): TerminalInstance { return new TerminalInstanceImpl(command, options); } + +export async function makeTmpDir(): Promise< + AsyncDisposable & { path: string } +> { + const dirPath = await mkdtemp(join(tmpdir(), "blink-tmp-")); + return { + path: dirPath, + [Symbol.asyncDispose](): Promise { + return rm(dirPath, { recursive: true }); + }, + }; +} + +export const KEY_CODES = { + ENTER: "\r", + TAB: "\t", + BACKSPACE: "\x08", + DELETE: "\x7f", + UP: "\x1b[A", + DOWN: "\x1b[B", + LEFT: "\x1b[D", + RIGHT: "\x1b[C", +} as const;