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.
Unified · Split
| @@ -328,6 +328,7 @@ · 327 unmodified lines | ||
| 328 | 328 | "@executor-js/sdk": "workspace:*", |
| 329 | 329 | "@kitlangton/terminal-control": "^0.3.0", |
| 330 | 330 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 331 | + "asciinema-player": "^3.15.1", | |
| 331 | 332 | "effect": "catalog:", |
| 332 | 333 | "monaco-editor": "^0.55.1", |
| 333 | 334 | "playwright": "^1.60.0", |
| @@ -2433,6 +2434,12 @@ · 2099 unmodified lines | ||
| 2433 | 2434 | |
| 2434 | 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 | 2443 | "@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="], |
| 2437 | 2444 | |
| 2438 | 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 | 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 | 2805 | "assert-plus": ["assert-plus@1.0.0", "", {}, "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw=="], |
| 2797 | 2806 | |
| 2798 | 2807 | "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], |
| @@ -4657,6 +4666,10 @@ · 1858 unmodified lines | ||
| 4657 | 4666 | |
| 4658 | 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 | 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 | 4675 | "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], |
| @@ -1,21 +1,24 @@ | ||
| 1 | 1 | // 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. | |
| 8 | 6 | // |
| 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 | + | |
| 14 | 17 | import { expect } from "@effect/vitest"; |
| 15 | 18 | import { Effect } from "effect"; |
| 16 | 19 | |
| 17 | 20 | import { scenario } from "../src/scenario"; |
| 18 | −import { makeOpenCodeHome, opencode, opencodeAuth } from "../src/clients/opencode"; | |
| 21 | +import { completeOAuthConsent, makeOpenCodeHome } from "../src/clients/opencode"; | |
| 19 | 22 | import { WORKOS_EMULATOR_PORT } from "../targets/cloud"; |
| 20 | 23 | |
| 21 | 24 | const SERVER_NAME = "executor"; |
| @@ -42,30 +45,58 @@ scenario( · 20 unmodified lines | ||
| 42 | 45 | const home = makeOpenCodeHome(SERVER_NAME, ctx.target.mcpUrl); |
| 43 | 46 | |
| 44 | 47 | 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"); | |
| 50 | 69 | |
| 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"); | |
| 54 | 73 | |
| 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 | + ); | |
| 57 | 81 | |
| 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))); | |
| 70 | 101 | }), |
| 71 | 102 | ); |
| @@ -21,6 +21,7 @@ · 20 unmodified lines | ||
| 21 | 21 | "@executor-js/sdk": "workspace:*", |
| 22 | 22 | "@kitlangton/terminal-control": "^0.3.0", |
| 23 | 23 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 24 | + "asciinema-player": "^3.15.1", | |
| 24 | 25 | "effect": "catalog:", |
| 25 | 26 | "monaco-editor": "^0.55.1", |
| 26 | 27 | "playwright": "^1.60.0", |
| @@ -3,13 +3,10 @@ · 2 unmodified lines | ||
| 3 | 3 | // /mcp, and an `open`(1) shim on PATH so the OAuth browser hop becomes a file |
| 4 | 4 | // we can read instead of a window. What OpenCode does with discovery, scopes, |
| 5 | 5 | // tokens, and refresh is entirely its own code — that is the point. |
| 6 | −import { spawn } from "node:child_process"; | |
| 7 | 6 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; |
| 8 | 7 | import { tmpdir } from "node:os"; |
| 9 | 8 | import { join } from "node:path"; |
| 10 | 9 | |
| 11 | −import { Effect } from "effect"; | |
| 12 | − | |
| 13 | 10 | export interface OpenCodeHome { |
| 14 | 11 | /** Working directory holding opencode.json (OpenCode reads config from cwd). */ |
| 15 | 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 | |
| 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 | − | |
| 130 | 70 | /** |
| 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. | |
| 135 | 75 | */ |
| 136 | −export const opencodeAuth = ( | |
| 76 | +export const completeOAuthConsent = async ( | |
| 137 | 77 | home: OpenCodeHome, |
| 138 | − serverName: string, | |
| 139 | 78 | 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 | +}; | |
| @@ -1,6 +1,9 @@ | ||
| 1 | 1 | // CLI/TUI surface: a real PTY via terminal-control. The scenario drives the |
| 2 | 2 | // 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 | + | |
| 4 | 7 | import { Effect } from "effect"; |
| 5 | 8 | import { TerminalControl, type Session } from "@kitlangton/terminal-control"; |
| 6 | 9 | |
| @@ -11,11 +14,43 @@ export interface CliSurface { · 4 unmodified lines | ||
| 11 | 14 | options?: { |
| 12 | 15 | readonly cwd?: string; |
| 13 | 16 | readonly env?: Record<string, string>; |
| 17 | + /** Path to write an asciicast v2 (.cast) of the whole session. */ | |
| 14 | 18 | readonly record?: string; |
| 19 | + readonly viewport?: { readonly cols: number; readonly rows: number }; | |
| 15 | 20 | }, |
| 16 | 21 | ) => Effect.Effect<T>; |
| 17 | 22 | } |
| 18 | 23 | |
| 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 | + | |
| 19 | 54 | // acquireUseRelease so a vitest timeout (fiber interruption) still tears the |
| 20 | 55 | // PTY down instead of leaking the child process. |
| 21 | 56 | export const makeCliSurface = (): CliSurface => ({ |
| @@ -27,13 +62,18 @@ export const makeCliSurface = (): CliSurface => ({ · 5 unmodified lines | ||
| 27 | 62 | command, |
| 28 | 63 | cwd: options?.cwd, |
| 29 | 64 | env: options?.env, |
| 30 | − record: options?.record, | |
| 65 | + record: options?.record ? true : undefined, | |
| 66 | + viewport: options?.viewport, | |
| 31 | 67 | }); |
| 32 | 68 | return { tc, session }; |
| 33 | 69 | }), |
| 34 | 70 | ({ session }) => Effect.promise(() => drive(session)), |
| 35 | 71 | ({ tc, session }) => |
| 36 | 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 | 77 | await session.stop().catch(() => {}); |
| 38 | 78 | await tc[Symbol.asyncDispose](); |
| 39 | 79 | }), |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import React, { Suspense, useEffect, useState } from "react"; |
| 2 | 2 | |
| 3 | 3 | const TestSource = React.lazy(() => import("./TestSource")); |
| 4 | +const TerminalCast = React.lazy(() => import("./TerminalCast")); | |
| 4 | 5 | |
| 5 | 6 | // --------------------------------------------------------------------------- |
| 6 | 7 | // 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 | 141 | const base = `${target}/${slug}`; |
| 141 | 142 | const [result, setResult] = useState<RunResult | null>(null); |
| 142 | 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 | 146 | useEffect(() => { |
| 146 | 147 | fetch(`${base}/result.json`) |
| @@ -155,6 +156,8 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 8 unmodified lines | ||
| 155 | 156 | const has = (name: string) => result.artifacts.includes(name); |
| 156 | 157 | const screenshots = result.artifacts.filter((a) => a.endsWith(".png")).sort(); |
| 157 | 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 | 161 | const traceUrl = has("trace.zip") |
| 159 | 162 | ? `https://trace.playwright.dev/?trace=${encodeURIComponent( |
| 160 | 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 | 187 | {new Date(result.endedAt).toLocaleString()} |
| 185 | 188 | </p> |
| 186 | 189 | {result.error && <pre className="errbox">{result.error}</pre>} |
| 187 | − {video && has("test.ts") && ( | |
| 190 | + {media && has("test.ts") && ( | |
| 188 | 191 | <div className="tabs"> |
| 189 | 192 | <button |
| 190 | − className={tab === "video" ? "tab active" : "tab"} | |
| 191 | − onClick={() => setTab("video")} | |
| 193 | + className={tab === "media" ? "tab active" : "tab"} | |
| 194 | + onClick={() => setTab("media")} | |
| 192 | 195 | > |
| 193 | − ▶ video | |
| 196 | + ▶ {video ? "video" : "terminal"} | |
| 194 | 197 | </button> |
| 195 | 198 | <button |
| 196 | 199 | className={tab === "source" ? "tab active" : "tab"} |
| @@ -200,13 +203,18 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { · 3 unmodified lines | ||
| 200 | 203 | </button> |
| 201 | 204 | </div> |
| 202 | 205 | )} |
| 203 | − {(!video || tab === "source") && has("test.ts") && ( | |
| 206 | + {(!media || tab === "source") && has("test.ts") && ( | |
| 204 | 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 | 209 | <TestSource url={`${base}/test.ts`} /> |
| 207 | 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 | 219 | {/* muted is required for browsers to honor autoplay */} |
| 212 | 220 | <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 | 246 | <p className="dim"> |
| 239 | 247 | No visual artifacts — this surface's source of truth is the test code and its assertions. |
| 240 | 248 | </p> |
| @@ -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; | |
| @@ -179,3 +179,10 @@ a.watch.no { · 178 unmodified lines | ||
| 179 | 179 | color: #d7dce5; |
| 180 | 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 | +} | |