From c6b20b8820bfc05f178bc5d9b4a7a9650319ee38 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:33:05 -0700 Subject: [PATCH] e2e: show a URL bar in browser session recordings Playwright records the page viewport only, so the recording is chromeless and a shared session.mp4 gives no hint of which page each moment was on. The runs viewer reconstructs a URL bar from the nav timeline, but the raw video that people actually pass around had none. Inject a synthetic URL bar at the top of every top-level page so it shows up in the video and the step screenshots, fed by location.href and updated across SPA route changes. It renders inside a closed shadow root (invisible to Playwright locators and the accessibility tree) and is pointer-events none, so it never perturbs a scenario. Skipped under E2E_DESK, where the headed browser already shows real chrome. --- e2e/src/recording-url-bar.ts | 108 +++++++++++++++++++++++++++++++++++ e2e/src/surfaces/browser.ts | 4 ++ 2 files changed, 112 insertions(+) create mode 100644 e2e/src/recording-url-bar.ts diff --git a/e2e/src/recording-url-bar.ts b/e2e/src/recording-url-bar.ts new file mode 100644 index 000000000..9fcc9306d --- /dev/null +++ b/e2e/src/recording-url-bar.ts @@ -0,0 +1,108 @@ +// A synthetic browser URL bar, baked into the session recording itself. +// +// Playwright records the page viewport only (the recording is chromeless), so +// a shared `session.mp4` gives no hint of which URL each moment was on. The +// runs viewer reconstructs a URL bar from the nav timeline, but the raw video +// (the thing people actually pass around) had none. This injects a thin URL bar +// at the top of every top-level page so it shows up in the video AND the step +// screenshots, fed by `location.href` and updated across SPA route changes. +// +// It must not perturb the scenario: it renders inside a CLOSED shadow root +// (invisible to Playwright locators and the accessibility tree) and is +// `pointer-events: none` (never intercepts a click). The styling mirrors the +// viewer's synthetic chrome (traffic lights, #161b22 bar) so the in-viewer and +// standalone-video looks agree. +import type { BrowserContext } from "playwright"; + +/** Runs in the page before any app script, on every top-level document. */ +function injectUrlBar(): void { + // Top frame only (iframes should not each grow their own bar). + if (window.top !== window.self) return; + const flagged = window as Window & { __e2eUrlBar?: boolean }; + if (flagged.__e2eUrlBar) return; + flagged.__e2eUrlBar = true; + + const BAR_H = 32; + + const install = (): void => { + const root = document.documentElement; + if (!root) return; + + const host = document.createElement("div"); + host.style.cssText = `position:fixed;top:0;left:0;width:100%;height:${BAR_H}px;z-index:2147483647;pointer-events:none`; + const shadow = host.attachShadow({ mode: "closed" }); + + const bar = document.createElement("div"); + bar.style.cssText = [ + "display:flex", + "align-items:center", + "gap:8px", + `height:${BAR_H}px`, + "box-sizing:border-box", + "padding:0 12px", + "background:#161b22", + "border-bottom:1px solid #21262d", + "font:13px/1 ui-monospace,SFMono-Regular,Menlo,monospace", + "color:#c9d1d9", + "white-space:nowrap", + "overflow:hidden", + ].join(";"); + + const dot = (color: string): HTMLElement => { + const d = document.createElement("span"); + d.style.cssText = `width:11px;height:11px;border-radius:50%;flex:none;background:${color}`; + return d; + }; + const lights = document.createElement("span"); + lights.style.cssText = "display:inline-flex;gap:6px;margin-right:4px"; + lights.append(dot("#ff5f57"), dot("#febc2e"), dot("#28c840")); + + const lock = document.createElement("span"); + lock.textContent = "⌁"; // the viewer's URL-bar glyph + lock.style.cssText = "color:#8b949e;flex:none"; + + const url = document.createElement("span"); + url.style.cssText = "overflow:hidden;text-overflow:ellipsis"; + + bar.append(lights, lock, url); + shadow.append(bar); + root.appendChild(host); + + const render = (): void => { + const next = location.href.replace(/^https?:\/\//, "") || "about:blank"; + if (url.textContent !== next) url.textContent = next; + }; + render(); + + // SPA route changes don't reload the document, so re-read the URL on every + // history transition; the interval also re-attaches the bar if a framework + // re-render detached it, and is the catch-all for navigations we can't hook. + window.setInterval(() => { + if (!root.contains(host)) root.appendChild(host); + render(); + }, 250); + for (const ev of ["popstate", "hashchange"] as const) window.addEventListener(ev, render); + for (const name of ["pushState", "replaceState"] as const) { + const orig = history[name] as (...args: unknown[]) => unknown; + if (typeof orig === "function") { + history[name] = function (this: History, ...args: unknown[]) { + const result = orig.apply(this, args); + render(); + return result; + } as History[typeof name]; + } + } + }; + + if (document.documentElement) install(); + else window.addEventListener("DOMContentLoaded", install, { once: true }); +} + +/** + * Install the recording URL bar on a Playwright context. No-op on the desk + * (E2E_DESK), where the browser is headed and already shows real chrome. + */ +export const installRecordingUrlBar = async (context: BrowserContext): Promise => { + if (process.env.E2E_DESK === "1") return; + await context.addInitScript(injectUrlBar); +}; diff --git a/e2e/src/surfaces/browser.ts b/e2e/src/surfaces/browser.ts index 2cc24903e..c68733310 100644 --- a/e2e/src/surfaces/browser.ts +++ b/e2e/src/surfaces/browser.ts @@ -13,6 +13,7 @@ import { chromium, type Page } 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 { @@ -74,6 +75,9 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface 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) => ({