329 lines · 13.7 KB · TypeScript
RawDownload
// 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 });        }),    ),});