diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index d0aed5b17..9a97bed3c 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -133,3 +133,32 @@ When handing results to the user, follow the evidence contract in the root values, not booleans — `expect(list).toContain(x)`, never `expect(list.includes(x)).toBe(true)` — so failures show the data. - Keep it deterministic: no sleeps; wait on conditions. + +## Developer-session recordings (chat theater + desk) + +Some scenarios are meant to be WATCHED — they show the product the way a +developer actually uses it. Three tiers, pick deliberately: + +1. **Chat theater** (`src/clients/chat-theater.ts`): the default for + product-flow recordings. The "agent" is a chat renderer in a recorded + PTY; every tool spinner brackets a REAL mcporter MCP call (OAuth, + execute, approval resume). No inference, no third-party binary. + Exemplar: `scenarios/connect-handoff-session.test.ts`. Artifacts: + `terminal.cast` (the chat) + `session.mp4` (browser hops); the viewer + plays them in story order. +2. **Replay brain + real client** (`src/clients/replay-brain.ts`): when the + third-party CLIENT's behavior is under test (OpenCode/Claude Code + protocol handling). A scripted OpenAI-wire server plays the LLM; the + real client does everything else. Script by transcript inspection, never + turn counting. +3. **Real-inference evals**: a different axis (performance distributions, + not pass/fail). Not in this suite. + +**The Desk** (`desk/`): films a scenario on one virtual Linux desktop — the +chat renderer in a visible xterm, the browser as a real headed window, one +ffmpeg x11grab. The film replaces session.mp4 in the run dir; the scenario +file is unchanged (chat-theater switches transports on `E2E_DESK=1`). + +``` +e2e/desk/run.sh [scenario] [project] # docker; first run builds + installs +``` diff --git a/e2e/desk/Dockerfile b/e2e/desk/Dockerfile new file mode 100644 index 000000000..6b92222e2 --- /dev/null +++ b/e2e/desk/Dockerfile @@ -0,0 +1,27 @@ +# The Desk: a virtual Linux desktop that e2e scenarios are FILMED on — one +# Xvfb display showing a real terminal window (the chat) and a real headed +# Chromium (the browser hop), captured by a single ffmpeg x11grab into one +# session.mp4. No splicing: the film is the screen. +# +# The repo is bind-mounted at /repo at runtime; node_modules and caches live +# in named volumes (host artifacts are macOS-native and can't be reused). +FROM node:22-bookworm + +RUN apt-get update && apt-get install -y --no-install-recommends \ + xvfb openbox xterm ffmpeg xdotool x11-utils x11-xserver-utils \ + fonts-dejavu-core fonts-noto-color-emoji \ + curl unzip ca-certificates git \ + && rm -rf /var/lib/apt/lists/* + +# Bun — the repo's package manager and script runner. +RUN curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.13" \ + && ln -s /root/.bun/bin/bun /usr/local/bin/bun \ + && ln -s /root/.bun/bin/bun /usr/local/bin/bunx + +# Chromium + system deps baked into the image, pinned to the repo's +# playwright version so the runtime install check is a no-op. +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +RUN npx -y playwright@1.60.0 install --with-deps chromium && rm -rf /root/.npm + +WORKDIR /repo +ENTRYPOINT ["bash", "e2e/desk/entry.sh"] diff --git a/e2e/desk/entry.sh b/e2e/desk/entry.sh new file mode 100755 index 000000000..c1b3fe0c7 --- /dev/null +++ b/e2e/desk/entry.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Desk entrypoint (runs inside the desk image): boot a display, roll one +# camera on it, run the scenario with E2E_DESK=1 so its windows land on the +# display, then drop the film into the scenario's run dir as session.mp4. +set -euo pipefail + +SCENARIO="${DESK_SCENARIO:-scenarios/connect-handoff-session.test.ts}" +PROJECT="${DESK_PROJECT:-cloud}" +SIZE="${DESK_SIZE:-1440x900}" + +echo "[desk] installing dependencies (cached in a volume after first run)…" +bun install --frozen-lockfile + +echo "[desk] display :99 at ${SIZE}" +Xvfb :99 -screen 0 "${SIZE}x24" -nolisten tcp & +export DISPLAY=:99 +sleep 0.5 +openbox & +xsetroot -solid "#0b0b10" || true + +mkdir -p /desk-out +echo "[desk] camera rolling" +ffmpeg -loglevel error -f x11grab -framerate 24 -video_size "$SIZE" -i :99 \ + -c:v libx264 -preset veryfast -crf 24 -pix_fmt yuv420p -y /desk-out/desk.mp4 & +FFMPEG_PID=$! + +set +e +(cd e2e && E2E_DESK=1 npx vitest run --project "$PROJECT" "$SCENARIO") +STATUS=$? +set -e + +sleep 1 +kill -INT "$FFMPEG_PID" 2>/dev/null || true +wait "$FFMPEG_PID" 2>/dev/null || true + +# The desk film IS the session recording — it replaces the browser-only +# video the surface recorded inside the run. +RUN_DIR=$(ls -dt e2e/runs/"$PROJECT"/*/ 2>/dev/null | head -1) +if [ -n "$RUN_DIR" ] && [ -f /desk-out/desk.mp4 ]; then + cp /desk-out/desk.mp4 "${RUN_DIR}session.mp4" + echo "[desk] film → ${RUN_DIR}session.mp4" +fi +exit "$STATUS" diff --git a/e2e/desk/run.sh b/e2e/desk/run.sh new file mode 100755 index 000000000..1f303b766 --- /dev/null +++ b/e2e/desk/run.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Film a scenario on the Desk (one virtual desktop, one recording). +# e2e/desk/run.sh [scenario-path] [project] +# First run builds the image and fills the node_modules volume; later runs +# reuse both. +set -euo pipefail +cd "$(dirname "$0")/../.." + +docker build -t executor-e2e-desk e2e/desk + +docker run --rm \ + -v "$PWD":/repo \ + -v executor-desk-node-modules:/repo/node_modules \ + -v executor-desk-bun-cache:/root/.bun/install/cache \ + -e DESK_SCENARIO="${1:-scenarios/connect-handoff-session.test.ts}" \ + -e DESK_PROJECT="${2:-cloud}" \ + executor-e2e-desk diff --git a/e2e/scenarios/connect-handoff-session.test.ts b/e2e/scenarios/connect-handoff-session.test.ts new file mode 100644 index 000000000..70b5d0f75 --- /dev/null +++ b/e2e/scenarios/connect-handoff-session.test.ts @@ -0,0 +1,189 @@ +// The connect handoff as a DEVELOPER SESSION — the way a human actually +// tests this: an agent chat in a real terminal where the agent wires up the +// API over MCP and drops a connect link, a browser hop to paste the key, +// then back to the chat to prove the connection works with a live send. +// +// No inference, no third-party agent binary: the "agent" is the chat +// theater (src/clients/chat-theater.ts) presenting REAL mcporter MCP calls +// — OAuth, execute, approval pause/resume all genuine, every tool spinner +// on screen bracketing the actual call it narrates. The provider on the +// other side is real too (resend.emulators.dev); its request ledger is the +// final evidence. +import { randomBytes } from "node:crypto"; +import { join } from "node:path"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Cli, Mcp, RunDir, Target } from "../src/services"; +import { withChatTheater } from "../src/clients/chat-theater"; +import type { McpSession } from "../src/surfaces/mcp"; + +const EMULATOR_BASE = "https://resend.emulators.dev"; + +const unique = (prefix: string) => `${prefix}_${randomBytes(4).toString("hex")}`; + +// The emulator serves its own OpenAPI document (bearer auth, base URL in +// `servers`) — adding by URL with nothing else is exactly what an agent +// does, and the platform derives the paste-a-token auth method from the +// spec's security scheme. +const EMULATOR_SPEC_URL = `${EMULATOR_BASE}/openapi.json`; + +const addSpecCode = (slug: string) => ` +const added = await tools.executor.openapi.addSpec({ + spec: { kind: "url", url: ${JSON.stringify(EMULATOR_SPEC_URL)} }, + slug: ${JSON.stringify(slug)}, +}); +return added.ok ? { ok: true, slug: added.data.slug, toolCount: added.data.toolCount } : { ok: false, error: added.error }; +`; + +const createHandoffCode = (slug: string) => ` +const handoff = await tools.executor.coreTools.connections.createHandoff({ + integration: ${JSON.stringify(slug)}, + owner: "org", + label: "Resend", +}); +return handoff.ok ? { ok: true, url: handoff.data.url } : { ok: false, error: handoff.error }; +`; + +const sendEmailCode = (slug: string, subject: string) => ` +const found = await tools.search({ namespace: ${JSON.stringify(slug)}, query: "send email", limit: 5 }); +const path = found.items[0]?.path; +if (!path) return { ok: false, error: "no send tool found" }; +let t = tools; +for (const seg of path.split(".")) t = t[seg]; +const sent = await t({ + body: { + from: "onboarding@example.com", + to: "dev-session@example.com", + subject: ${JSON.stringify(subject)}, + html: "

connect-handoff developer session

", + }, +}); +return { ok: sent.ok, path, result: sent.ok ? sent.data : sent.error }; +`; + +/** Run `execute`, auto-approving a paused execution (policy elicitation) + * once, and parse the sandbox's JSON return value. */ +const executeJson = (session: McpSession, code: string) => + Effect.gen(function* () { + let result = yield* session.call("execute", { code }); + if (result.text.includes("executionId:")) { + result = yield* session.approvePaused(result.text); + } + expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true); + return JSON.parse(result.text) as Record; + }); + +const mintEmulatorApiKey = Effect.promise(async () => { + const response = await fetch(`${EMULATOR_BASE}/_emulate/credentials`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "api-key" }), + }); + const body = (await response.json()) as { credential?: { token?: string } }; + const token = body.credential?.token; + if (!token) throw new Error(`emulator credential mint failed: ${JSON.stringify(body)}`); + return token; +}); + +scenario( + "Connect · developer session: agent chat → handoff link → paste key → verified send", + { timeout: 240_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const mcp = yield* Mcp; + const browser = yield* Browser; + const cli = yield* Cli; + const runDir = yield* RunDir; + + const integration = unique("resendsesh"); + const emailSubject = unique("dev-session"); + const apiKey = yield* mintEmulatorApiKey; + const identity = yield* target.newIdentity(); + const session = mcp.session(identity); + + yield* withChatTheater( + cli, + { title: "executor agent — connect Resend", record: join(runDir, "terminal.cast") }, + (chat) => + Effect.gen(function* () { + // Real MCP OAuth + tool discovery happens behind this call. + yield* chat.tool( + { name: "executor (mcp)", result: (tools) => `${tools.length} tools available` }, + session.listTools(), + ); + + yield* chat.user( + "Add the Resend API to my executor and give me a link to connect my account", + ); + yield* chat.assistant("I'll register the Resend API in your Executor now."); + const added = yield* chat.tool( + { name: "execute", input: addSpecCode(integration) }, + executeJson(session, addSpecCode(integration)), + ); + expect(added.ok, `addSpec succeeded: ${JSON.stringify(added)}`).toBe(true); + + yield* chat.assistant("Registered. Creating your connect link…"); + const handoff = yield* chat.tool( + { name: "execute", input: createHandoffCode(integration) }, + executeJson(session, createHandoffCode(integration)), + ); + expect(handoff.ok, `createHandoff succeeded: ${JSON.stringify(handoff)}`).toBe(true); + const handoffUrl = String(handoff.url); + expect(new URL(handoffUrl).origin, "handoff targets this deployment").toBe( + new URL(target.baseUrl).origin, + ); + + yield* chat.assistant( + `Open this link to connect your Resend account:\n\n${handoffUrl}\n\nTell me once you've pasted your API key.`, + ); + + // The browser hop — the terminal session stays open while the + // "user" pastes the key; the paste is the real add-account UI. + yield* chat.status("you, in the browser: opening the link and pasting the API key…"); + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the connect link from the chat", async () => { + await page.goto(handoffUrl, { waitUntil: "networkidle" }); + await page + .getByRole("heading", { name: /Add connection/ }) + .waitFor({ timeout: 15_000 }); + }); + await step("Paste the Resend API key and connect", async () => { + const credential = page.getByPlaceholder(/paste the value \/ token/i); + await credential.waitFor({ timeout: 15_000 }); + await credential.fill(apiKey); + await page.getByRole("button", { name: "Add connection", exact: true }).click(); + await page + .getByRole("heading", { name: /Add connection/ }) + .waitFor({ state: "hidden", timeout: 20_000 }); + }); + }); + + yield* chat.user("Connected, now send a test email to prove it works"); + yield* chat.assistant("Sending a test email through your new connection…"); + const sent = yield* chat.tool( + { name: "execute", input: sendEmailCode(integration, emailSubject) }, + executeJson(session, sendEmailCode(integration, emailSubject)), + ); + expect(sent.ok, `email sent through the connection: ${JSON.stringify(sent)}`).toBe( + true, + ); + + yield* chat.assistant("Test email sent - your Resend connection works."); + }), + ); + + // Final evidence: the emulator's ledger saw the send from Executor. + const ledger = yield* Effect.promise(async () => + (await fetch(`${EMULATOR_BASE}/_emulate/ledger`)).text(), + ); + expect( + ledger.includes(emailSubject), + "the emulator request ledger recorded the test email", + ).toBe(true); + }), + ), +); diff --git a/e2e/scripts/film.ts b/e2e/scripts/film.ts new file mode 100644 index 000000000..cea0878ac --- /dev/null +++ b/e2e/scripts/film.ts @@ -0,0 +1,193 @@ +// Film a run: turn a chat-theater run's real recordings into ONE mp4 that +// plays like a screen recording of a developer tabbing between full-screen +// windows — terminal, browser, terminal. Both segments are the genuine +// recordings; the only editorial act is the cut, exactly like real tabbing. +// +// bun scripts/film.ts runs// +// +// The cut list comes from the run's focus timeline (src/timeline.ts): +// surfaces mark focus as a side effect of acting — a Playwright step +// focuses the browser, a chat event focuses the terminal — so the +// operations themselves decide where the film cuts, for any number of +// hops. Runs without a timeline fall back to the narrator-line / largest- +// gap heuristic. The cast renders with NO idle compression so cast time +// equals video time. Output: film.mp4, registered in result.json — the +// viewer plays it as the session when present. +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { readTimeline } from "../src/timeline"; + +const runDir = resolve(process.argv[2] ?? ""); +const castPath = join(runDir, "terminal.cast"); +const browserPath = join(runDir, "session.mp4"); +if (!existsSync(castPath) || !existsSync(browserPath)) { + console.error(`film: need terminal.cast + session.mp4 in ${runDir}`); + process.exit(1); +} + +const run = (cmd: string, args: string[]) => execFileSync(cmd, args, { stdio: "pipe" }); +const probeSeconds = (file: string): number => + Number( + execFileSync("ffprobe", [ + ...["-v", "quiet", "-show_entries", "format=duration"], + ...["-of", "csv=p=0", file], + ]) + .toString() + .trim(), + ); + +// --------------------------------------------------------------------------- +// Build the cut list: acts of { source, from, to } in each source's clock. +// --------------------------------------------------------------------------- + +interface CastEvent { + readonly at: number; + readonly text: string; +} + +const events: CastEvent[] = readFileSync(castPath, "utf8") + .split("\n") + .slice(1) + .filter(Boolean) + .map((line) => JSON.parse(line) as [number, string, string]) + .filter((event) => event[1] === "o") + .map((event) => ({ at: event[0], text: event[2] })); + +const castEnd = events.at(-1)?.at ?? 0; + +interface Act { + readonly source: "terminal" | "browser"; + readonly from: number; + readonly to: number; +} + +/** Acts from the focus timeline: each contiguous focus run becomes a cut of + * that window's recording, mapped via the recording-start anchors. */ +const actsFromTimeline = (): Act[] | null => { + const timeline = readTimeline(runDir); + if (!timeline || timeline.focus.length < 2) return null; + const terminalAnchor = timeline.anchors.terminal; + const browserAnchor = timeline.anchors.browser; + if (terminalAnchor === undefined || browserAnchor === undefined) return null; + + const browserEnd = probeSeconds(browserPath); + const toMedia = (window: Act["source"], wallMs: number) => + Math.max(0, (wallMs - (window === "terminal" ? terminalAnchor : browserAnchor)) / 1000); + + const acts: Act[] = []; + for (let i = 0; i < timeline.focus.length; i += 1) { + const current = timeline.focus[i]; + if (!current) continue; + const nextAt = timeline.focus[i + 1]?.at; + const from = i === 0 && current.window === "terminal" ? 0 : toMedia(current.window, current.at); + const mediaEnd = current.window === "terminal" ? castEnd : browserEnd; + const to = + nextAt === undefined ? mediaEnd : Math.min(toMedia(current.window, nextAt), mediaEnd); + if (to - from > 0.5) acts.push({ source: current.window, from, to }); + } + return acts.length >= 2 ? acts : null; +}; + +/** Fallback for runs without a timeline: one browser hop located by the + * narrator line, or the largest output gap in the cast. */ +const actsFromHeuristic = (): Act[] => { + const findHop = (): { start: number; end: number } => { + const markerIndex = events.findIndex((event) => event.text.includes("in the browser")); + if (markerIndex !== -1) { + const start = events[markerIndex]; + const after = events + .slice(markerIndex + 1) + .find((event) => event.at > (start?.at ?? 0) + 1 && event.text.trim().length > 0); + if (start && after) return { start: start.at + 0.8, end: after.at }; + } + let best = { start: 0, end: 0 }; + for (let i = 1; i < events.length; i += 1) { + const previous = events[i - 1]; + const current = events[i]; + if (previous && current && current.at - previous.at > best.end - best.start) { + best = { start: previous.at, end: current.at }; + } + } + return best; + }; + const hop = findHop(); + if (hop.end - hop.start < 2) { + console.error(`film: no browser hop found in the cast (gap ${hop.end - hop.start}s)`); + process.exit(1); + } + return [ + { source: "terminal", from: 0, to: hop.start }, + { source: "browser", from: 0, to: probeSeconds(browserPath) }, + { source: "terminal", from: hop.end, to: castEnd }, + ]; +}; + +const acts = actsFromTimeline() ?? actsFromHeuristic(); + +// --------------------------------------------------------------------------- +// Render + cut + concatenate onto one canvas — full-screen cuts, like +// tabbing. +// --------------------------------------------------------------------------- + +const work = mkdtempSync(join(tmpdir(), "e2e-film-")); +const castGif = join(work, "cast.gif"); +const castVideo = join(work, "cast.mp4"); + +run("agg", [ + ...["--idle-time-limit", String(Math.ceil(castEnd) + 60)], + ...["--font-size", "16"], + castPath, + castGif, +]); +run("ffmpeg", [ + ...["-y", "-i", castGif], + // agg's gif can have odd dimensions; libx264 requires even. + ...["-vf", "scale=ceil(iw/2)*2:ceil(ih/2)*2"], + ...["-pix_fmt", "yuv420p", "-r", "24", castVideo], +]); + +const FIT = + "scale=1280:800:force_original_aspect_ratio=decrease,pad=1280:800:(ow-iw)/2:(oh-ih)/2:color=0x0b0b10,setsar=1,fps=24,format=yuv420p"; + +const filmPath = join(runDir, "film.mp4"); +const inputIndex = { terminal: 0, browser: 1 } as const; +const filters = acts.map( + (act, index) => + `[${inputIndex[act.source]}:v]trim=${act.from.toFixed(2)}:${act.to.toFixed(2)},setpts=PTS-STARTPTS,${FIT}[act${index}]`, +); +run("ffmpeg", [ + "-y", + ...["-i", castVideo], + ...["-i", browserPath], + "-filter_complex", + [ + ...filters, + `${acts.map((_, index) => `[act${index}]`).join("")}concat=n=${acts.length}:v=1:a=0[out]`, + ].join(";"), + ...["-map", "[out]"], + ...["-c:v", "libx264", "-preset", "veryfast", "-crf", "24", "-movflags", "+faststart"], + filmPath, +]); +rmSync(work, { recursive: true, force: true }); + +// Register the film in the run's artifact list so the viewer offers it. +const resultPath = join(runDir, "result.json"); +if (existsSync(resultPath)) { + const result = JSON.parse(readFileSync(resultPath, "utf8")) as { artifacts?: string[] }; + if (Array.isArray(result.artifacts) && !result.artifacts.includes("film.mp4")) { + result.artifacts.push("film.mp4"); + writeFileSync(resultPath, JSON.stringify(result, null, 1)); + } +} + +console.log( + `film: ${filmPath}\n${acts + .map( + (act, index) => + ` act ${index + 1} ${act.source} ${act.from.toFixed(1)}–${act.to.toFixed(1)}s`, + ) + .join("\n")}`, +); diff --git a/e2e/scripts/pr-media.ts b/e2e/scripts/pr-media.ts index 43e3104cb..972ce015a 100644 --- a/e2e/scripts/pr-media.ts +++ b/e2e/scripts/pr-media.ts @@ -8,8 +8,9 @@ // the PR description. // // Usage: bun e2e/scripts/pr-media.ts [...more] -// run dir e2e/runs// — picks session.mp4 or -// terminal.cast and labels the gif from result.json +// run dir e2e/runs// — picks film.mp4 (the +// whole session), else session.mp4, else terminal.cast; +// labels the gif from result.json // session.mp4 browser recording (ffmpeg -> gif) // terminal.cast terminal recording (agg -> gif; brew install agg) // *.png run screenshot, uploaded as-is @@ -48,10 +49,13 @@ const resolveArtifact = (input: string): Artifact => { slug: basename(path).replace(/\.[^.]+$/, ""), }; } - const recording = ["session.mp4", "terminal.cast"] + // film.mp4 (scripts/film.ts) is the whole session — terminal chat AND the + // browser hop, cut in time order; the bare browser session.mp4 is only the + // hop, so it must never win when a film exists. + const recording = ["film.mp4", "session.mp4", "terminal.cast"] .map((name) => join(path, name)) .find(existsSync); - if (!recording) throw new Error(`${input} has no session.mp4 or terminal.cast`); + if (!recording) throw new Error(`${input} has no film.mp4, session.mp4, or terminal.cast`); let label = basename(path); try { const result = JSON.parse(readFileSync(join(path, "result.json"), "utf8")) as { diff --git a/e2e/src/clients/agent-chat-tui.ts b/e2e/src/clients/agent-chat-tui.ts new file mode 100644 index 000000000..23c1def50 --- /dev/null +++ b/e2e/src/clients/agent-chat-tui.ts @@ -0,0 +1,166 @@ +// The chat renderer half of the chat theater (see chat-theater.ts): a tiny +// agent-chat TUI that runs inside the recorded PTY and paints whatever the +// scenario's REAL MCP calls are doing. It performs no logic of its own — it +// reads base64-encoded JSON events on stdin and renders them with the +// pacing of a chat session (typed user input, streamed agent text, live +// tool spinners that run exactly as long as the real call did). +// +// Run with: bun agent-chat-tui.ts "" [events-fifo] +// +// Two transports: events arrive base64-encoded one-per-line either on stdin +// (PTY mode — the chat theater types them into the recorded terminal) or on +// a FIFO path (desk mode — the renderer runs inside a visible xterm on the +// virtual desktop, where stdin belongs to the terminal emulator). +import { createReadStream, writeFileSync } from "node:fs"; +import { createInterface } from "node:readline"; + +type TheaterEvent = + | { readonly type: "user"; readonly text: string } + | { readonly type: "assistant"; readonly text: string } + | { + readonly type: "tool-start"; + readonly name: string; + /** The call's real input, pre-rendered as preview lines. */ + readonly input?: readonly string[]; + } + | { + readonly type: "tool-end"; + readonly ok: boolean; + /** First line of the call's real result. */ + readonly result?: string; + readonly seconds?: number; + } + | { readonly type: "status"; readonly text: string } + | { readonly type: "done" }; + +const out = (text: string) => process.stdout.write(text); + +// The PTY would otherwise echo every incoming event line into the recording. +if (process.stdin.isTTY) process.stdin.setRawMode(true); + +const DIM = "\x1b[2m"; +const BOLD = "\x1b[1m"; +const RESET = "\x1b[0m"; +const CYAN = "\x1b[36m"; +const GREEN = "\x1b[32m"; +const RED = "\x1b[31m"; +const MAGENTA = "\x1b[35m"; +const CLEAR_LINE = "\x1b[2K\r"; + +const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +const sleep = (ms: number) => new Promise((tick) => setTimeout(tick, ms)); + +const title = process.argv[2] ?? "executor session"; +const eventsFifo = process.argv[3]; +out(`${BOLD}${MAGENTA}●${RESET} ${BOLD}${title}${RESET}\n`); +out(`${DIM}${"─".repeat(Math.min(96, title.length + 24))}${RESET}\n`); + +let spinnerTimer: ReturnType | undefined; +let spinnerName = ""; + +// A tool call renders the way agent TUIs render them: the tool's name, the +// call's real input as an indented block, a spinner while the real call +// runs, then the real result's first line with the real duration. +// +// ⚙ execute +// │ const added = await tools.executor.openapi.addSpec({ +// │ spec: { kind: "url", url: "https://…/openapi.json" }, +// ⠼ running… +// ✓ 2.3s {"ok":true,"slug":"resend","toolCount":9} +const startTool = (name: string, input?: readonly string[]) => { + spinnerName = name; + out(`\n ${MAGENTA}⚙${RESET} ${BOLD}${name}${RESET}\n`); + for (const line of input ?? []) { + out(` ${DIM}│ ${line.slice(0, 92)}${RESET}\n`); + } + let frame = 0; + spinnerTimer = setInterval(() => { + out(`${CLEAR_LINE} ${CYAN}${SPINNER[frame % SPINNER.length]}${RESET} ${DIM}running…${RESET}`); + frame += 1; + }, 80); +}; + +const endTool = (ok: boolean, result?: string, seconds?: number) => { + if (spinnerTimer) clearInterval(spinnerTimer); + spinnerTimer = undefined; + const mark = ok ? `${GREEN}✓${RESET}` : `${RED}✗ ${spinnerName}${RESET}`; + const took = seconds !== undefined ? `${DIM}${seconds.toFixed(1)}s${RESET} ` : ""; + out(`${CLEAR_LINE} ${mark} ${took}${DIM}${(result ?? "").slice(0, 88)}${RESET}\n`); +}; + +const handle = async (event: TheaterEvent): Promise => { + switch (event.type) { + case "user": { + out(`\n${BOLD}${CYAN}┃ you${RESET} `); + for (const ch of event.text) { + out(ch); + await sleep(18); + } + out("\n"); + return true; + } + case "assistant": { + out(`\n${BOLD}${MAGENTA}● agent${RESET} `); + for (const piece of event.text.match(/.{1,3}/gs) ?? []) { + out(piece); + await sleep(12); + } + out("\n"); + return true; + } + case "tool-start": { + startTool(event.name, event.input); + return true; + } + case "tool-end": { + endTool(event.ok, event.result, event.seconds); + return true; + } + case "status": { + out(`\n ${DIM}· ${event.text}${RESET}\n`); + return true; + } + case "done": { + out(`\n${DIM}${"─".repeat(40)}${RESET}\n${GREEN}✦ session complete${RESET}\n`); + if (eventsFifo) { + // Desk mode: ack completion to the driver, then linger so the + // closing frame stays on camera before the xterm vanishes. + writeFileSync(`${eventsFifo}.done`, ""); + await sleep(4_000); + } + return false; + } + } +}; + +// Events arrive faster than they render; process strictly in order so the +// animations (not arrival time) set the pacing. +const queue: TheaterEvent[] = []; +let draining = false; +const drain = async () => { + if (draining) return; + draining = true; + while (queue.length > 0) { + const event = queue.shift(); + if (!event) break; + const keepGoing = await handle(event); + if (!keepGoing) { + process.exit(0); + } + } + draining = false; +}; + +const input = eventsFifo ? createReadStream(eventsFifo) : process.stdin; +createInterface({ input, terminal: false }).on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: PTY stdin may echo stray keystrokes that aren't base64 JSON; ignore them + try { + queue.push(JSON.parse(Buffer.from(trimmed, "base64").toString("utf8")) as TheaterEvent); + } catch { + return; + } + void drain(); +}); diff --git a/e2e/src/clients/chat-theater.ts b/e2e/src/clients/chat-theater.ts new file mode 100644 index 000000000..053306c36 --- /dev/null +++ b/e2e/src/clients/chat-theater.ts @@ -0,0 +1,217 @@ +// Chat theater: agent-chat presentation over REAL mcporter MCP calls — no +// inference, no third-party agent binary. The scenario stays in Effect land +// making real calls; a chat renderer (agent-chat-tui.ts) paints them, so +// the recording reads like a developer chatting with an agent while every +// tool spinner brackets the genuine call it narrates. +// +// Two stages for the same play: +// - PTY mode (default): the renderer runs in a recorded PTY → terminal.cast +// - Desk mode (E2E_DESK=1): the renderer runs in a visible xterm on the +// virtual desktop (events over a FIFO) and the desk's single x11grab +// films it together with the headed browser — one screen, one mp4 +// +// Division of labor in the e2e stack: +// - chat theater (this): deterministic PRODUCT-flow recordings +// - replay brain + real client (replay-brain.ts): CLIENT-behavior tests +// (OpenCode/Claude Code protocol handling), still no inference +// - real-inference evals: a separate axis entirely — performance +// distributions, not pass/fail scenarios +import { spawn, spawnSync } from "node:child_process"; +import { createWriteStream, existsSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { Effect, Exit, Fiber } from "effect"; + +import { markFocus, markRecordingStart } from "../timeline"; +import type { CliSurface } from "../surfaces/cli"; + +const RENDERER = fileURLToPath(new URL("./agent-chat-tui.ts", import.meta.url)); + +interface TheaterEvent { + readonly type: "user" | "assistant" | "tool-start" | "tool-end" | "status" | "done"; + readonly [key: string]: unknown; +} + +export interface ChatTheater { + /** The human's chat line, typed out on screen. */ + readonly user: (text: string) => Effect.Effect; + /** The agent's reply, streamed on screen. */ + readonly assistant: (text: string) => Effect.Effect; + /** Run a REAL effect rendered as a tool call: the tool's name, the call's + * real input block, a spinner for exactly as long as the call runs, then + * the call's real result line with the real duration. */ + readonly tool: ( + call: { + /** Tool name as an agent TUI would show it (e.g. "execute"). */ + readonly name: string; + /** The call's real input — rendered as the indented block. */ + readonly input?: string; + /** Render the result's display line (defaults to JSON of the value). */ + readonly result?: (value: A) => string; + }, + work: Effect.Effect, + ) => Effect.Effect; + /** A dim narrator line (e.g. "waiting for the browser hop"). */ + readonly status: (text: string) => Effect.Effect; +} + +const sleep = (ms: number) => new Promise((tick) => setTimeout(tick, ms)); + +const encode = (event: TheaterEvent) => + `${Buffer.from(JSON.stringify(event)).toString("base64")}\n`; + +/** Input block, like a TUI's call preview: trimmed lines, capped count. */ +const inputPreview = (input: string | undefined): string[] | undefined => { + if (!input) return undefined; + const lines = input + .split("\n") + .map((line) => line.trimEnd()) + .filter((line) => line.trim().length > 0); + return lines.length > 7 ? [...lines.slice(0, 6), `… +${lines.length - 6} lines`] : lines; +}; + +const makeTheater = (push: (event: TheaterEvent) => Effect.Effect): ChatTheater => ({ + user: (text) => push({ type: "user", text }), + assistant: (text) => push({ type: "assistant", text }), + status: (text) => push({ type: "status", text }), + tool: (call, work) => + Effect.gen(function* () { + yield* push({ type: "tool-start", name: call.name, input: inputPreview(call.input) }); + const startedAt = Date.now(); + const exit = yield* Effect.exit(work); + const seconds = (Date.now() - startedAt) / 1000; + if (Exit.isSuccess(exit)) { + const line = call.result ? call.result(exit.value) : JSON.stringify(exit.value); + yield* push({ type: "tool-end", ok: true, result: line, seconds }); + return exit.value; + } + yield* push({ type: "tool-end", ok: false, result: "failed", seconds }); + return yield* exit; + }), +}); + +export interface TheaterOptions { + readonly title: string; + readonly record: string; + readonly viewport?: { readonly cols: number; readonly rows: number }; +} + +/** + * Open the chat renderer, hand the scenario a ChatTheater handle, and close + * the session (footer + exit) when the body finishes — success or failure, + * so the recording always ends cleanly. + */ +export const withChatTheater = ( + cli: CliSurface, + options: TheaterOptions, + body: (theater: ChatTheater) => Effect.Effect, +): Effect.Effect => + process.env.E2E_DESK === "1" ? deskTheater(options, body) : ptyTheater(cli, options, body); + +// --------------------------------------------------------------------------- +// PTY mode: renderer in a recorded PTY; events typed in as base64 lines. +// --------------------------------------------------------------------------- + +const ptyTheater = ( + cli: CliSurface, + options: TheaterOptions, + body: (theater: ChatTheater) => Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const runDir = dirname(options.record); + const queue: TheaterEvent[] = []; + let closed = false; + const push = (event: TheaterEvent) => + Effect.sync(() => { + if (!closed) { + // Chatting IS focusing the terminal window. + if (event.type !== "done") markFocus(runDir, "terminal"); + queue.push(event); + } + }); + + const pump = cli.session( + ["bun", RENDERER, options.title], + async (term) => { + // Don't type until the renderer has painted its header — before that + // it hasn't set raw mode yet, and the PTY would echo the event line + // into the recording. + await term.screen.waitForText(options.title, { timeoutMs: 30_000 }); + // The cast's clock started with the PTY moments ago; anchor it for + // the run's focus timeline (scripts/film.ts cuts on these). + markRecordingStart(runDir, "terminal"); + const deadline = Date.now() + 30 * 60 * 1000; + for (;;) { + const event = queue.shift(); + if (event) { + await term.keyboard.type(encode(event)); + if (event.type === "done") break; + continue; + } + if (Date.now() > deadline) break; + await sleep(40); + } + await term.screen.waitForText("session complete", { timeoutMs: 60_000 }); + }, + { + record: options.record, + viewport: options.viewport ?? { cols: 100, rows: 32 }, + }, + ); + const pumpFiber = yield* Effect.forkChild(Effect.exit(pump)); + + const result = yield* Effect.exit(body(makeTheater(push))); + yield* push({ type: "done" }); + closed = true; + yield* Fiber.join(pumpFiber); + return yield* result; + }); + +// --------------------------------------------------------------------------- +// Desk mode: renderer in a visible xterm on the virtual desktop; events over +// a FIFO. No cast — the desk's x11grab films the screen. +// --------------------------------------------------------------------------- + +const deskTheater = ( + options: TheaterOptions, + body: (theater: ChatTheater) => Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const root = mkdtempSync(join(tmpdir(), "desk-theater-")); + const fifo = join(root, "events.fifo"); + spawnSync("mkfifo", [fifo]); + + const xterm = spawn( + "xterm", + [ + // -u8 + a UTF-8 locale: the renderer speaks box-drawing and bullets. + ...["-u8", "-fa", "DejaVu Sans Mono", "-fs", "13"], + ...["-bg", "#0b0b10", "-fg", "#e8e8ea"], + ...["-geometry", "112x34+48+40", "-T", options.title], + ...["-e", "bun", RENDERER, options.title, fifo], + ], + { stdio: "ignore", env: { ...process.env, LANG: "C.UTF-8", LC_ALL: "C.UTF-8" } }, + ); + // Opening the write end parks until the renderer opens the read end. + const writer = createWriteStream(fifo); + const push = (event: TheaterEvent) => + Effect.sync(() => { + writer.write(encode(event)); + }); + + const result = yield* Effect.exit(body(makeTheater(push))); + yield* push({ type: "done" }); + yield* Effect.promise(async () => { + // Renderer acks the footer, lingers for the camera, then exits with + // its xterm. Don't hang the run on a wedged terminal. + const ackDeadline = Date.now() + 30_000; + while (!existsSync(`${fifo}.done`) && Date.now() < ackDeadline) await sleep(200); + const exitDeadline = Date.now() + 10_000; + while (xterm.exitCode === null && Date.now() < exitDeadline) await sleep(200); + if (xterm.exitCode === null) xterm.kill(); + writer.end(); + }); + return yield* result; + }); diff --git a/e2e/src/clients/opencode.ts b/e2e/src/clients/opencode.ts index 6d3c9ead3..f4242063e 100644 --- a/e2e/src/clients/opencode.ts +++ b/e2e/src/clients/opencode.ts @@ -24,8 +24,18 @@ export interface OpenCodeHome { ) => { accessToken?: string; refreshToken?: string; expiresAt?: number } | undefined; } -/** A throwaway OpenCode installation configured with one remote MCP server. */ -export const makeOpenCodeHome = (serverName: string, mcpUrl: string): OpenCodeHome => { +/** A throwaway OpenCode installation configured with one remote MCP server. + * + * With `chatBrainUrl` set, the config also declares a `replay` provider + * pointing OpenCode's LLM traffic at a local replay brain + * (clients/replay-brain.ts) and selects it as the model — real agent, + * scripted conversation. Tool permissions are pre-allowed so the recorded + * TUI session flows without approval dialogs. */ +export const makeOpenCodeHome = ( + serverName: string, + mcpUrl: string, + options?: { readonly chatBrainUrl?: string }, +): OpenCodeHome => { const root = mkdtempSync(join(tmpdir(), "e2e-opencode-")); const projectDir = join(root, "project"); const dataDir = join(root, "data"); @@ -38,6 +48,22 @@ export const makeOpenCodeHome = (serverName: string, mcpUrl: string): OpenCodeHo JSON.stringify({ $schema: "https://opencode.ai/config.json", mcp: { [serverName]: { type: "remote", url: mcpUrl } }, + ...(options?.chatBrainUrl + ? { + autoupdate: false, + share: "disabled", + model: "replay/replay-model", + permission: { "*": "allow" }, + provider: { + replay: { + name: "Replay", + npm: "@ai-sdk/openai-compatible", + options: { baseURL: options.chatBrainUrl, apiKey: "replay-key" }, + models: { "replay-model": { name: "Replay Model" } }, + }, + }, + } + : {}), }), ); // OpenCode launches the OAuth URL via `open`; the shim records it instead. diff --git a/e2e/src/clients/replay-brain.ts b/e2e/src/clients/replay-brain.ts new file mode 100644 index 000000000..2d5ac49bb --- /dev/null +++ b/e2e/src/clients/replay-brain.ts @@ -0,0 +1,208 @@ +// Replay brain: a local server speaking the OpenAI chat-completions wire +// (SSE streaming, tool calls) that a REAL agent (OpenCode) uses as its LLM. +// The scenario scripts the brain; the agent does everything else for real — +// TUI rendering, MCP discovery/auth, tool execution against the target, +// result round-trips. "Replay brain, real hands": deterministic conversation, +// zero modeled client behavior. +// +// The brain is a state machine, not a fixed turn list: each request gets a +// normalized view of the conversation so far and the scenario's `respond` +// callback decides what the "model" says next (text, a tool call, or both). +// That lets a script absorb variable-length detours — approval pauses that +// need a resume call, retries — without index bookkeeping. +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { Effect, Scope } from "effect"; + +/** What the scenario's script sees per request. */ +export interface BrainContext { + /** 0-based count of requests served before this one. */ + readonly requestIndex: number; + /** Role of the final message — "user" means a fresh human turn, "tool" + * means the agent is returning a tool result for the brain to continue. */ + readonly lastRole: string; + /** The latest user-role message content (the human's chat input). */ + readonly lastUser: string; + /** The latest tool-role message content (last tool result), if any. */ + readonly lastToolResult: string | undefined; + /** Tool names offered in this request (already namespaced by the agent). */ + readonly toolNames: ReadonlyArray; +} + +/** What the "model" does next. `tool.name` may be a suffix — the brain + * resolves it against the request's offered tool names, so scripts don't + * hardcode the agent's MCP namespacing. */ +export interface BrainResponse { + readonly text?: string; + readonly tool?: { readonly name: string; readonly args: unknown }; +} + +export interface ReplayBrain { + /** Base URL for the agent's provider config (…/v1). */ + readonly baseUrl: string; + /** Every request body served, in order (for post-hoc assertions). */ + readonly requests: () => ReadonlyArray; + /** Script errors (mismatched expectations, throws) — assert empty. */ + readonly errors: () => ReadonlyArray; +} + +export interface BrainRequest { + readonly messages: ReadonlyArray<{ role: string; content: string }>; + readonly toolNames: ReadonlyArray; +} + +interface WireMessage { + readonly role: string; + readonly content?: unknown; + readonly tool_calls?: ReadonlyArray; +} + +interface WireBody { + readonly messages?: ReadonlyArray; + readonly tools?: ReadonlyArray<{ function?: { name?: string } }>; +} + +const contentText = (content: unknown): string => { + if (typeof content === "string") return content; + if (Array.isArray(content)) { + return content + .map((part) => + typeof part === "object" && part !== null && "text" in part + ? String((part as { text: unknown }).text) + : "", + ) + .join(""); + } + return ""; +}; + +const sseChunk = (delta: Record, finish: string | null) => + `data: ${JSON.stringify({ + id: "chatcmpl-replay", + object: "chat.completion.chunk", + created: 0, + model: "replay-model", + choices: [{ index: 0, delta, finish_reason: finish }], + })}\n\n`; + +/** + * Serve a scripted brain for the lifetime of the surrounding scope. + * `respond` is called once per chat-completions request. + */ +export const serveReplayBrain = ( + respond: (ctx: BrainContext) => BrainResponse, +): Effect.Effect => + Effect.acquireRelease( + Effect.callback<{ server: Server; brain: ReplayBrain }>((resume) => { + const served: BrainRequest[] = []; + const errors: string[] = []; + + const server = createServer((request, response) => { + if (!request.url?.includes("/chat/completions")) { + response.writeHead(404).end(); + return; + } + let raw = ""; + request.on("data", (piece: Buffer) => (raw += piece.toString("utf8"))); + request.on("end", () => { + const body = JSON.parse(raw || "{}") as WireBody; + const messages = (body.messages ?? []).map((message) => ({ + role: message.role, + content: contentText(message.content), + })); + const toolNames = (body.tools ?? []) + .map((tool) => tool.function?.name ?? "") + .filter(Boolean); + const requestIndex = served.length; + served.push({ messages, toolNames }); + + const lastOf = (role: string) => + [...messages].reverse().find((message) => message.role === role)?.content; + + let scripted: BrainResponse; + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a throwing script must surface as a recorded error + a stop response, not a hung agent + try { + scripted = respond({ + requestIndex, + lastRole: messages.at(-1)?.role ?? "", + lastUser: lastOf("user") ?? "", + lastToolResult: lastOf("tool"), + toolNames, + }); + } catch (error) { + errors.push(`respond() threw on request ${requestIndex}: ${String(error)}`); + scripted = { text: "(replay brain script error)" }; + } + + // Resolve a scripted tool name against the agent's namespaced names. + let resolvedTool: { name: string; args: unknown } | undefined; + if (scripted.tool) { + const wanted = scripted.tool.name; + const match = + toolNames.find((name) => name === wanted) ?? + toolNames.find((name) => name.endsWith(wanted)); + if (match) { + resolvedTool = { name: match, args: scripted.tool.args }; + } else { + errors.push( + `request ${requestIndex}: no offered tool matches "${wanted}" (offered: ${toolNames.join(", ")})`, + ); + } + } + + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + response.write(sseChunk({ role: "assistant" }, null)); + if (scripted.text) { + // A few chunks so the TUI streams like a real model reply. + for (const piece of scripted.text.match(/.{1,24}/gs) ?? []) { + response.write(sseChunk({ content: piece }, null)); + } + } + if (resolvedTool) { + response.write( + sseChunk( + { + tool_calls: [ + { + index: 0, + id: `call_${requestIndex}`, + type: "function", + function: { + name: resolvedTool.name, + arguments: JSON.stringify(resolvedTool.args ?? {}), + }, + }, + ], + }, + null, + ), + ); + } + response.write(sseChunk({}, resolvedTool ? "tool_calls" : "stop")); + response.write("data: [DONE]\n\n"); + response.end(); + }); + }); + + server.listen(0, "127.0.0.1", () => { + const { port } = server.address() as AddressInfo; + resume( + Effect.succeed({ + server, + brain: { + baseUrl: `http://127.0.0.1:${port}/v1`, + requests: () => served, + errors: () => errors, + }, + }), + ); + }); + }), + (resource: { server: Server; brain: ReplayBrain }) => + Effect.sync(() => void resource.server.close()), + ).pipe(Effect.map(({ brain }) => brain)); diff --git a/e2e/src/scenario.ts b/e2e/src/scenario.ts index 64c7b7579..b135beecc 100644 --- a/e2e/src/scenario.ts +++ b/e2e/src/scenario.ts @@ -9,7 +9,8 @@ // recording layer. What survives per run is a small result.json (for the // scenario × target matrix) plus whatever artifacts the surfaces produced // (browser video/trace/screenshots, terminal casts). -import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -136,6 +137,29 @@ export const scenario = ( 1, ), ); + // A run with both recordings is ONE developer session — splice them + // into film.mp4 (scripts/film.ts cuts on the focus timeline) so the + // viewer plays a single recording, not parts. Best-effort: missing + // agg/ffmpeg or a film failure never fails the run; the parts stay + // and the viewer falls back to cast + video in story order. + if ( + exit._tag === "Success" && + existsSync(join(dir, "terminal.cast")) && + existsSync(join(dir, "session.mp4")) + ) { + yield* Effect.sync(() => { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: optional post-processing over external tooling (agg, ffmpeg) + try { + execFileSync( + "bun", + [fileURLToPath(new URL("../scripts/film.ts", import.meta.url)), dir], + { stdio: "pipe", timeout: 120_000 }, + ); + } catch { + // parts remain the artifacts + } + }); + } buildManifest(RUNS_DIR); if (exit._tag === "Failure") { return yield* Effect.failCause(exit.cause); diff --git a/e2e/src/surfaces/browser.ts b/e2e/src/surfaces/browser.ts index c0d250283..7dd4c6f12 100644 --- a/e2e/src/surfaces/browser.ts +++ b/e2e/src/surfaces/browser.ts @@ -11,6 +11,7 @@ import { promisify } from "node:util"; import { Effect } from "effect"; import { chromium, type Page } from "playwright"; +import { markFocus, markRecordingStart } from "../timeline"; import type { Identity, Target } from "../target"; export interface BrowserSession { @@ -42,7 +43,17 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface const videoTmp = join(dir, ".video-tmp"); mkdirSync(videoTmp, { recursive: true }); - const browser = await chromium.launch(); + // On the desk (E2E_DESK), the browser is a real headed window on the + // virtual display — the desk's single screen recording films it next + // to the chat terminal, exactly like a developer tabbing over. + const browser = await chromium.launch( + process.env.E2E_DESK === "1" + ? { + headless: false, + args: ["--window-position=300,40", "--window-size=1100,830"], + } + : {}, + ); const context = await browser.newContext({ colorScheme: "dark", viewport: { width: 1280, height: 800 }, @@ -56,11 +67,16 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface ); } const page = await context.newPage(); + // The session video's clock starts with the page; anchor it for the + // run's focus timeline (scripts/film.ts cuts on these). + markRecordingStart(dir, "browser"); return { browser, context, page, videoTmp, shots: { count: 0 } }; }), ({ page, context, shots }) => Effect.promise(async () => { const step = async (label: string, action: (page: Page) => Promise) => { + // Acting on the page IS focusing the browser window. + markFocus(dir, "browser"); await context.tracing.group(label); try { await action(page); diff --git a/e2e/src/timeline.ts b/e2e/src/timeline.ts new file mode 100644 index 000000000..4de414fed --- /dev/null +++ b/e2e/src/timeline.ts @@ -0,0 +1,47 @@ +// The run's focus timeline: which window the scenario was acting on, when. +// +// Focus is DERIVED, never declared — driving a Playwright page focuses the +// browser window; pushing a chat/terminal event focuses the terminal. The +// surfaces call markFocus as a side effect of normal operations, so any +// scenario gets a faithful "where was the developer looking" track for +// free, and scripts/film.ts can cut the session recordings exactly where +// the action moved. Anchors map wall-clock to each recording's own clock. +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export type TimelineWindow = "terminal" | "browser"; + +export interface Timeline { + /** Wall-clock ms when each recording's clock started. */ + readonly anchors: { terminal?: number; browser?: number }; + /** Focus transitions (first event per contiguous run of a window). */ + readonly focus: Array<{ at: number; window: TimelineWindow }>; +} + +const fileFor = (runDir: string) => join(runDir, "timeline.json"); + +const read = (runDir: string): Timeline => { + const file = fileFor(runDir); + if (!existsSync(file)) return { anchors: {}, focus: [] }; + return JSON.parse(readFileSync(file, "utf8")) as Timeline; +}; + +const write = (runDir: string, timeline: Timeline) => + writeFileSync(fileFor(runDir), JSON.stringify(timeline, null, 1)); + +/** Record that `window`'s recording clock starts now. */ +export const markRecordingStart = (runDir: string, window: TimelineWindow): void => { + const timeline = read(runDir); + write(runDir, { ...timeline, anchors: { ...timeline.anchors, [window]: Date.now() } }); +}; + +/** Record that the scenario is acting on `window` (deduped per run). */ +export const markFocus = (runDir: string, window: TimelineWindow): void => { + const timeline = read(runDir); + if (timeline.focus.at(-1)?.window === window) return; + timeline.focus.push({ at: Date.now(), window }); + write(runDir, timeline); +}; + +export const readTimeline = (runDir: string): Timeline | null => + existsSync(fileFor(runDir)) ? read(runDir) : null; diff --git a/e2e/viewer/src/App.tsx b/e2e/viewer/src/App.tsx index c36272626..ef6e940de 100644 --- a/e2e/viewer/src/App.tsx +++ b/e2e/viewer/src/App.tsx @@ -155,8 +155,13 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { const has = (name: string) => result.artifacts.includes(name); const screenshots = result.artifacts.filter((a) => a.endsWith(".png")).sort(); - const video = has("session.mp4") ? "session.mp4" : has("session.webm") ? "session.webm" : null; - const cast = has("terminal.cast") ? "terminal.cast" : null; + // film.mp4 (scripts/film.ts) is the whole session as one video — terminal, + // browser hop, terminal, cut like tabbing between full-screen windows. + // When present it IS the session; the parts stay on disk for debugging. + const film = has("film.mp4") ? "film.mp4" : null; + const video = + film ?? (has("session.mp4") ? "session.mp4" : has("session.webm") ? "session.webm" : null); + const cast = film ? null : has("terminal.cast") ? "terminal.cast" : null; const media = video ?? cast; const traceUrl = has("trace.zip") ? `https://trace.playwright.dev/?trace=${encodeURIComponent( @@ -193,7 +198,7 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { className={tab === "media" ? "tab active" : "tab"} onClick={() => setTab("media")} > - ▶ {video ? "video" : "terminal"} + ▶ {cast && video ? "session" : video ? "video" : "terminal"}