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
328328 "@executor-js/sdk": "workspace:*",
329329 "@kitlangton/terminal-control": "^0.3.0",
330330 "@modelcontextprotocol/sdk": "^1.29.0",
331+ "asciinema-player": "^3.15.1",
331332 "effect": "catalog:",
332333 "monaco-editor": "^0.55.1",
333334 "playwright": "^1.60.0",
@@ -2433,6 +2434,12 @@ · 2099 unmodified lines
24332434
24342435 "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
24352436
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+
24362443 "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="],
24372444
24382445 "@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
27932800
27942801 "array-union": ["array-union@2.1.0", "", {}, "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw=="],
27952802
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+
27962805 "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="],
27972806
27982807 "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
@@ -4657,6 +4666,10 @@ · 1858 unmodified lines
46574666
46584667 "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
46594668
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+
46604673 "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=="],
46614674
46624675 "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
e2e/cloud/mcp-opencode-real.test.ts+65−34
@@ -1,21 +1,24 @@
11 // Cloud: the OpenCode daily re-auth, reproduced with the REAL opencode
2−// binary. Nothing about the client is modeled — OpenCode runs its own
3−// discovery against our published metadata, its own DCR, its own scope
4−// selection, its own token storage. The only theater is the browser hop
5−// (an open(1) shim captures the URL and a fetch with login_hint plays the
6−// signed-in human) and time (the emulator's seeded default TTL compresses
7−// "a day" into seconds).
2+// binary in a REAL terminal. The whole session runs in one recorded PTY —
3+// the run's terminal.cast replays exactly what a user at a shell would see:
4+// authenticate, connected, wait out the token, suddenly "needs
5+// authentication" again.
86 //
9−// Field report this encodes: OpenCode users must re-authenticate the
10−// executor MCP every day, while Claude Code sessions persist. The scenario
11−// asserts the experience a user deserves — authenticate once, stay signed
12−// in across an access-token expiry. It stays red until the server gives
13−// spec-faithful clients a way to refresh.
7+// Nothing about the client is modeled: OpenCode runs its own discovery
8+// against our published metadata, its own DCR, its own scope selection, its
9+// own token storage. The only theater is the browser hop (an open(1) shim
10+// captures the URL and a fetch with login_hint plays the signed-in human)
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.
15+import { join } from "node:path";
16+
1417 import { expect } from "@effect/vitest";
1518 import { Effect } from "effect";
1619
1720 import { scenario } from "../src/scenario";
18−import { makeOpenCodeHome, opencode, opencodeAuth } from "../src/clients/opencode";
21+import { completeOAuthConsent, makeOpenCodeHome } from "../src/clients/opencode";
1922 import { WORKOS_EMULATOR_PORT } from "../targets/cloud";
2023
2124 const SERVER_NAME = "executor";
@@ -42,30 +45,58 @@ scenario( · 20 unmodified lines
4245 const home = makeOpenCodeHome(SERVER_NAME, ctx.target.mcpUrl);
4346
4447 yield* seedAccessTokenTtl(TTL_SECONDS);
45− yield* Effect.gen(function* () {
46− // OpenCode completes MCP OAuth for real: discovery, DCR, PKCE,
47− // its own scope request, its own token store.
48− const auth = yield* opencodeAuth(home, SERVER_NAME, email);
49− expect(auth.exitCode, `opencode mcp auth completes\n${auth.output}`).toBe(0);
48+ yield* ctx.cli
49+ .session(
50+ ["bash", "--norc"],
51+ async (term) => {
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");
5069
51− // While the token is fresh, OpenCode is a working MCP client.
52− const fresh = yield* opencode(home, ["mcp", "list"]);
53− expect(fresh.output, "OpenCode connects on a fresh token").toContain("connected");
70+ // While the token is fresh, OpenCode is a working MCP client.
71+ const fresh = await sh("clear; opencode mcp list", "FRESH-DONE", 60_000);
72+ expect(fresh, "OpenCode connects on a fresh token").toContain("connected");
5473
55− // The access token genuinely expires (server-honored TTL, no fakes).
56− yield* Effect.sleep(`${TTL_SECONDS + 3} seconds`);
74+ // The access token genuinely expires on camera (server-honored
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+ );
5781
58− // The experience a user deserves: still signed in. OpenCode requested
59− // exactly the scopes our metadata advertises; whether it ended up
60− // holding a refresh token decides this assertion — that is the bug.
61− const tokens = home.storedTokens(SERVER_NAME);
62− const expired = yield* opencode(home, ["mcp", "list"]);
63− expect(
64− expired.output,
65− `OpenCode stays signed in across token expiry (its store holds ${
66− tokens?.refreshToken ? "a refresh token" : "NO refresh token"
67− })`,
68− ).toContain("connected");
69− }).pipe(Effect.ensuring(seedAccessTokenTtl(null)));
82+ // The experience a user deserves: still signed in. OpenCode
83+ // requested exactly the scopes our metadata advertises; whether
84+ // it got a refresh token decides this assertion — that's the bug.
85+ const tokens = home.storedTokens(SERVER_NAME);
86+ expect(
87+ expired,
88+ `OpenCode stays signed in across token expiry (its store holds ${
89+ tokens?.refreshToken ? "a refresh token" : "NO refresh token"
90+ })`,
91+ ).toContain("connected");
92+ },
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)));
70101 }),
71102 );
e2e/package.json+1−0
@@ -21,6 +21,7 @@ · 20 unmodified lines
2121 "@executor-js/sdk": "workspace:*",
2222 "@kitlangton/terminal-control": "^0.3.0",
2323 "@modelcontextprotocol/sdk": "^1.29.0",
24+ "asciinema-player": "^3.15.1",
2425 "effect": "catalog:",
2526 "monaco-editor": "^0.55.1",
2627 "playwright": "^1.60.0",
e2e/src/clients/opencode.ts+19−90
@@ -3,13 +3,10 @@ · 2 unmodified lines
33 // /mcp, and an `open`(1) shim on PATH so the OAuth browser hop becomes a file
44 // we can read instead of a window. What OpenCode does with discovery, scopes,
55 // tokens, and refresh is entirely its own code — that is the point.
6−import { spawn } from "node:child_process";
76 import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
87 import { tmpdir } from "node:os";
98 import { join } from "node:path";
109
11−import { Effect } from "effect";
12−
1310 export interface OpenCodeHome {
1411 /** Working directory holding opencode.json (OpenCode reads config from cwd). */
1512 readonly projectDir: string;
@@ -70,94 +67,26 @@ export const makeOpenCodeHome = (serverName: string, mcpUrl: string): OpenCodeHo · 54 unmodified lines
7067 };
7168 };
7269
73−const ESC = String.fromCharCode(27);
74−const BEL = String.fromCharCode(7);
75−const CSI = new RegExp(`${ESC}\\[[0-9;?]*[a-zA-Z]`, "g");
76−const OSC = new RegExp(`${ESC}\\][^${BEL}]*${BEL}`, "g");
77−const stripAnsi = (text: string): string =>
78− text.replace(CSI, "").replace(OSC, "").replace(/\r/g, "\n");
79−
80−interface 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. */
87−const 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−
115−const 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. */
121−export 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−
13070 /**
131− * `opencode mcp auth <server>` end to end: OpenCode runs its own discovery →
132− * DCR → authorize (captured by the open-shim) and waits on its localhost
133− * callback; we play the signed-in human by following the authorize URL with
134− * login_hint, which lands the code on OpenCode's callback.
71+ * Play the signed-in human for an OAuth flow OpenCode just started: wait for
72+ * it to "open the browser" (the shim records the URL instead), then follow
73+ * the authorize URL with login_hint — the emulator's consent redirects the
74+ * code straight to OpenCode's localhost callback.
13575 */
136−export const opencodeAuth = (
76+export const completeOAuthConsent = async (
13777 home: OpenCodeHome,
138− serverName: string,
13978 email: string,
140−): Effect.Effect<{ readonly output: string; readonly exitCode: number }> =>
141− Effect.promise(async () => {
142− const seen = home.openedUrls().length;
143− const auth = run(home, ["mcp", "auth", serverName], 60_000);
144−
145− const consent = (async () => {
146− const deadline = Date.now() + 30_000;
147− while (Date.now() < deadline) {
148− const url = home.openedUrls()[seen];
149− if (url) {
150− const response = await fetch(`${url}&login_hint=${encodeURIComponent(email)}`);
151− if (!response.ok) {
152− throw new Error(`consent redirect chain failed (${response.status})`);
153− }
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− });
79+ sinceIndex: number,
80+): Promise<void> => {
81+ const deadline = Date.now() + 30_000;
82+ while (Date.now() < deadline) {
83+ const url = home.openedUrls()[sinceIndex];
84+ if (url) {
85+ const response = await fetch(`${url}&login_hint=${encodeURIComponent(email)}`);
86+ if (!response.ok) throw new Error(`consent redirect chain failed (${response.status})`);
87+ return;
88+ }
89+ await new Promise((tick) => setTimeout(tick, 250));
90+ }
91+ throw new Error("opencode never opened an authorization URL");
92+};
e2e/src/surfaces/cli.ts+42−2
@@ -1,6 +1,9 @@
11 // CLI/TUI surface: a real PTY via terminal-control. The scenario drives the
22 // 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.
5+import { writeFileSync } from "node:fs";
6+
47 import { Effect } from "effect";
58 import { TerminalControl, type Session } from "@kitlangton/terminal-control";
69
@@ -11,11 +14,43 @@ export interface CliSurface { · 4 unmodified lines
1114 options?: {
1215 readonly cwd?: string;
1316 readonly env?: Record<string, string>;
17+ /** Path to write an asciicast v2 (.cast) of the whole session. */
1418 readonly record?: string;
19+ readonly viewport?: { readonly cols: number; readonly rows: number };
1520 },
1621 ) => Effect.Effect<T>;
1722 }
1823
24+/** terminal-control's JSONL recording → asciicast v2 (what asciinema plays). */
25+const 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+
1954 // acquireUseRelease so a vitest timeout (fiber interruption) still tears the
2055 // PTY down instead of leaking the child process.
2156 export const makeCliSurface = (): CliSurface => ({
@@ -27,13 +62,18 @@ export const makeCliSurface = (): CliSurface => ({ · 5 unmodified lines
2762 command,
2863 cwd: options?.cwd,
2964 env: options?.env,
30− record: options?.record,
65+ record: options?.record ? true : undefined,
66+ viewport: options?.viewport,
3167 });
3268 return { tc, session };
3369 }),
3470 ({ session }) => Effect.promise(() => drive(session)),
3571 ({ tc, session }) =>
3672 Effect.promise(async () => {
73+ if (options?.record) {
74+ const recording = await session.recording().catch(() => undefined);
75+ if (recording) writeFileSync(options.record, toAsciicast(recording));
76+ }
3777 await session.stop().catch(() => {});
3878 await tc[Symbol.asyncDispose]();
3979 }),
e2e/viewer/src/App.tsx+17−9
@@ -1,6 +1,7 @@
11 import React, { Suspense, useEffect, useState } from "react";
22
33 const TestSource = React.lazy(() => import("./TestSource"));
4+const TerminalCast = React.lazy(() => import("./TerminalCast"));
45
56 // ---------------------------------------------------------------------------
67 // 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
140141 const base = `${target}/${slug}`;
141142 const [result, setResult] = useState<RunResult | null>(null);
142143 const [error, setError] = useState<string | null>(null);
143− const [tab, setTab] = useState<"video" | "source">("video");
144+ const [tab, setTab] = useState<"media" | "source">("media");
144145
145146 useEffect(() => {
146147 fetch(`${base}/result.json`)
@@ -155,6 +156,8 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 8 unmodified lines
155156 const has = (name: string) => result.artifacts.includes(name);
156157 const screenshots = result.artifacts.filter((a) => a.endsWith(".png")).sort();
157158 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;
158161 const traceUrl = has("trace.zip")
159162 ? `https://trace.playwright.dev/?trace=${encodeURIComponent(
160163 new URL(`${base}/trace.zip`, window.location.href).toString(),
@@ -184,13 +187,13 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 23 unmodified lines
184187 {new Date(result.endedAt).toLocaleString()}
185188 </p>
186189 {result.error && <pre className="errbox">{result.error}</pre>}
187− {video && has("test.ts") && (
190+ {media && has("test.ts") && (
188191 <div className="tabs">
189192 <button
190− className={tab === "video" ? "tab active" : "tab"}
191− onClick={() => setTab("video")}
193+ className={tab === "media" ? "tab active" : "tab"}
194+ onClick={() => setTab("media")}
192195 >
193− ▶ video
196+ ▶ {video ? "video" : "terminal"}
194197 </button>
195198 <button
196199 className={tab === "source" ? "tab active" : "tab"}
@@ -200,13 +203,18 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 3 unmodified lines
200203 </button>
201204 </div>
202205 )}
203− {(!video || tab === "source") && has("test.ts") && (
206+ {(!media || tab === "source") && has("test.ts") && (
204207 <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>}
206209 <TestSource url={`${base}/test.ts`} />
207210 </Suspense>
208211 )}
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" && (
210218 <>
211219 {/* muted is required for browsers to honor autoplay */}
212220 <video
@@ -234,7 +242,7 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 21 unmodified lines
234242 )}
235243 </>
236244 )}
237− {!video && !has("test.ts") && screenshots.length === 0 && (
245+ {!media && !has("test.ts") && screenshots.length === 0 && (
238246 <p className="dim">
239247 No visual artifacts — this surface's source of truth is the test code and its assertions.
240248 </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.
4+import { useEffect, useRef } from "react";
5+import * as AsciinemaPlayer from "asciinema-player";
6+import "asciinema-player/dist/bundle/asciinema-player.css";
7+
8+const 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+
23+export default TerminalCast;
e2e/viewer/src/styles.css+7−0
@@ -179,3 +179,10 @@ a.watch.no { · 178 unmodified lines
179179 color: #d7dce5;
180180 border-bottom-color: #388bfd;
181181 }
182+
183+.cast-player {
184+ margin-top: 12px;
185+ border-radius: 8px;
186+ overflow: hidden;
187+ border: 1px solid #2a2a2a;
188+}