History

Reproduce the OpenCode daily re-auth with the real client, on camera (#945)

* Reproduce the OpenCode daily re-auth: spec-faithful clients get no refresh token Field report: OpenCode users must re-authenticate the executor MCP every day, while Claude Code sessions persist. e2e/cloud/mcp-oauth-lifecycle.test.ts reproduces it as two scenarios that differ in exactly one step — how the client picks scopes: - spec-faithful (OpenCode, Poke, mcporter): requests exactly what our protected-resource metadata advertises in scopes_supported - hardcoding (Claude Code, Codex): ignores metadata, always requests openid profile email offline_access Each walks the full real lifecycle: discovery, DCR, PKCE authorize, token grant, working tool call, REAL access-token expiry (compressed to seconds via a per-client TTL the authorization server honors), 401, then refresh recovery. The hardcoding client survives; the spec-faithful client is left holding no refresh token and fails — because our metadata advertises scopes_supported: [], so it requested nothing and AuthKit only issues refresh tokens when offline_access is granted. The failing scenario is intentional: it is the executable form of the bug report, kept red until the metadata fix lands and flips it into a regression lock. Framework pieces (e2e MCP surface): advertisedScopes(), mintTokens() with scope + TTL control and rotating refresh(), and named client scope policies. Emulator (vendor/emulate pin): AuthKit-faithful scope grants, offline_access-gated refresh tokens, single-use rotation, and a DCR access_token_ttl_seconds extension. * Reproduce the OpenCode daily re-auth with the real opencode binary The wire-tier repro models OpenCode's scope policy; this scenario removes the model. e2e/cloud/mcp-opencode-real.test.ts drives the actual installed opencode in a hermetic home (own XDG dirs, opencode.json pointing at the target's /mcp, an open(1) shim that captures the OAuth URL instead of opening a browser): opencode mcp auth runs OpenCode's own discovery, DCR, scope selection, and token storage against our published metadata, with a fetch playing the signed-in human via login_hint. While the token is fresh, opencode mcp list reports connected; the emulator-honored 15-second TTL then really expires it, and the scenario asserts the experience a user deserves — still signed in. Red today: OpenCode's own store holds NO refresh token (it requested the empty scope list we advertise; observed on the wire: its authorize request carries no scope parameter at all), so it reports needs-authentication — the field bug, compressed from a day into seconds. Verified the scopes_supported one-liner flips it green end to end, including OpenCode's silent refresh; the fix stays out until the repro is reviewed. Pieces: an opencode capability (present when the binary is installed — skips elsewhere), e2e/src/clients/opencode.ts (hermetic real-client driver), and the emulator pin with a seedable default access-token TTL for plain-DCR clients. * 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. * Clean up the OpenCode repro recording The cast now reads like a person at a shell: wait for the prompt to paint before typing (early keystrokes echoed above the prompt), silence macOS's zsh-deprecation banner, run OpenCode's one-time database migration off camera before the session, and replace the quoted echo sentinels with an invisible completion signal — a command is done when the bare prompt is back as the bottom line of the screen. * Natural scrollback in the OpenCode repro recording Drop the clear-between-commands: assertions now scope themselves to the text a command produced (slice after its echoed line, prompt-after-it as the completion signal), so earlier output can't satisfy later checks and the cast reads like one uninterrupted shell session. Taller viewport keeps every line on screen since the slice anchor must not scroll away. The warm-up runs opencode in a bare no-MCP project: an mcp list that probes the real server before mcp auth makes the auth flow die with Unexpected status: needs_auth. * Make the MCP OAuth lifecycle scenarios shared, not cloud-specific An MCP client surviving token expiry is a product guarantee for every target with mcp-oauth, so both lifecycle scenarios (wire-tier and the real OpenCode binary) move from e2e/cloud to e2e/scenarios. The one genuinely target-specific piece — how a test compresses the access-token lifetime — becomes a Target seam: a ttl-control capability plus setAccessTokenTtl(). Cloud implements it against its emulated authorization server; selfhost (Better Auth as the AS, no test-adjustable TTL yet) skips with the missing capability named in the matrix, which is the honest state until someone wires a knob there. The opencode binary capability is detected on both targets. * Drop the modeled scope-policy MCP client; the real client is the repro

Rhys Sullivan committed 5 months ago

Commit 4a4277c1 parent 397610013 files · +450 −88Browse files

Unified · Split

13 files
  1. bun.lock +13 −0
  2. e2e/package.json +1 −0
  3. e2e/scenarios/mcp-opencode-real.test.ts +111 −0
  4. e2e/src/clients/opencode.ts +110 −0
  5. e2e/src/surfaces/cli.ts +42 −2
  6. e2e/src/surfaces/mcp.ts +83 −70
  7. e2e/src/target.ts +9 −1
  8. e2e/targets/cloud.ts +22 −2
  9. e2e/targets/selfhost.ts +11 −3
  10. e2e/viewer/src/App.tsx +17 −9
  11. e2e/viewer/src/TerminalCast.tsx +23 −0
  12. e2e/viewer/src/styles.css +7 −0
  13. vendor/emulate +1 −1
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/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/scenarios/mcp-opencode-real.test.ts+111−0
@@ -0,0 +1,111 @@
1// The OpenCode daily re-auth, reproduced with the REAL opencode
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.
6//
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 target's ttl-control compresses "a day" into seconds). The
12// 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
17import { expect } from "@effect/vitest";
18import { Effect } from "effect";
19
20import { scenario } from "../src/scenario";
21import { completeOAuthConsent, makeOpenCodeHome, warmUp } from "../src/clients/opencode";
22
23const SERVER_NAME = "executor";
24const TTL_SECONDS = 15;
25
26scenario(
27 "MCP OAuth lifecycle · the real OpenCode binary stays signed in across token expiry",
28 { needs: ["mcp-oauth", "opencode", "ttl-control"], timeout: 180_000 },
29 (ctx) =>
30 Effect.gen(function* () {
31 const setTtl = ctx.target.setAccessTokenTtl;
32 if (!setTtl)
33 return yield* Effect.die(new Error("ttl-control target lacks setAccessTokenTtl"));
34 const identity = yield* ctx.target.newIdentity();
35 const email = identity.credentials?.email ?? identity.label;
36 const home = makeOpenCodeHome(SERVER_NAME, ctx.target.mcpUrl);
37 // First-run database migration happens off camera.
38 yield* Effect.sync(() => warmUp(home));
39
40 yield* setTtl(TTL_SECONDS);
41 yield* ctx.cli
42 .session(
43 ["bash", "--norc"],
44 async (term) => {
45 // Don't type into a shell that hasn't painted its prompt yet —
46 // early keystrokes echo above the prompt in the recording.
47 await term.screen.waitForText("$", { timeoutMs: 10_000 });
48
49 // A command is done when its echoed line is on screen AND the
50 // bare prompt is back after it — no sentinel noise, no clears,
51 // and the pre-command prompt can't satisfy the wait. Returns
52 // only what THIS command produced, so earlier output can't
53 // satisfy a later assertion and the scrollback stays natural.
54 const outputAfter = (text: string, line: string): string | null => {
55 const echoed = text.lastIndexOf(line);
56 if (echoed === -1) return null;
57 const after = text.slice(echoed + line.length);
58 return after.trimEnd().endsWith("\n$") ? after : null;
59 };
60 const sh = async (line: string, timeoutMs: number) => {
61 await term.keyboard.type(line);
62 await term.keyboard.press("Enter");
63 const snapshot = await term.screen.waitUntil(
64 (current) => outputAfter(current.text, line) !== null,
65 { timeoutMs },
66 );
67 return outputAfter(snapshot.text, line) ?? "";
68 };
69
70 // OpenCode completes MCP OAuth for real: discovery, DCR, PKCE,
71 // its own scope request, its own token store.
72 const consent = completeOAuthConsent(home, email, home.openedUrls().length);
73 const auth = await sh(`opencode mcp auth ${SERVER_NAME}`, 60_000);
74 await consent;
75 expect(auth, "opencode mcp auth completes").not.toContain("failed");
76
77 // While the token is fresh, OpenCode is a working MCP client.
78 const fresh = await sh("opencode mcp list", 60_000);
79 expect(fresh, "OpenCode connects on a fresh token").toContain("connected");
80
81 // The access token genuinely expires on camera (server-honored
82 // TTL, no fakes), then the same command runs again.
83 const expired = await sh(
84 `sleep ${TTL_SECONDS + 3}; opencode mcp list`,
85 (TTL_SECONDS + 3) * 1000 + 60_000,
86 );
87
88 // The experience a user deserves: still signed in. OpenCode
89 // requested exactly the scopes our metadata advertises; whether
90 // it got a refresh token decides this assertion — that's the bug.
91 const tokens = home.storedTokens(SERVER_NAME);
92 expect(
93 expired,
94 `OpenCode stays signed in across token expiry (its store holds ${
95 tokens?.refreshToken ? "a refresh token" : "NO refresh token"
96 })`,
97 ).toContain("connected");
98 },
99 {
100 cwd: home.projectDir,
101 env: { ...home.env, PS1: "$ ", BASH_SILENCE_DEPRECATION_WARNING: "1" },
102 record: join(ctx.dir, "terminal.cast"),
103 // Tall enough that the whole session stays on screen — the
104 // per-command slice in sh() depends on the echoed line not
105 // scrolling away.
106 viewport: { cols: 100, rows: 40 },
107 },
108 )
109 .pipe(Effect.ensuring(setTtl(null)));
110 }),
111);
e2e/src/clients/opencode.ts+110−0
@@ -0,0 +1,110 @@
1// Drive the REAL installed OpenCode binary as an MCP client, hermetically:
2// its own XDG dirs, a project dir whose opencode.json points at the target's
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,
5// tokens, and refresh is entirely its own code — that is the point.
6import { spawnSync } from "node:child_process";
7import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
8import { tmpdir } from "node:os";
9import { join } from "node:path";
10
11/** Whether the real OpenCode binary is installed — the "opencode" capability. */
12export const hasOpenCode = (): boolean => spawnSync("opencode", ["--version"]).status === 0;
13
14export interface OpenCodeHome {
15 /** Working directory holding opencode.json (OpenCode reads config from cwd). */
16 readonly projectDir: string;
17 /** Environment that isolates this OpenCode from the machine's real one. */
18 readonly env: Record<string, string>;
19 /** Every URL OpenCode tried to open in a browser, in order. */
20 readonly openedUrls: () => ReadonlyArray<string>;
21 /** OpenCode's own MCP token store (undefined until it persists a grant). */
22 readonly storedTokens: (
23 serverName: string,
24 ) => { accessToken?: string; refreshToken?: string; expiresAt?: number } | undefined;
25}
26
27/** A throwaway OpenCode installation configured with one remote MCP server. */
28export const makeOpenCodeHome = (serverName: string, mcpUrl: string): OpenCodeHome => {
29 const root = mkdtempSync(join(tmpdir(), "e2e-opencode-"));
30 const projectDir = join(root, "project");
31 const dataDir = join(root, "data");
32 const binDir = join(root, "bin");
33 const openedUrlsFile = join(root, "opened-urls.txt");
34 for (const dir of [projectDir, dataDir, binDir]) mkdirSync(dir, { recursive: true });
35
36 writeFileSync(
37 join(projectDir, "opencode.json"),
38 JSON.stringify({
39 $schema: "https://opencode.ai/config.json",
40 mcp: { [serverName]: { type: "remote", url: mcpUrl } },
41 }),
42 );
43 // OpenCode launches the OAuth URL via `open`; the shim records it instead.
44 writeFileSync(join(binDir, "open"), `#!/bin/sh\necho "$@" >> ${openedUrlsFile}\nexit 0\n`, {
45 mode: 0o755,
46 });
47
48 return {
49 projectDir,
50 env: {
51 ...process.env,
52 PATH: `${binDir}:${process.env.PATH ?? ""}`,
53 XDG_DATA_HOME: dataDir,
54 XDG_CONFIG_HOME: join(root, "config"),
55 XDG_STATE_HOME: join(root, "state"),
56 XDG_CACHE_HOME: join(root, "cache"),
57 },
58 openedUrls: () =>
59 existsSync(openedUrlsFile)
60 ? readFileSync(openedUrlsFile, "utf8").split("\n").filter(Boolean)
61 : [],
62 storedTokens: (name) => {
63 const file = join(dataDir, "opencode", "mcp-auth.json");
64 if (!existsSync(file)) return undefined;
65 const store = JSON.parse(readFileSync(file, "utf8")) as Record<
66 string,
67 { tokens?: { accessToken?: string; refreshToken?: string; expiresAt?: number } }
68 >;
69 return store[name]?.tokens;
70 },
71 };
72};
73
74/**
75 * Run OpenCode's one-time first-run work (database migration) off camera so
76 * a recorded session starts clean. Runs in a bare project with NO MCP
77 * servers configured: `mcp auth` errors with "Unexpected status: needs_auth"
78 * if an earlier `mcp list` already probed the server, so the warm-up must
79 * never touch it.
80 */
81export const warmUp = (home: OpenCodeHome): void => {
82 const bare = join(home.projectDir, "..", "warmup");
83 mkdirSync(bare, { recursive: true });
84 writeFileSync(join(bare, "opencode.json"), "{}");
85 spawnSync("opencode", ["mcp", "list"], { cwd: bare, env: home.env, timeout: 60_000 });
86};
87
88/**
89 * Play the signed-in human for an OAuth flow OpenCode just started: wait for
90 * it to "open the browser" (the shim records the URL instead), then follow
91 * the authorize URL with login_hint — the emulator's consent redirects the
92 * code straight to OpenCode's localhost callback.
93 */
94export const completeOAuthConsent = async (
95 home: OpenCodeHome,
96 email: string,
97 sinceIndex: number,
98): Promise<void> => {
99 const deadline = Date.now() + 30_000;
100 while (Date.now() < deadline) {
101 const url = home.openedUrls()[sinceIndex];
102 if (url) {
103 const response = await fetch(`${url}&login_hint=${encodeURIComponent(email)}`);
104 if (!response.ok) throw new Error(`consent redirect chain failed (${response.status})`);
105 return;
106 }
107 await new Promise((tick) => setTimeout(tick, 250));
108 }
109 throw new Error("opencode never opened an authorization URL");
110};
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/src/surfaces/mcp.ts+83−70
@@ -32,11 +32,12 @@ export interface McpSession { · 31 unmodified lines
32export interface McpSurface {32export interface McpSurface {
33 readonly session: (identity: Identity) => McpSession;33 readonly session: (identity: Identity) => McpSession;
34 /**34 /**
35 * Mint a real MCP bearer exactly the way an MCP client does, headlessly:35 * Mint a real MCP bearer headlessly: protected-resource discovery →
36 * protected-resource discovery → authorization-server discovery → dynamic36 * authorization-server discovery → dynamic client registration → authorize
37 * client registration → authorize with PKCE (consent via the target's37 * with PKCE (consent via the target's strategy) → code exchange. Plumbing
38 * strategy) → code exchange. For raw-wire scenarios that drive /mcp without38 * for raw-wire scenarios that drive /mcp without an MCP client library —
39 * an MCP client library.39 * client *behavior* (scope choices, refresh, token storage) is never
40 * modeled here; that's what driving the real client binaries is for.
40 */41 */
41 readonly mintBearer: (email: string) => Effect.Effect<string>;42 readonly mintBearer: (email: string) => Effect.Effect<string>;
42}43}
@@ -52,70 +53,78 @@ const textOf = (result: unknown): string => { · 9 unmodified lines
52 return typeof result === "string" ? result : JSON.stringify(result);53 return typeof result === "string" ? result : JSON.stringify(result);
53};54};
54 55
56interface TokenResponse {
57 readonly access_token?: string;
58}
59
60const mintBearerFlow = async (target: Target, email: string): Promise<string> => {
61 const consent = target.mcpConsent?.({
62 label: email,
63 credentials: { email, password: "" },
64 });
65 if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`);
66
67 const mcpPath = new URL(target.mcpUrl).pathname;
68 const resource = (await (
69 await fetch(new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl))
70 ).json()) as { authorization_servers?: ReadonlyArray<string> };
71 const issuer = resource.authorization_servers?.[0];
72 if (!issuer) throw new Error("mintBearer: no authorization server advertised");
73 const metadata = (await (
74 await fetch(new URL("/.well-known/oauth-authorization-server", issuer))
75 ).json()) as {
76 readonly authorization_endpoint: string;
77 readonly token_endpoint: string;
78 readonly registration_endpoint: string;
79 };
80
81 const redirectUri = "http://127.0.0.1:9/callback";
82 const registered = (await (
83 await fetch(metadata.registration_endpoint, {
84 method: "POST",
85 headers: { "content-type": "application/json" },
86 body: JSON.stringify({
87 client_name: "executor-e2e",
88 redirect_uris: [redirectUri],
89 grant_types: ["authorization_code", "refresh_token"],
90 response_types: ["code"],
91 token_endpoint_auth_method: "none",
92 }),
93 })
94 ).json()) as { readonly client_id: string };
95
96 const verifier = randomBytes(32).toString("base64url");
97 const authorizeUrl = new URL(metadata.authorization_endpoint);
98 authorizeUrl.searchParams.set("client_id", registered.client_id);
99 authorizeUrl.searchParams.set("redirect_uri", redirectUri);
100 authorizeUrl.searchParams.set("response_type", "code");
101 authorizeUrl.searchParams.set("state", randomUUID());
102 authorizeUrl.searchParams.set(
103 "code_challenge",
104 createHash("sha256").update(verifier).digest("base64url"),
105 );
106 authorizeUrl.searchParams.set("code_challenge_method", "S256");
107 const { code } = await consent({ authorizationUrl: authorizeUrl.toString() });
108
109 const token = (await (
110 await fetch(metadata.token_endpoint, {
111 method: "POST",
112 headers: { "content-type": "application/x-www-form-urlencoded" },
113 body: new URLSearchParams({
114 grant_type: "authorization_code",
115 code,
116 redirect_uri: redirectUri,
117 client_id: registered.client_id,
118 code_verifier: verifier,
119 }),
120 })
121 ).json()) as TokenResponse;
122 if (!token.access_token) throw new Error("mintBearer: token exchange returned no token");
123 return token.access_token;
124};
125
55export const makeMcpSurface = (target: Target): McpSurface => ({126export const makeMcpSurface = (target: Target): McpSurface => ({
56 mintBearer: (email) =>127 mintBearer: (email) => Effect.promise(() => mintBearerFlow(target, email)),
57 Effect.promise(async () => {
58 const consent = target.mcpConsent?.({ label: email, credentials: { email, password: "" } });
59 if (!consent) throw new Error(`target ${target.name} has no mcpConsent strategy`);
60
61 const mcpPath = new URL(target.mcpUrl).pathname;
62 const resource = (await (
63 await fetch(new URL(`/.well-known/oauth-protected-resource${mcpPath}`, target.baseUrl))
64 ).json()) as { readonly authorization_servers?: ReadonlyArray<string> };
65 const issuer = resource.authorization_servers?.[0];
66 if (!issuer) throw new Error("mintBearer: no authorization server advertised");
67 const metadata = (await (
68 await fetch(new URL("/.well-known/oauth-authorization-server", issuer))
69 ).json()) as {
70 readonly authorization_endpoint: string;
71 readonly token_endpoint: string;
72 readonly registration_endpoint: string;
73 };
74
75 const redirectUri = "http://127.0.0.1:9/callback";
76 const registered = (await (
77 await fetch(metadata.registration_endpoint, {
78 method: "POST",
79 headers: { "content-type": "application/json" },
80 body: JSON.stringify({
81 client_name: "executor-e2e",
82 redirect_uris: [redirectUri],
83 grant_types: ["authorization_code"],
84 response_types: ["code"],
85 token_endpoint_auth_method: "none",
86 }),
87 })
88 ).json()) as { readonly client_id: string };
89
90 const verifier = randomBytes(32).toString("base64url");
91 const authorizeUrl = new URL(metadata.authorization_endpoint);
92 authorizeUrl.searchParams.set("client_id", registered.client_id);
93 authorizeUrl.searchParams.set("redirect_uri", redirectUri);
94 authorizeUrl.searchParams.set("response_type", "code");
95 authorizeUrl.searchParams.set("state", randomUUID());
96 authorizeUrl.searchParams.set(
97 "code_challenge",
98 createHash("sha256").update(verifier).digest("base64url"),
99 );
100 authorizeUrl.searchParams.set("code_challenge_method", "S256");
101 const { code } = await consent({ authorizationUrl: authorizeUrl.toString() });
102
103 const token = (await (
104 await fetch(metadata.token_endpoint, {
105 method: "POST",
106 headers: { "content-type": "application/x-www-form-urlencoded" },
107 body: new URLSearchParams({
108 grant_type: "authorization_code",
109 code,
110 redirect_uri: redirectUri,
111 client_id: registered.client_id,
112 code_verifier: verifier,
113 }),
114 })
115 ).json()) as { readonly access_token?: string };
116 if (!token.access_token) throw new Error("mintBearer: token exchange returned no token");
117 return token.access_token;
118 }),
119 session: (identity) => {128 session: (identity) => {
120 const serverName = target.name;129 const serverName = target.name;
121 let runtimePromise: Promise<Runtime> | undefined;130 let runtimePromise: Promise<Runtime> | undefined;
@@ -132,9 +141,13 @@ export const makeMcpSurface = (target: Target): McpSurface => ({ · 10 unmodified lines
132 const dir = mkdtempSync(join(tmpdir(), "executor-e2e-mcp-"));141 const dir = mkdtempSync(join(tmpdir(), "executor-e2e-mcp-"));
133 writeFileSync(142 writeFileSync(
134 join(dir, "mcporter.json"),143 join(dir, "mcporter.json"),
135 JSON.stringify({ mcpServers: { [serverName]: { url: target.mcpUrl } } }),144 JSON.stringify({
145 mcpServers: { [serverName]: { url: target.mcpUrl } },
146 }),
136 );147 );
137 runtimePromise = createRuntime({ configPath: join(dir, "mcporter.json") });148 runtimePromise = createRuntime({
149 configPath: join(dir, "mcporter.json"),
150 });
138 }151 }
139 return runtimePromise;152 return runtimePromise;
140 };153 };
e2e/src/target.ts+9−1
@@ -11,7 +11,9 @@ export type Capability = · 10 unmodified lines
11 | "browser" // web UI reachable + identity injectable into a browser context11 | "browser" // web UI reachable + identity injectable into a browser context
12 | "mcp-oauth" // MCP endpoint with a headless OAuth consent path12 | "mcp-oauth" // MCP endpoint with a headless OAuth consent path
13 | "cli" // a CLI/TUI entry point exists for this target13 | "cli" // a CLI/TUI entry point exists for this target
14 | "billing"; // billing limits are enforced (cloud-only)14 | "billing" // billing limits are enforced (cloud-only)
15 | "opencode" // the real OpenCode binary is installed on this host
16 | "ttl-control"; // the authorization server's access-token TTL is test-adjustable
15 17
16export interface Identity {18export interface Identity {
17 /** Shown in transcripts ("user_ab12cd") */19 /** Shown in transcripts ("user_ab12cd") */
@@ -40,4 +42,10 @@ export interface Target { · 22 unmodified lines
40 readonly mcpConsent?: (42 readonly mcpConsent?: (
41 identity: Identity,43 identity: Identity,
42 ) => (request: { authorizationUrl: string }) => Promise<{ code: string }>;44 ) => (request: { authorizationUrl: string }) => Promise<{ code: string }>;
45 /**
46 * Compress (or restore, with null) the authorization server's access-token
47 * lifetime, when "ttl-control" is supported — what lets token-expiry
48 * scenarios cross a REAL expiry in seconds instead of an hour.
49 */
50 readonly setAccessTokenTtl?: (seconds: number | null) => Effect.Effect<void>;
43}51}
e2e/targets/cloud.ts+22−2
@@ -8,7 +8,8 @@ import { randomUUID } from "node:crypto"; · 7 unmodified lines
8 8
9import { Effect } from "effect";9import { Effect } from "effect";
10 10
11import type { Identity, Target } from "../src/target";11import type { Capability, Identity, Target } from "../src/target";
12import { hasOpenCode } from "../src/clients/opencode";
12 13
13export const CLOUD_PORT = Number(process.env.E2E_CLOUD_PORT ?? 4798);14export const CLOUD_PORT = Number(process.env.E2E_CLOUD_PORT ?? 4798);
14export const CLOUD_DB_PORT = Number(process.env.E2E_CLOUD_DB_PORT ?? 5436);15export const CLOUD_DB_PORT = Number(process.env.E2E_CLOUD_DB_PORT ?? 5436);
@@ -54,7 +55,26 @@ export const cloudTarget = (): Target => ({ · 39 unmodified lines
54 name: "cloud",55 name: "cloud",
55 baseUrl: CLOUD_BASE_URL,56 baseUrl: CLOUD_BASE_URL,
56 mcpUrl: `${CLOUD_BASE_URL}/mcp`,57 mcpUrl: `${CLOUD_BASE_URL}/mcp`,
57 capabilities: new Set(["api", "browser", "billing", "mcp-oauth"]),58 capabilities: new Set<Capability>([
59 "api",
60 "browser",
61 "billing",
62 "mcp-oauth",
63 // Cloud's authorization server is the WorkOS emulator, so token-expiry
64 // scenarios can compress the lifecycle.
65 "ttl-control",
66 // Real-client capability is environmental, not a property of the deployment.
67 ...(hasOpenCode() ? (["opencode"] as const) : []),
68 ]),
69 setAccessTokenTtl: (seconds) =>
70 Effect.promise(async () => {
71 const response = await fetch(`http://127.0.0.1:${WORKOS_EMULATOR_PORT}/_emulate/seed`, {
72 method: "POST",
73 headers: { "content-type": "application/json" },
74 body: JSON.stringify({ oauth: { default_access_token_ttl_seconds: seconds } }),
75 });
76 if (!response.ok) throw new Error(`seeding emulator TTL failed (${response.status})`);
77 }),
58 newIdentity: ({ org = true } = {}) =>78 newIdentity: ({ org = true } = {}) =>
59 Effect.promise(async (): Promise<Identity> => {79 Effect.promise(async (): Promise<Identity> => {
60 const label = `user-${randomUUID().slice(0, 8)}`;80 const label = `user-${randomUUID().slice(0, 8)}`;
e2e/targets/selfhost.ts+11−3
@@ -6,7 +6,8 @@ import { Effect } from "effect"; · 5 unmodified lines
6 6
7import { cookieConsentStrategy } from "@executor-js/mcporter";7import { cookieConsentStrategy } from "@executor-js/mcporter";
8 8
9import type { Identity, Target } from "../src/target";9import type { Capability, Identity, Target } from "../src/target";
10import { hasOpenCode } from "../src/clients/opencode";
10 11
11export const SELFHOST_PORT = Number(process.env.E2E_SELFHOST_PORT ?? 4799);12export const SELFHOST_PORT = Number(process.env.E2E_SELFHOST_PORT ?? 4799);
12export const SELFHOST_BASE_URL =13export const SELFHOST_BASE_URL =
@@ -48,10 +49,17 @@ export const selfhostTarget = (): Target => ({ · 35 unmodified lines
48 name: "selfhost",49 name: "selfhost",
49 baseUrl: SELFHOST_BASE_URL,50 baseUrl: SELFHOST_BASE_URL,
50 mcpUrl: `${SELFHOST_BASE_URL}/mcp`,51 mcpUrl: `${SELFHOST_BASE_URL}/mcp`,
51 // No "billing" (no limits). Identity is the bootstrap admin for now —52 // No "billing" (no limits) and no "ttl-control" yet (Better Auth is the
53 // authorization server; its token TTL isn't test-adjustable, so token-expiry
54 // scenarios skip here). Identity is the bootstrap admin for now —
52 // single-tenant; per-test invite-signup isolation is the next step here, so55 // single-tenant; per-test invite-signup isolation is the next step here, so
53 // browser scenarios must prefix the resources they create.56 // browser scenarios must prefix the resources they create.
54 capabilities: new Set(["api", "browser", "mcp-oauth"]),57 capabilities: new Set<Capability>([
58 "api",
59 "browser",
60 "mcp-oauth",
61 ...(hasOpenCode() ? (["opencode"] as const) : []),
62 ]),
55 newIdentity: () =>63 newIdentity: () =>
56 Effect.promise(async (): Promise<Identity> => {64 Effect.promise(async (): Promise<Identity> => {
57 // Sign in once and carry the session in both shapes: `headers` for the65 // Sign in once and carry the session in both shapes: `headers` for the
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}
vendor/emulate+1−1
@@ -1 +1 @@
1Subproject commit 41f09e973c174d4d64c60b9f4ec271cf7ef9c2eb1Subproject commit 36638da8c104e92cb3522e0dca336618cdfd8205

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t