Skip to content

Commit 5b6a7af

Browse files
authored
chore: terminal test helper (#37)
1 parent 5f00612 commit 5b6a7af

File tree

5 files changed

+305
-0
lines changed

5 files changed

+305
-0
lines changed

bun.lock

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
"@types/react": "^19.2.2",
5252
"@types/ws": "^8.18.1",
5353
"@whatwg-node/server": "^0.10.12",
54+
"@xterm/headless": "^5.5.0",
5455
"chalk": "^5.6.2",
5556
"commander": "^14.0.0",
5657
"dotenv": "^17.2.3",

packages/blink/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@
104104
"@types/react": "^19.2.2",
105105
"@types/ws": "^8.18.1",
106106
"@whatwg-node/server": "^0.10.12",
107+
"@xterm/headless": "^5.5.0",
107108
"chalk": "^5.6.2",
108109
"commander": "^14.0.0",
109110
"dotenv": "^17.2.3",

packages/blink/src/cli/init.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { describe, it, expect } from "bun:test";
22
import { getFilesForTemplate } from "./init";
3+
import { render, BLINK_COMMAND, makeTmpDir, KEY_CODES } from "./lib/terminal";
4+
import { join } from "path";
5+
import { readFile } from "fs/promises";
36

47
const getFile = (files: Record<string, string>, filename: string): string => {
58
const fileContent = files[filename];
@@ -212,3 +215,43 @@ describe("getFilesForTemplate", () => {
212215
});
213216
});
214217
});
218+
219+
describe("init command", () => {
220+
it("scratch template, happy path", async () => {
221+
await using tempDir = await makeTmpDir();
222+
using term = render(`${BLINK_COMMAND} init`, { cwd: tempDir.path });
223+
await term.waitUntil((screen) => screen.includes("Scratch"));
224+
// by default, the first option should be selected. Scratch is second in the list.
225+
expect(term.getScreen()).not.toContain("Basic agent with example tool");
226+
term.write(KEY_CODES.DOWN);
227+
await term.waitUntil((screen) =>
228+
screen.includes("Basic agent with example tool")
229+
);
230+
term.write(KEY_CODES.ENTER);
231+
await term.waitUntil((screen) =>
232+
screen.includes("Which AI provider do you want to use?")
233+
);
234+
term.write(KEY_CODES.ENTER);
235+
await term.waitUntil((screen) =>
236+
screen.includes("Enter your OpenAI API key:")
237+
);
238+
term.write("sk-test-123");
239+
term.write(KEY_CODES.ENTER);
240+
await term.waitUntil((screen) =>
241+
screen.includes("What package manager do you want to use?")
242+
);
243+
const screen = term.getScreen();
244+
expect(screen).toContain("Bun");
245+
expect(screen).toContain("NPM");
246+
expect(screen).toContain("PNPM");
247+
expect(screen).toContain("Yarn");
248+
term.write(KEY_CODES.ENTER);
249+
await term.waitUntil((screen) =>
250+
screen.includes("API key saved to .env.local")
251+
);
252+
await term.waitUntil((screen) => screen.includes("To get started, run:"));
253+
const envFilePath = join(tempDir.path, ".env.local");
254+
const envFileContent = await readFile(envFilePath, "utf-8");
255+
expect(envFileContent.split("\n")).toContain("OPENAI_API_KEY=sk-test-123");
256+
});
257+
});
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { test, expect } from "bun:test";
2+
import { render } from "./terminal";
3+
import { BLINK_COMMAND } from "./terminal";
4+
5+
test("escape codes are rendered", async () => {
6+
using term = render(
7+
`sh -c "echo 'Hello from the terminal! Here is some \x1b[31mred text\x1b[0m'!"`
8+
);
9+
await term.waitUntil((screen) => screen.includes("Here is some red text!"));
10+
});
11+
12+
test("blink command is rendered", async () => {
13+
using term = render(`${BLINK_COMMAND} --help`);
14+
await term.waitUntil((screen) => screen.includes("Usage: blink"));
15+
});
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import {
2+
spawn,
3+
spawnSync,
4+
type ChildProcessWithoutNullStreams,
5+
} from "node:child_process";
6+
import { Terminal } from "@xterm/headless";
7+
import { join } from "path";
8+
import { mkdtemp, rm } from "fs/promises";
9+
import { tmpdir } from "os";
10+
11+
export interface RenderOptions {
12+
cols?: number;
13+
rows?: number;
14+
cwd?: string;
15+
env?: Record<string, string>;
16+
timeout?: number;
17+
}
18+
19+
export interface TerminalInstance extends Disposable {
20+
getScreen(): string;
21+
getLine(index: number): string;
22+
getLines(): string[];
23+
waitUntil(
24+
condition: (screen: string) => boolean,
25+
timeoutMs?: number
26+
): Promise<void>;
27+
write(data: string): void;
28+
29+
/** Underlying Node child process */
30+
readonly child: ChildProcessWithoutNullStreams;
31+
32+
/** Underlying xterm Terminal instance */
33+
readonly terminal: Terminal;
34+
}
35+
36+
class TerminalInstanceImpl implements TerminalInstance {
37+
public readonly child: ChildProcessWithoutNullStreams;
38+
public readonly terminal: Terminal;
39+
private disposed = false;
40+
private processExited = false;
41+
private defaultTimeoutMs;
42+
43+
constructor(command: string, options: RenderOptions = {}) {
44+
const {
45+
cols = 80,
46+
rows = 24,
47+
cwd = process.cwd(),
48+
env = process.env as Record<string, string>,
49+
timeout = 10000,
50+
} = options;
51+
52+
this.defaultTimeoutMs = timeout;
53+
54+
// xterm.js headless terminal buffer (no DOM)
55+
this.terminal = new Terminal({
56+
cols,
57+
rows,
58+
allowProposedApi: true,
59+
});
60+
61+
if (process.platform === "win32") {
62+
throw new Error("Windows is not supported");
63+
}
64+
65+
// Run the command under a PTY via `script(1)`:
66+
// script -qf -c "<cmd args...>" /dev/null
67+
// -q (quiet), -f (flush), -c (run command) — output goes to stdout.
68+
// This is a workaround for Bun not supporting node-pty.
69+
const argv = [
70+
"-qf",
71+
"-c",
72+
`stty cols ${cols} rows ${rows}; exec ${command}`,
73+
"/dev/null",
74+
];
75+
const child = spawn("script", argv, {
76+
cwd,
77+
env,
78+
stdio: ["pipe", "pipe", "pipe"], // Node creates pipes for us
79+
}) as ChildProcessWithoutNullStreams;
80+
81+
this.child = child;
82+
83+
// Stream stdout → xterm
84+
child.stdout.setEncoding("utf8");
85+
child.stdout.on("data", (chunk: string) => {
86+
this.terminal.write(chunk);
87+
});
88+
89+
// Mirror stderr to the terminal too
90+
child.stderr.setEncoding("utf8");
91+
child.stderr.on("data", (chunk: string) => {
92+
this.terminal.write(chunk);
93+
});
94+
95+
child.on("exit", (code, signal) => {
96+
this.processExited = true;
97+
if (!this.disposed && code !== 0) {
98+
console.warn(`Process exited with code ${code}, signal ${signal}`);
99+
}
100+
});
101+
102+
child.on("error", (err) => {
103+
console.error("Failed to spawn child process:", err);
104+
});
105+
}
106+
107+
private findScript(): string | null {
108+
const r = spawnSync("which", ["script"], { encoding: "utf8" });
109+
if (r.status === 0 && r.stdout.trim()) {
110+
return r.stdout.trim();
111+
}
112+
return null;
113+
}
114+
115+
getScreen(): string {
116+
const buffer = this.terminal.buffer.active;
117+
const lines: string[] = [];
118+
for (let i = 0; i < buffer.length; i++) {
119+
const line = buffer.getLine(i);
120+
if (line) lines.push(line.translateToString(true));
121+
}
122+
return lines.join("\n");
123+
}
124+
125+
getLine(index: number): string {
126+
const buffer = this.terminal.buffer.active;
127+
const line = buffer.getLine(index);
128+
return line ? line.translateToString(true) : "";
129+
}
130+
131+
getLines(): string[] {
132+
const buffer = this.terminal.buffer.active;
133+
const out: string[] = [];
134+
for (let i = 0; i < buffer.length; i++) {
135+
const line = buffer.getLine(i);
136+
if (line) out.push(line.translateToString(true));
137+
}
138+
return out;
139+
}
140+
141+
async waitUntil(
142+
condition: (screen: string) => boolean,
143+
timeoutMs?: number
144+
): Promise<void> {
145+
const pollInterval = 50;
146+
147+
return new Promise((resolve, reject) => {
148+
let pollTimer: ReturnType<typeof setInterval> | null = null;
149+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
150+
151+
const cleanup = () => {
152+
if (pollTimer) clearInterval(pollTimer);
153+
if (timeoutId) clearTimeout(timeoutId);
154+
};
155+
156+
const check = () => {
157+
if (condition(this.getScreen())) {
158+
cleanup();
159+
resolve();
160+
return true;
161+
}
162+
return false;
163+
};
164+
165+
if (check()) return;
166+
167+
timeoutId = setTimeout(() => {
168+
cleanup();
169+
reject(
170+
new Error(
171+
`Timeout after ${timeoutMs}ms\n\nCurrent screen:\n${this.getScreen()}`
172+
)
173+
);
174+
}, timeoutMs ?? this.defaultTimeoutMs);
175+
176+
pollTimer = setInterval(check, pollInterval);
177+
});
178+
}
179+
180+
write(data: string): void {
181+
// Send keystrokes to the child’s stdin
182+
this.child.stdin.write(data);
183+
}
184+
185+
[Symbol.dispose](): void {
186+
this.dispose();
187+
}
188+
189+
dispose(): void {
190+
if (this.disposed) return;
191+
this.disposed = true;
192+
193+
try {
194+
// Politely end stdin; then kill if needed
195+
this.child.stdin.end();
196+
} catch {
197+
/* ignore */
198+
}
199+
200+
try {
201+
this.child.kill();
202+
} catch (e) {
203+
console.warn("Error killing child:", e);
204+
}
205+
206+
try {
207+
this.terminal.dispose();
208+
} catch (e) {
209+
console.warn("Error disposing terminal:", e);
210+
}
211+
}
212+
}
213+
214+
const pathToCliEntrypoint = join(import.meta.dirname, "..", "index.ts");
215+
export const BLINK_COMMAND = `bun ${pathToCliEntrypoint}`;
216+
217+
export function render(
218+
command: string,
219+
options?: RenderOptions
220+
): TerminalInstance {
221+
return new TerminalInstanceImpl(command, options);
222+
}
223+
224+
export async function makeTmpDir(): Promise<
225+
AsyncDisposable & { path: string }
226+
> {
227+
const dirPath = await mkdtemp(join(tmpdir(), "blink-tmp-"));
228+
return {
229+
path: dirPath,
230+
[Symbol.asyncDispose](): Promise<void> {
231+
return rm(dirPath, { recursive: true });
232+
},
233+
};
234+
}
235+
236+
export const KEY_CODES = {
237+
ENTER: "\r",
238+
TAB: "\t",
239+
BACKSPACE: "\x08",
240+
DELETE: "\x7f",
241+
UP: "\x1b[A",
242+
DOWN: "\x1b[B",
243+
LEFT: "\x1b[D",
244+
RIGHT: "\x1b[C",
245+
} as const;

0 commit comments

Comments
 (0)