// Browser surface: Playwright over the target's real web UI, dark mode, with // the standard debugging artifacts — a Playwright trace (time-travel DOM, // network, console), the session video (transcoded to mp4 so it plays // everywhere), per-step screenshots, and a failure screenshot. The scenario // drives `page` directly; assertions are vitest's job. import { execFile } from "node:child_process"; import { copyFileSync, mkdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { promisify } from "node:util"; import { Effect } from "effect"; import { chromium, type Page } from "playwright"; import { beat, enterFocus, markNavigation, markRecordingStart } from "../timeline"; import { appendTraces, type TraceEntry } from "../trace-harvest"; import type { Identity, Target } from "../target"; export interface BrowserSession { readonly page: Page; /** Perform one user-visible step; names the trace group + saves a screenshot. */ readonly step: (label: string, action: (page: Page) => Promise) => Promise; } export interface BrowserSurface { readonly session: ( identity: Identity, drive: (session: BrowserSession) => Promise, ) => Effect.Effect; } const slug = (text: string): string => text .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 60); // acquireUseRelease so a vitest timeout (fiber interruption) still closes the // browser and flushes video + trace — a bare promise would leak Chromium. export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface => ({ session: (identity, drive) => Effect.acquireUseRelease( Effect.promise(async () => { const videoTmp = join(dir, ".video-tmp"); mkdirSync(videoTmp, { recursive: true }); // Watchable mode (E2E_FILM / E2E_DESK): slow each Playwright action so // the recording is readable instead of flickering through states at // machine speed. Off by default — normal CI runs stay fast. const watchable = process.env.E2E_FILM === "1" || process.env.E2E_DESK === "1"; const slowMo = watchable ? 400 : undefined; // 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"], slowMo, } : slowMo ? { slowMo } : {}, ); const context = await browser.newContext({ colorScheme: "dark", viewport: { width: 1280, height: 800 }, recordVideo: { dir: videoTmp, size: { width: 1280, height: 800 } }, baseURL: target.baseUrl, }); await context.tracing.start({ screenshots: true, snapshots: true, sources: true, }); if (identity.cookies?.length) { await context.addCookies( identity.cookies.map((cookie) => ({ ...cookie, url: target.baseUrl, })), ); } 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"); // Main-frame navigations feed the viewer's synthetic URL bar — the // recording itself is chromeless, so this is the only place the // address the developer "typed" survives. page.on("framenavigated", (frame) => { if (frame === page.mainFrame()) markNavigation(dir, frame.url()); }); // Harvest distributed-trace ids: every app API request carries a W3C // traceparent (Effect's HttpClient), and each id names one // click→server→DB trace in whatever OTLP store the run exported to // (motel locally). Appended to traces.json (shared with the MCP // surface's terminal-side entries) so the runs viewer can link a // recording to its traces. Duration comes from the finished/failed // event so the viewer can answer "why did that take so long" // without leaving the run page. const traceIds: Array = []; const inflight = new Map(); page.on("request", (request) => { const traceparent = request.headers()["traceparent"]; const match = traceparent ? /^[0-9a-f]{2}-([0-9a-f]{32})-/.exec(traceparent) : null; if (match?.[1]) { const entry: (typeof traceIds)[number] = { id: match[1], at: Date.now(), url: request.url(), source: "browser", }; traceIds.push(entry); inflight.set(request, entry); } }); page.on("requestfinished", async (request) => { const entry = inflight.get(request); if (!entry) return; inflight.delete(request); entry.ms = Date.now() - entry.at; entry.status = (await request.response().catch(() => null))?.status(); }); page.on("requestfailed", (request) => { const entry = inflight.get(request); if (!entry) return; inflight.delete(request); entry.ms = Date.now() - entry.at; }); return { browser, context, page, videoTmp, shots: { count: 0 }, traceIds, }; }), ({ page, context, shots }) => Effect.promise(async () => { const step = async (label: string, action: (page: Page) => Promise) => { // Acting on the page IS focusing the browser window — and when // filming, enterFocus lingers a beat on whatever the developer was // looking at before tabbing here. await enterFocus(dir, "browser"); await context.tracing.group(label); try { await action(page); } finally { await context.tracing.groupEnd(); } await page.screenshot({ path: join(dir, `${String(shots.count++).padStart(2, "0")}-${slug(label)}.png`), }); // Hold this step's result on screen so the film is readable (the // consent screen, the success page, …). No-op unless filming. await beat(); }; try { await drive({ page, step }); } catch (error) { // Freeze the scene: the artifact dir shows the screen at failure. await page.screenshot({ path: join(dir, "failure.png") }).catch(() => {}); throw error; } }), ({ browser, context, page, videoTmp, traceIds }) => Effect.promise(async () => { appendTraces(dir, traceIds); await context.tracing.stop({ path: join(dir, "trace.zip") }).catch(() => {}); const video = page.video(); await context.close(); // flushes the recording await browser.close(); const recordedPath = await video?.path().catch(() => undefined); if (recordedPath) { try { // mp4 plays everywhere (Safari/iOS don't do webm). await promisify(execFile)("ffmpeg", [ "-y", "-i", recordedPath, "-c:v", "libx264", "-preset", "veryfast", "-crf", "26", "-pix_fmt", "yuv420p", "-movflags", "+faststart", join(dir, "session.mp4"), ]); } catch { copyFileSync(recordedPath, join(dir, "session.webm")); } } rmSync(videoTmp, { recursive: true, force: true }); }), ), });