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 | "@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=="], |
| @@ -1,21 +1,24 @@ | |||
| 1 | // Cloud: the OpenCode daily re-auth, reproduced with the REAL opencode | 1 | // Cloud: the OpenCode daily re-auth, reproduced with the REAL opencode |
| 2 | // binary. Nothing about the client is modeled — OpenCode runs its own | 2 | // binary in a REAL terminal. The whole session runs in one recorded PTY — |
| 3 | // discovery against our published metadata, its own DCR, its own scope | 3 | // 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 hop | 4 | // authenticate, connected, wait out the token, suddenly "needs |
| 5 | // (an open(1) shim captures the URL and a fetch with login_hint plays the | 5 | // 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 the | 7 | // Nothing about the client is modeled: OpenCode runs its own discovery |
| 10 | // executor MCP every day, while Claude Code sessions persist. The scenario | 8 | // against our published metadata, its own DCR, its own scope selection, its |
| 11 | // asserts the experience a user deserves — authenticate once, stay signed | 9 | // 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 gives | 10 | // 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. | ||
| 15 | import { join } from "node:path"; | ||
| 16 | |||
| 14 | import { expect } from "@effect/vitest"; | 17 | import { expect } from "@effect/vitest"; |
| 15 | import { Effect } from "effect"; | 18 | import { Effect } from "effect"; |
| 16 | 19 | ||
| 17 | import { scenario } from "../src/scenario"; | 20 | import { scenario } from "../src/scenario"; |
| 18 | import { makeOpenCodeHome, opencode, opencodeAuth } from "../src/clients/opencode"; | 21 | import { completeOAuthConsent, makeOpenCodeHome } from "../src/clients/opencode"; |
| 19 | import { WORKOS_EMULATOR_PORT } from "../targets/cloud"; | 22 | import { WORKOS_EMULATOR_PORT } from "../targets/cloud"; |
| 20 | 23 | ||
| 21 | const SERVER_NAME = "executor"; | 24 | const 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 requested | 82 | // The experience a user deserves: still signed in. OpenCode |
| 59 | // exactly the scopes our metadata advertises; whether it ended up | 83 | // 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 | ); |
| @@ -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", |
| @@ -3,13 +3,10 @@ · 2 unmodified lines | |||
| 3 | // /mcp, and an `open`(1) shim on PATH so the OAuth browser hop becomes a file | 3 | // /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. |
| 6 | import { spawn } from "node:child_process"; | ||
| 7 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; | 6 | import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; |
| 8 | import { tmpdir } from "node:os"; | 7 | import { tmpdir } from "node:os"; |
| 9 | import { join } from "node:path"; | 8 | import { join } from "node:path"; |
| 10 | 9 | ||
| 11 | import { Effect } from "effect"; | ||
| 12 | |||
| 13 | export interface OpenCodeHome { | 10 | export 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 | ||
| 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 → | 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 localhost | 72 | * 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 with | 73 | * 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 | */ |
| 136 | export const opencodeAuth = ( | 76 | export 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 | }); | ||
| @@ -1,6 +1,9 @@ | |||
| 1 | // CLI/TUI surface: a real PTY via terminal-control. The scenario drives the | 1 | // CLI/TUI surface: a real PTY via terminal-control. The scenario drives the |
| 2 | // session (type/press/waitForText) and asserts on the rendered screen with | 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 | import { Effect } from "effect"; | 7 | import { Effect } from "effect"; |
| 5 | import { TerminalControl, type Session } from "@kitlangton/terminal-control"; | 8 | import { 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). */ | ||
| 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 | // acquireUseRelease so a vitest timeout (fiber interruption) still tears the | 54 | // 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. |
| 21 | export const makeCliSurface = (): CliSurface => ({ | 56 | export 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 | }), |
| @@ -1,6 +1,7 @@ | |||
| 1 | import React, { Suspense, useEffect, useState } from "react"; | 1 | import React, { Suspense, useEffect, useState } from "react"; |
| 2 | 2 | ||
| 3 | const TestSource = React.lazy(() => import("./TestSource")); | 3 | const TestSource = React.lazy(() => import("./TestSource")); |
| 4 | const TerminalCast = React.lazy(() => import("./TerminalCast")); | ||
| 4 | 5 | ||
| 5 | // --------------------------------------------------------------------------- | 6 | // --------------------------------------------------------------------------- |
| 6 | // The matrix (scenario × target health) plus a per-run artifact page. The | 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 | 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 | <button | 192 | <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 | ▶ video | 196 | ▶ {video ? "video" : "terminal"} |
| 194 | </button> | 197 | </button> |
| 195 | <button | 198 | <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 | <video | 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 | <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> |
| @@ -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 | 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 | } | ||