History

Record the real-OpenCode repro as a terminal cast the viewer replays

The scenario now runs its whole story in ONE recorded PTY (terminal-control) instead of piped child processes: a bash session where opencode mcp auth, the fresh connected check, the on-camera sleep past token expiry, and the needs-authentication ending are typed and rendered like a user at a shell. Assertions read the live screen (waitForText + sentinel echoes, quoted so the typed command can't satisfy them). The CLI surface's record option now writes a standard asciicast v2: terminal-control's JSONL recording is converted (streaming UTF-8 decode across output events) in the release step, so even a timed-out run keeps its evidence. The viewer's media tab generalizes video|terminal — terminal.cast plays via asciinema-player (lazy-loaded, idle stretches compressed), giving PTY scenarios the same watch-the-run property browser scenarios get from session video.

Rhys Sullivan committed 5 months ago

Commit 41aec851 parent 1ae6a738 files · +187 −135Browse files

Unified · Split

8 files
  1. bun.lock +13 −0
  2. e2e/cloud/mcp-opencode-real.test.ts +65 −34
  3. e2e/package.json +1 −0
  4. e2e/src/clients/opencode.ts +19 −90
  5. e2e/src/surfaces/cli.ts +42 −2
  6. e2e/viewer/src/App.tsx +17 −9
  7. e2e/viewer/src/TerminalCast.tsx +23 −0
  8. e2e/viewer/src/styles.css +7 −0
bun.lock+13−0
@@ -328,6 +328,7 @@ · 327 unmodified lines
328 "@executor-js/sdk": "workspace:*",328 "@executor-js/sdk": "workspace:*",
329 "@kitlangton/terminal-control": "^0.3.0",329 "@kitlangton/terminal-control": "^0.3.0",
330 "@modelcontextprotocol/sdk": "^1.29.0",330 "@modelcontextprotocol/sdk": "^1.29.0",
331 "asciinema-player": "^3.15.1",
331 "effect": "catalog:",332 "effect": "catalog:",
332 "monaco-editor": "^0.55.1",333 "monaco-editor": "^0.55.1",
333 "playwright": "^1.60.0",334 "playwright": "^1.60.0",
@@ -2433,6 +2434,12 @@ · 2099 unmodified lines
2433 2434
2434 "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],2435 "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
2435 2436
2437 "@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="],
2438
2439 "@solid-primitives/transition-group": ["@solid-primitives/transition-group@1.1.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA=="],
2440
2441 "@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
2442
2436 "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="],2443 "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="],
2437 2444
2438 "@splinetool/runtime": ["@splinetool/runtime@0.9.526", "", { "dependencies": { "on-change": "^4.0.0", "semver-compare": "^1.0.0" } }, "sha512-qznHbXA5aKwDbCgESAothCNm1IeEZcmNWG145p5aXj4w5uoqR1TZ9qkTHTKLTsUbHeitCwdhzmRqan1kxboLgQ=="],2445 "@splinetool/runtime": ["@splinetool/runtime@0.9.526", "", { "dependencies": { "on-change": "^4.0.0", "semver-compare": "^1.0.0" } }, "sha512-qznHbXA5aKwDbCgESAothCNm1IeEZcmNWG145p5aXj4w5uoqR1TZ9qkTHTKLTsUbHeitCwdhzmRqan1kxboLgQ=="],
@@ -2793,6 +2800,8 @@ · 354 unmodified lines
2793 2800
2794 "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="],2801 "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="],
2795 2802
2803 "asciinema-player": ["asciinema-player@3.15.1", "", { "dependencies": { "@babel/runtime": "^7.21.0", "solid-js": "^1.3.0", "solid-transition-group": "^0.2.3" } }, "sha512-agVYeNlPxthLyAb92l9AS7ypW0uhesqOuQzyR58Q4Sj+MvesQztZBgx86lHqNJkB8rQ6EP0LeA9czGytQUBpYw=="],
2804
2796 "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],2805 "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
2797 2806
2798 "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],2807 "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
@@ -4657,6 +4666,10 @@ · 1858 unmodified lines
4657 4666
4658 "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],4667 "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
4659 4668
4669 "solid-js": ["solid-js@1.9.13", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-6hJeJMOcEX8ktqjpDoJZEmld3ijvcvWBDtiXBm7f4332SiFN66QeAQI1REQshvyUoISsSeJ4PHDauKYbwao9JQ=="],
4670
4671 "solid-transition-group": ["solid-transition-group@0.2.3", "", { "dependencies": { "@solid-primitives/refs": "^1.0.5", "@solid-primitives/transition-group": "^1.0.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-iB72c9N5Kz9ykRqIXl0lQohOau4t0dhel9kjwFvx81UZJbVwaChMuBuyhiZmK24b8aKEK0w3uFM96ZxzcyZGdg=="],
4672
4660 "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],4673 "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
4661 4674
4662 "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],4675 "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
e2e/cloud/mcp-opencode-real.test.ts+65−34
@@ -1,21 +1,24 @@
1// Cloud: the OpenCode daily re-auth, reproduced with the REAL opencode1// Cloud: the OpenCode daily re-auth, reproduced with the REAL opencode
2// binary. Nothing about the client is modeled — OpenCode runs its own2// binary in a REAL terminal. The whole session runs in one recorded PTY —
3// discovery against our published metadata, its own DCR, its own scope3// the run's terminal.cast replays exactly what a user at a shell would see:
4// selection, its own token storage. The only theater is the browser hop4// authenticate, connected, wait out the token, suddenly "needs
5// (an open(1) shim captures the URL and a fetch with login_hint plays the5// authentication" again.
6// signed-in human) and time (the emulator's seeded default TTL compresses
7// "a day" into seconds).
8//6//
9// Field report this encodes: OpenCode users must re-authenticate the7// Nothing about the client is modeled: OpenCode runs its own discovery
10// executor MCP every day, while Claude Code sessions persist. The scenario8// against our published metadata, its own DCR, its own scope selection, its
11// asserts the experience a user deserves — authenticate once, stay signed9// own token storage. The only theater is the browser hop (an open(1) shim
12// in across an access-token expiry. It stays red until the server gives10// captures the URL and a fetch with login_hint plays the signed-in human)
13// spec-faithful clients a way to refresh.11// and time (the emulator's seeded default TTL compresses "a day" into
12// seconds). The scenario asserts the experience a user deserves —
13// authenticate once, stay signed in across an access-token expiry. It stays
14// red until the server gives spec-faithful clients a way to refresh.
15import { join } from "node:path";
16
14import { expect } from "@effect/vitest";17import { expect } from "@effect/vitest";
15import { Effect } from "effect";18import { Effect } from "effect";
16 19
17import { scenario } from "../src/scenario";20import { scenario } from "../src/scenario";
18import { makeOpenCodeHome, opencode, opencodeAuth } from "../src/clients/opencode";21import { completeOAuthConsent, makeOpenCodeHome } from "../src/clients/opencode";
19import { WORKOS_EMULATOR_PORT } from "../targets/cloud";22import { WORKOS_EMULATOR_PORT } from "../targets/cloud";
20 23
21const SERVER_NAME = "executor";24const SERVER_NAME = "executor";
@@ -42,30 +45,58 @@ scenario( · 20 unmodified lines
42 const home = makeOpenCodeHome(SERVER_NAME, ctx.target.mcpUrl);45 const home = makeOpenCodeHome(SERVER_NAME, ctx.target.mcpUrl);
43 46
44 yield* seedAccessTokenTtl(TTL_SECONDS);47 yield* seedAccessTokenTtl(TTL_SECONDS);
45 yield* Effect.gen(function* () {48 yield* ctx.cli
46 // OpenCode completes MCP OAuth for real: discovery, DCR, PKCE,49 .session(
47 // its own scope request, its own token store.50 ["bash", "--norc"],
48 const auth = yield* opencodeAuth(home, SERVER_NAME, email);51 async (term) => {
49 expect(auth.exitCode, `opencode mcp auth completes\n${auth.output}`).toBe(0);52 // Sentinels are typed quoted ("DO""NE") so waitForText can't
53 // match the echoed command line, only the command's output.
54 const sh = async (line: string, sentinel: string, timeoutMs: number) => {
55 await term.keyboard.type(
56 `${line}; echo ${sentinel.slice(0, 2)}""${sentinel.slice(2)}`,
57 );
58 await term.keyboard.press("Enter");
59 await term.screen.waitForText(sentinel, { timeoutMs });
60 return term.screen.text();
61 };
62
63 // OpenCode completes MCP OAuth for real: discovery, DCR, PKCE,
64 // its own scope request, its own token store.
65 const consent = completeOAuthConsent(home, email, home.openedUrls().length);
66 const auth = await sh(`opencode mcp auth ${SERVER_NAME}`, "AUTH-DONE", 60_000);
67 await consent;
68 expect(auth, "opencode mcp auth completes").not.toContain("failed");
50 69
51 // While the token is fresh, OpenCode is a working MCP client.70 // While the token is fresh, OpenCode is a working MCP client.
52 const fresh = yield* opencode(home, ["mcp", "list"]);71 const fresh = await sh("clear; opencode mcp list", "FRESH-DONE", 60_000);
53 expect(fresh.output, "OpenCode connects on a fresh token").toContain("connected");72 expect(fresh, "OpenCode connects on a fresh token").toContain("connected");
54 73
55 // The access token genuinely expires (server-honored TTL, no fakes).74 // The access token genuinely expires on camera (server-honored
56 yield* Effect.sleep(`${TTL_SECONDS + 3} seconds`);75 // TTL, no fakes), then the same command runs again.
76 const expired = await sh(
77 `sleep ${TTL_SECONDS + 3}; clear; opencode mcp list`,
78 "EXPIRED-DONE",
79 (TTL_SECONDS + 3) * 1000 + 60_000,
80 );
57 81
58 // The experience a user deserves: still signed in. OpenCode requested82 // The experience a user deserves: still signed in. OpenCode
59 // exactly the scopes our metadata advertises; whether it ended up83 // requested exactly the scopes our metadata advertises; whether
60 // holding a refresh token decides this assertion — that is the bug.84 // it got a refresh token decides this assertion — that's the bug.
61 const tokens = home.storedTokens(SERVER_NAME);85 const tokens = home.storedTokens(SERVER_NAME);
62 const expired = yield* opencode(home, ["mcp", "list"]);86 expect(
63 expect(87 expired,
64 expired.output,88 `OpenCode stays signed in across token expiry (its store holds ${
65 `OpenCode stays signed in across token expiry (its store holds ${89 tokens?.refreshToken ? "a refresh token" : "NO refresh token"
66 tokens?.refreshToken ? "a refresh token" : "NO refresh token"90 })`,
67 })`,91 ).toContain("connected");
68 ).toContain("connected");92 },
69 }).pipe(Effect.ensuring(seedAccessTokenTtl(null)));93 {
94 cwd: home.projectDir,
95 env: { ...home.env, PS1: "$ " },
96 record: join(ctx.dir, "terminal.cast"),
97 viewport: { cols: 100, rows: 30 },
98 },
99 )
100 .pipe(Effect.ensuring(seedAccessTokenTtl(null)));
70 }),101 }),
71);102);
e2e/package.json+1−0
@@ -21,6 +21,7 @@ · 20 unmodified lines
21 "@executor-js/sdk": "workspace:*",21 "@executor-js/sdk": "workspace:*",
22 "@kitlangton/terminal-control": "^0.3.0",22 "@kitlangton/terminal-control": "^0.3.0",
23 "@modelcontextprotocol/sdk": "^1.29.0",23 "@modelcontextprotocol/sdk": "^1.29.0",
24 "asciinema-player": "^3.15.1",
24 "effect": "catalog:",25 "effect": "catalog:",
25 "monaco-editor": "^0.55.1",26 "monaco-editor": "^0.55.1",
26 "playwright": "^1.60.0",27 "playwright": "^1.60.0",
e2e/src/clients/opencode.ts+19−90
@@ -3,13 +3,10 @@ · 2 unmodified lines
3// /mcp, and an `open`(1) shim on PATH so the OAuth browser hop becomes a file3// /mcp, and an `open`(1) shim on PATH so the OAuth browser hop becomes a file
4// we can read instead of a window. What OpenCode does with discovery, scopes,4// we can read instead of a window. What OpenCode does with discovery, scopes,
5// tokens, and refresh is entirely its own code — that is the point.5// tokens, and refresh is entirely its own code — that is the point.
6import { spawn } from "node:child_process";
7import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";6import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
8import { tmpdir } from "node:os";7import { tmpdir } from "node:os";
9import { join } from "node:path";8import { join } from "node:path";
10 9
11import { Effect } from "effect";
12
13export interface OpenCodeHome {10export interface OpenCodeHome {
14 /** Working directory holding opencode.json (OpenCode reads config from cwd). */11 /** Working directory holding opencode.json (OpenCode reads config from cwd). */
15 readonly projectDir: string;12 readonly projectDir: string;
@@ -70,94 +67,26 @@ export const makeOpenCodeHome = (serverName: string, mcpUrl: string): OpenCodeHo · 54 unmodified lines
70 };67 };
71};68};
72 69
73const ESC = String.fromCharCode(27);
74const BEL = String.fromCharCode(7);
75const CSI = new RegExp(`${ESC}\\[[0-9;?]*[a-zA-Z]`, "g");
76const OSC = new RegExp(`${ESC}\\][^${BEL}]*${BEL}`, "g");
77const stripAnsi = (text: string): string =>
78 text.replace(CSI, "").replace(OSC, "").replace(/\r/g, "\n");
79
80interface RunResult {
81 readonly output: string;
82 readonly exitCode: number;
83 readonly failure?: string;
84}
85
86/** Spawn `opencode <args>`; never rejects — failures are carried in the result. */
87const run = (
88 home: OpenCodeHome,
89 args: ReadonlyArray<string>,
90 timeoutMs: number,
91): Promise<RunResult> =>
92 new Promise((resolve) => {
93 const child = spawn("opencode", args, {
94 cwd: home.projectDir,
95 env: home.env,
96 stdio: ["ignore", "pipe", "pipe"],
97 });
98 let output = "";
99 child.stdout.on("data", (chunk: Buffer) => (output += chunk.toString()));
100 child.stderr.on("data", (chunk: Buffer) => (output += chunk.toString()));
101 const timer = setTimeout(() => {
102 child.kill("SIGKILL");
103 resolve({ output: stripAnsi(output), exitCode: -1, failure: "timed out" });
104 }, timeoutMs);
105 child.on("close", (code) => {
106 clearTimeout(timer);
107 resolve({ output: stripAnsi(output), exitCode: code ?? -1 });
108 });
109 child.on("error", (cause) => {
110 clearTimeout(timer);
111 resolve({ output: stripAnsi(output), exitCode: -1, failure: String(cause) });
112 });
113 });
114
115const okOrThrow = (label: string, result: RunResult): RunResult => {
116 if (result.failure) throw new Error(`${label} ${result.failure}:\n${result.output}`);
117 return result;
118};
119
120/** Run `opencode <args>` in the hermetic home; resolves with cleaned output. */
121export const opencode = (
122 home: OpenCodeHome,
123 args: ReadonlyArray<string>,
124 options: { readonly timeoutMs?: number } = {},
125): Effect.Effect<{ readonly output: string; readonly exitCode: number }> =>
126 Effect.promise(async () =>
127 okOrThrow(`opencode ${args.join(" ")}`, await run(home, args, options.timeoutMs ?? 60_000)),
128 );
129
130/**70/**
131 * `opencode mcp auth <server>` end to end: OpenCode runs its own discovery →71 * Play the signed-in human for an OAuth flow OpenCode just started: wait for
132 * DCR → authorize (captured by the open-shim) and waits on its localhost72 * it to "open the browser" (the shim records the URL instead), then follow
133 * callback; we play the signed-in human by following the authorize URL with73 * the authorize URL with login_hint — the emulator's consent redirects the
134 * login_hint, which lands the code on OpenCode's callback.74 * code straight to OpenCode's localhost callback.
135 */75 */
136export const opencodeAuth = (76export const completeOAuthConsent = async (
137 home: OpenCodeHome,77 home: OpenCodeHome,
138 serverName: string,
139 email: string,78 email: string,
140): Effect.Effect<{ readonly output: string; readonly exitCode: number }> =>79 sinceIndex: number,
141 Effect.promise(async () => {80): Promise<void> => {
142 const seen = home.openedUrls().length;81 const deadline = Date.now() + 30_000;
143 const auth = run(home, ["mcp", "auth", serverName], 60_000);82 while (Date.now() < deadline) {
144 83 const url = home.openedUrls()[sinceIndex];
145 const consent = (async () => {84 if (url) {
146 const deadline = Date.now() + 30_000;85 const response = await fetch(`${url}&login_hint=${encodeURIComponent(email)}`);
147 while (Date.now() < deadline) {86 if (!response.ok) throw new Error(`consent redirect chain failed (${response.status})`);
148 const url = home.openedUrls()[seen];87 return;
149 if (url) {88 }
150 const response = await fetch(`${url}&login_hint=${encodeURIComponent(email)}`);89 await new Promise((tick) => setTimeout(tick, 250));
151 if (!response.ok) {90 }
152 throw new Error(`consent redirect chain failed (${response.status})`);91 throw new Error("opencode never opened an authorization URL");
153 }92};
154 return;
155 }
156 await new Promise((tick) => setTimeout(tick, 250));
157 }
158 throw new Error("opencode never opened an authorization URL");
159 })();
160
161 const [result] = await Promise.all([auth, consent]);
162 return okOrThrow("opencode mcp auth", result);
163 });
e2e/src/surfaces/cli.ts+42−2
@@ -1,6 +1,9 @@
1// CLI/TUI surface: a real PTY via terminal-control. The scenario drives the1// CLI/TUI surface: a real PTY via terminal-control. The scenario drives the
2// session (type/press/waitForText) and asserts on the rendered screen with2// session (type/press/waitForText) and asserts on the rendered screen with
3// vitest; pass `record` to capture an asciinema-style cast file if wanted.3// vitest; pass `record` to save an asciicast v2 the viewer can replay. The
4// recording is written in release so a timeout still leaves the evidence.
5import { writeFileSync } from "node:fs";
6
4import { Effect } from "effect";7import { Effect } from "effect";
5import { TerminalControl, type Session } from "@kitlangton/terminal-control";8import { TerminalControl, type Session } from "@kitlangton/terminal-control";
6 9
@@ -11,11 +14,43 @@ export interface CliSurface { · 4 unmodified lines
11 options?: {14 options?: {
12 readonly cwd?: string;15 readonly cwd?: string;
13 readonly env?: Record<string, string>;16 readonly env?: Record<string, string>;
17 /** Path to write an asciicast v2 (.cast) of the whole session. */
14 readonly record?: string;18 readonly record?: string;
19 readonly viewport?: { readonly cols: number; readonly rows: number };
15 },20 },
16 ) => Effect.Effect<T>;21 ) => Effect.Effect<T>;
17}22}
18 23
24/** terminal-control's JSONL recording → asciicast v2 (what asciinema plays). */
25const toAsciicast = (recording: Uint8Array): string => {
26 const events = new TextDecoder()
27 .decode(recording)
28 .split("\n")
29 .filter(Boolean)
30 .map(
31 (line) =>
32 JSON.parse(line) as {
33 type: string;
34 cols?: number;
35 rows?: number;
36 at_ms?: number;
37 bytes?: number[];
38 },
39 );
40 const header = events.find((event) => event.type === "header");
41 const lines = [
42 JSON.stringify({ version: 2, width: header?.cols ?? 80, height: header?.rows ?? 24 }),
43 ];
44 // One streaming decoder so multi-byte UTF-8 split across events survives.
45 const decoder = new TextDecoder();
46 for (const event of events) {
47 if (event.type !== "output") continue;
48 const text = decoder.decode(Uint8Array.from(event.bytes ?? []), { stream: true });
49 if (text) lines.push(JSON.stringify([(event.at_ms ?? 0) / 1000, "o", text]));
50 }
51 return `${lines.join("\n")}\n`;
52};
53
19// acquireUseRelease so a vitest timeout (fiber interruption) still tears the54// acquireUseRelease so a vitest timeout (fiber interruption) still tears the
20// PTY down instead of leaking the child process.55// PTY down instead of leaking the child process.
21export const makeCliSurface = (): CliSurface => ({56export const makeCliSurface = (): CliSurface => ({
@@ -27,13 +62,18 @@ export const makeCliSurface = (): CliSurface => ({ · 5 unmodified lines
27 command,62 command,
28 cwd: options?.cwd,63 cwd: options?.cwd,
29 env: options?.env,64 env: options?.env,
30 record: options?.record,65 record: options?.record ? true : undefined,
66 viewport: options?.viewport,
31 });67 });
32 return { tc, session };68 return { tc, session };
33 }),69 }),
34 ({ session }) => Effect.promise(() => drive(session)),70 ({ session }) => Effect.promise(() => drive(session)),
35 ({ tc, session }) =>71 ({ tc, session }) =>
36 Effect.promise(async () => {72 Effect.promise(async () => {
73 if (options?.record) {
74 const recording = await session.recording().catch(() => undefined);
75 if (recording) writeFileSync(options.record, toAsciicast(recording));
76 }
37 await session.stop().catch(() => {});77 await session.stop().catch(() => {});
38 await tc[Symbol.asyncDispose]();78 await tc[Symbol.asyncDispose]();
39 }),79 }),
e2e/viewer/src/App.tsx+17−9
@@ -1,6 +1,7 @@
1import React, { Suspense, useEffect, useState } from "react";1import React, { Suspense, useEffect, useState } from "react";
2 2
3const TestSource = React.lazy(() => import("./TestSource"));3const TestSource = React.lazy(() => import("./TestSource"));
4const TerminalCast = React.lazy(() => import("./TerminalCast"));
4 5
5// ---------------------------------------------------------------------------6// ---------------------------------------------------------------------------
6// The matrix (scenario × target health) plus a per-run artifact page. The7// The matrix (scenario × target health) plus a per-run artifact page. The
@@ -140,7 +141,7 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 133 unmodified lines
140 const base = `${target}/${slug}`;141 const base = `${target}/${slug}`;
141 const [result, setResult] = useState<RunResult | null>(null);142 const [result, setResult] = useState<RunResult | null>(null);
142 const [error, setError] = useState<string | null>(null);143 const [error, setError] = useState<string | null>(null);
143 const [tab, setTab] = useState<"video" | "source">("video");144 const [tab, setTab] = useState<"media" | "source">("media");
144 145
145 useEffect(() => {146 useEffect(() => {
146 fetch(`${base}/result.json`)147 fetch(`${base}/result.json`)
@@ -155,6 +156,8 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 8 unmodified lines
155 const has = (name: string) => result.artifacts.includes(name);156 const has = (name: string) => result.artifacts.includes(name);
156 const screenshots = result.artifacts.filter((a) => a.endsWith(".png")).sort();157 const screenshots = result.artifacts.filter((a) => a.endsWith(".png")).sort();
157 const video = has("session.mp4") ? "session.mp4" : has("session.webm") ? "session.webm" : null;158 const video = has("session.mp4") ? "session.mp4" : has("session.webm") ? "session.webm" : null;
159 const cast = has("terminal.cast") ? "terminal.cast" : null;
160 const media = video ?? cast;
158 const traceUrl = has("trace.zip")161 const traceUrl = has("trace.zip")
159 ? `https://trace.playwright.dev/?trace=${encodeURIComponent(162 ? `https://trace.playwright.dev/?trace=${encodeURIComponent(
160 new URL(`${base}/trace.zip`, window.location.href).toString(),163 new URL(`${base}/trace.zip`, window.location.href).toString(),
@@ -184,13 +187,13 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 23 unmodified lines
184 {new Date(result.endedAt).toLocaleString()}187 {new Date(result.endedAt).toLocaleString()}
185 </p>188 </p>
186 {result.error && <pre className="errbox">{result.error}</pre>}189 {result.error && <pre className="errbox">{result.error}</pre>}
187 {video && has("test.ts") && (190 {media && has("test.ts") && (
188 <div className="tabs">191 <div className="tabs">
189 <button192 <button
190 className={tab === "video" ? "tab active" : "tab"}193 className={tab === "media" ? "tab active" : "tab"}
191 onClick={() => setTab("video")}194 onClick={() => setTab("media")}
192 >195 >
193 ▶ video196 ▶ {video ? "video" : "terminal"}
194 </button>197 </button>
195 <button198 <button
196 className={tab === "source" ? "tab active" : "tab"}199 className={tab === "source" ? "tab active" : "tab"}
@@ -200,13 +203,18 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 3 unmodified lines
200 </button>203 </button>
201 </div>204 </div>
202 )}205 )}
203 {(!video || tab === "source") && has("test.ts") && (206 {(!media || tab === "source") && has("test.ts") && (
204 <Suspense fallback={<p className="dim">loading test source…</p>}>207 <Suspense fallback={<p className="dim">loading test source…</p>}>
205 {!video && <h2 className="section">The test</h2>}208 {!media && <h2 className="section">The test</h2>}
206 <TestSource url={`${base}/test.ts`} />209 <TestSource url={`${base}/test.ts`} />
207 </Suspense>210 </Suspense>
208 )}211 )}
209 {video && tab === "video" && (212 {cast && !video && tab === "media" && (
213 <Suspense fallback={<p className="dim">loading recording…</p>}>
214 <TerminalCast url={`${base}/${cast}`} />
215 </Suspense>
216 )}
217 {video && tab === "media" && (
210 <>218 <>
211 {/* muted is required for browsers to honor autoplay */}219 {/* muted is required for browsers to honor autoplay */}
212 <video220 <video
@@ -234,7 +242,7 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 21 unmodified lines
234 )}242 )}
235 </>243 </>
236 )}244 )}
237 {!video && !has("test.ts") && screenshots.length === 0 && (245 {!media && !has("test.ts") && screenshots.length === 0 && (
238 <p className="dim">246 <p className="dim">
239 No visual artifacts — this surface's source of truth is the test code and its assertions.247 No visual artifacts — this surface's source of truth is the test code and its assertions.
240 </p>248 </p>
e2e/viewer/src/TerminalCast.tsx+23−0
@@ -0,0 +1,23 @@
1// asciinema-player over a run's terminal.cast — the terminal twin of the
2// browser surface's session video. Idle stretches (token-expiry waits) are
3// compressed so playback stays watchable.
4import { useEffect, useRef } from "react";
5import * as AsciinemaPlayer from "asciinema-player";
6import "asciinema-player/dist/bundle/asciinema-player.css";
7
8const TerminalCast = ({ url }: { url: string }) => {
9 const mount = useRef<HTMLDivElement>(null);
10 useEffect(() => {
11 if (!mount.current) return;
12 const player = AsciinemaPlayer.create(url, mount.current, {
13 autoPlay: true,
14 idleTimeLimit: 2,
15 fit: "width",
16 terminalFontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
17 });
18 return () => player.dispose();
19 }, [url]);
20 return <div ref={mount} className="cast-player" />;
21};
22
23export default TerminalCast;
e2e/viewer/src/styles.css+7−0
@@ -179,3 +179,10 @@ a.watch.no { · 178 unmodified lines
179 color: #d7dce5;179 color: #d7dce5;
180 border-bottom-color: #388bfd;180 border-bottom-color: #388bfd;
181}181}
182
183.cast-player {
184 margin-top: 12px;
185 border-radius: 8px;
186 overflow: hidden;
187 border: 1px solid #2a2a2a;
188}