// 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 Locator, type Page, type Response } from "playwright"; import { beat, enterFocus, markNavigation, markRecordingStart } from "../timeline";import { appendTraces, type TraceEntry } from "../trace-harvest";import { installRecordingUrlBar } from "../recording-url-bar";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<void>) => Promise<void>;} export interface BrowserSurface { readonly session: ( identity: Identity, drive: (session: BrowserSession) => Promise<void>, ) => Effect.Effect<void>;} const slug = (text: string): string => text .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 60); // How long a navigation may wait for the network to go quiet before giving up// on quiet and letting the scenario's own assertion decide. Long enough to// cover a cold vite compile's request burst, short enough that a page which// never goes quiet costs seconds instead of the 30s navigation timeout.const SETTLE_TIMEOUT_MS = 5_000; /** * Wait for the network to go quiet, but only for `SETTLE_TIMEOUT_MS`. * * Playwright's `networkidle` — 500ms with zero in-flight requests — cannot be * a hard gate in this suite, because the suite itself keeps the network busy: * every browser session exports OTel spans to the run's motel through * packages/react's `OtlpTracer` on a one-second interval (wired by * `setup/motel.ts` via VITE_PUBLIC_OTLP_TRACES_URL). A page that keeps * producing spans — a console retrying an org-scoped query that 403s, say — * feeds that exporter indefinitely, so the 500ms window never opens and the * wait burns its full timeout on a page that is, visibly, completely loaded. * * The quiet is worth waiting for when it comes (it usually arrives in * milliseconds, and it lets a step's screenshot catch a settled page), so keep * it — bounded. Readiness is asserted by whatever the scenario does next: * Playwright's locators auto-wait, so `getByRole(...).waitFor()` is a real, * page-specific readiness signal where `networkidle` was only ever a proxy. */export const settle = async (page: Page): Promise<void> => { await page .waitForLoadState("networkidle", { timeout: SETTLE_TIMEOUT_MS }) // oxlint-disable-next-line executor/no-promise-catch -- boundary: quiet is best-effort, the scenario's next assertion is the gate .catch(() => {});}; /** * Wait until React owns the document — the readiness `networkidle` was standing * in for, asked directly. * * React DOM stamps `__reactContainer$<id>` on the element it roots at the * moment `hydrateRoot` runs, so its presence says the SSR markup is under a * live root rather than inert HTML. That matters because it is the line either * side of which an interaction has a different meaning: before it, a click or * a `fill` goes to markup nobody is listening to and is lost without a trace; * after it, React records the event and replays it as hydration reaches that * subtree. Locators can't see the difference — the element is visible and * enabled either way — which is why a swallowed interaction surfaces 20 * seconds later as "the thing it should have opened never appeared". * * Best-effort, like `settle`: plenty of pages the suite visits are not this * app at all (a provider's authorize screen, an OAuth callback result), and * they are none the worse for having no React root. */const HYDRATION_TIMEOUT_MS = 10_000; export const hydrated = async (page: Page): Promise<void> => { await page .waitForFunction( () => [...document.body.children].some((element) => Object.keys(element).some((key) => key.startsWith("__reactContainer$")), ), undefined, { timeout: HYDRATION_TIMEOUT_MS }, ) // oxlint-disable-next-line executor/no-promise-catch -- boundary: a page with no React root is a legitimate destination .catch(() => {});}; /** * Navigate, then wait for the page to be interactive. The drop-in for a `goto` * that waited on `networkidle`, and a better one: it waits for React to take * the document (`hydrated`) and for the network to go quiet (`settle`), both * bounded, instead of one unbounded wait that proved neither. `timeout` bounds * the navigation itself, exactly as `goto`'s does. */export const visit = async ( page: Page, url: string, options: { readonly timeout?: number } = {},): Promise<Response | null> => { const response = await page.goto(url, { waitUntil: "load", ...options }); await hydrated(page); await settle(page); return response;}; /** `visit` for a reload — the drop-in for `reload({ waitUntil: "networkidle" })`. */export const revisit = async ( page: Page, options: { readonly timeout?: number } = {},): Promise<void> => { await page.reload({ waitUntil: "load", ...options }); await hydrated(page); await settle(page);}; /** * Click `trigger` until `revealed` is visible. * * A settled network does not mean the console has hydrated: a click * that lands between the SSR paint and React attaching the handler is * swallowed without a trace, and whatever the click was meant to open never * appears (the "Connect an integration" dialog no-show flake). Re-clicking a * reveal-style trigger is idempotent, so retry until the result is actually * on screen; the final attempt waits with the full timeout so the failure * surfaces as the ordinary locator error. */export const clickToReveal = async ( trigger: Locator, revealed: Locator, { attempts = 5, revealTimeoutMs = 4_000 }: { attempts?: number; revealTimeoutMs?: number } = {},): Promise<void> => { for (let attempt = 1; attempt < attempts; attempt++) { await trigger.click(); const shown = await revealed .waitFor({ timeout: revealTimeoutMs }) .then(() => true) // oxlint-disable-next-line executor/no-promise-catch -- retry boundary: a missed reveal is the signal to click again, not a failure .catch(() => false); if (shown) return; } await trigger.click(); await revealed.waitFor({ timeout: revealTimeoutMs });}; // 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, }); // Bake a synthetic URL bar into the recording so a shared session.mp4 // (and the step screenshots) shows which page each moment was on. await installRecordingUrlBar(context); 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<TraceEntry & { ms?: number; status?: number }> = []; const inflight = new Map<unknown, (typeof traceIds)[number]>(); 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<void>) => { // 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 }); }), ),});
// 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 Locator, type Page, type Response } from "playwright";
import { beat, enterFocus, markNavigation, markRecordingStart } from "../timeline";
import { appendTraces, type TraceEntry } from "../trace-harvest";
import { installRecordingUrlBar } from "../recording-url-bar";
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<void>) => Promise<void>;
}
export interface BrowserSurface {
readonly session: (
identity: Identity,
drive: (session: BrowserSession) => Promise<void>,
) => Effect.Effect<void>;
}
const slug = (text: string): string =>
text
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 60);
// How long a navigation may wait for the network to go quiet before giving up
// on quiet and letting the scenario's own assertion decide. Long enough to
// cover a cold vite compile's request burst, short enough that a page which
// never goes quiet costs seconds instead of the 30s navigation timeout.
const SETTLE_TIMEOUT_MS = 5_000;
/**
* Wait for the network to go quiet, but only for `SETTLE_TIMEOUT_MS`.
*
* Playwright's `networkidle` — 500ms with zero in-flight requests — cannot be
* a hard gate in this suite, because the suite itself keeps the network busy:
* every browser session exports OTel spans to the run's motel through
* packages/react's `OtlpTracer` on a one-second interval (wired by
* `setup/motel.ts` via VITE_PUBLIC_OTLP_TRACES_URL). A page that keeps
* producing spans — a console retrying an org-scoped query that 403s, say —
* feeds that exporter indefinitely, so the 500ms window never opens and the
* wait burns its full timeout on a page that is, visibly, completely loaded.
*
* The quiet is worth waiting for when it comes (it usually arrives in
* milliseconds, and it lets a step's screenshot catch a settled page), so keep
* it — bounded. Readiness is asserted by whatever the scenario does next:
* Playwright's locators auto-wait, so `getByRole(...).waitFor()` is a real,
* page-specific readiness signal where `networkidle` was only ever a proxy.
*/
export const settle = async (page: Page): Promise<void> => {
await page
.waitForLoadState("networkidle", { timeout: SETTLE_TIMEOUT_MS })
// oxlint-disable-next-line executor/no-promise-catch -- boundary: quiet is best-effort, the scenario's next assertion is the gate
.catch(() => {});
};
/**
* Wait until React owns the document — the readiness `networkidle` was standing
* in for, asked directly.
*
* React DOM stamps `__reactContainer$<id>` on the element it roots at the
* moment `hydrateRoot` runs, so its presence says the SSR markup is under a
* live root rather than inert HTML. That matters because it is the line either
* side of which an interaction has a different meaning: before it, a click or
* a `fill` goes to markup nobody is listening to and is lost without a trace;
* after it, React records the event and replays it as hydration reaches that
* subtree. Locators can't see the difference — the element is visible and
* enabled either way — which is why a swallowed interaction surfaces 20
* seconds later as "the thing it should have opened never appeared".
*
* Best-effort, like `settle`: plenty of pages the suite visits are not this
* app at all (a provider's authorize screen, an OAuth callback result), and
* they are none the worse for having no React root.
*/
const HYDRATION_TIMEOUT_MS = 10_000;
export const hydrated = async (page: Page): Promise<void> => {
await page
.waitForFunction(
() =>
[...document.body.children].some((element) =>
Object.keys(element).some((key) => key.startsWith("__reactContainer$")),
),
undefined,
{ timeout: HYDRATION_TIMEOUT_MS },
)
// oxlint-disable-next-line executor/no-promise-catch -- boundary: a page with no React root is a legitimate destination
.catch(() => {});
};
/**
* Navigate, then wait for the page to be interactive. The drop-in for a `goto`
* that waited on `networkidle`, and a better one: it waits for React to take
* the document (`hydrated`) and for the network to go quiet (`settle`), both
* bounded, instead of one unbounded wait that proved neither. `timeout` bounds
* the navigation itself, exactly as `goto`'s does.
*/
export const visit = async (
page: Page,
url: string,
options: { readonly timeout?: number } = {},
): Promise<Response | null> => {
const response = await page.goto(url, { waitUntil: "load", ...options });
await hydrated(page);
await settle(page);
return response;
};
/** `visit` for a reload — the drop-in for `reload({ waitUntil: "networkidle" })`. */
export const revisit = async (
page: Page,
options: { readonly timeout?: number } = {},
): Promise<void> => {
await page.reload({ waitUntil: "load", ...options });
await hydrated(page);
await settle(page);
};
/**
* Click `trigger` until `revealed` is visible.
*
* A settled network does not mean the console has hydrated: a click
* that lands between the SSR paint and React attaching the handler is
* swallowed without a trace, and whatever the click was meant to open never
* appears (the "Connect an integration" dialog no-show flake). Re-clicking a
* reveal-style trigger is idempotent, so retry until the result is actually
* on screen; the final attempt waits with the full timeout so the failure
* surfaces as the ordinary locator error.
*/
export const clickToReveal = async (
trigger: Locator,
revealed: Locator,
{ attempts = 5, revealTimeoutMs = 4_000 }: { attempts?: number; revealTimeoutMs?: number } = {},
): Promise<void> => {
for (let attempt = 1; attempt < attempts; attempt++) {
await trigger.click();
const shown = await revealed
.waitFor({ timeout: revealTimeoutMs })
.then(() => true)
// oxlint-disable-next-line executor/no-promise-catch -- retry boundary: a missed reveal is the signal to click again, not a failure
.catch(() => false);
if (shown) return;
}
await trigger.click();
await revealed.waitFor({ timeout: revealTimeoutMs });
};
// 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,
});
// Bake a synthetic URL bar into the recording so a shared session.mp4
// (and the step screenshots) shows which page each moment was on.
await installRecordingUrlBar(context);
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<TraceEntry & { ms?: number; status?: number }> = [];
const inflight = new Map<unknown, (typeof traceIds)[number]>();
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<void>) => {
// 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 });
}),
),
});