Skip to content

Commit c6b20b8

Browse files
committed
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.
1 parent 529b908 commit c6b20b8

2 files changed

Lines changed: 112 additions & 0 deletions

File tree

e2e/src/recording-url-bar.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
// A synthetic browser URL bar, baked into the session recording itself.
2+
//
3+
// Playwright records the page viewport only (the recording is chromeless), so
4+
// a shared `session.mp4` gives no hint of which URL each moment was on. The
5+
// runs viewer reconstructs a URL bar from the nav timeline, but the raw video
6+
// (the thing people actually pass around) had none. This injects a thin URL bar
7+
// at the top of every top-level page so it shows up in the video AND the step
8+
// screenshots, fed by `location.href` and updated across SPA route changes.
9+
//
10+
// It must not perturb the scenario: it renders inside a CLOSED shadow root
11+
// (invisible to Playwright locators and the accessibility tree) and is
12+
// `pointer-events: none` (never intercepts a click). The styling mirrors the
13+
// viewer's synthetic chrome (traffic lights, #161b22 bar) so the in-viewer and
14+
// standalone-video looks agree.
15+
import type { BrowserContext } from "playwright";
16+
17+
/** Runs in the page before any app script, on every top-level document. */
18+
function injectUrlBar(): void {
19+
// Top frame only (iframes should not each grow their own bar).
20+
if (window.top !== window.self) return;
21+
const flagged = window as Window & { __e2eUrlBar?: boolean };
22+
if (flagged.__e2eUrlBar) return;
23+
flagged.__e2eUrlBar = true;
24+
25+
const BAR_H = 32;
26+
27+
const install = (): void => {
28+
const root = document.documentElement;
29+
if (!root) return;
30+
31+
const host = document.createElement("div");
32+
host.style.cssText = `position:fixed;top:0;left:0;width:100%;height:${BAR_H}px;z-index:2147483647;pointer-events:none`;
33+
const shadow = host.attachShadow({ mode: "closed" });
34+
35+
const bar = document.createElement("div");
36+
bar.style.cssText = [
37+
"display:flex",
38+
"align-items:center",
39+
"gap:8px",
40+
`height:${BAR_H}px`,
41+
"box-sizing:border-box",
42+
"padding:0 12px",
43+
"background:#161b22",
44+
"border-bottom:1px solid #21262d",
45+
"font:13px/1 ui-monospace,SFMono-Regular,Menlo,monospace",
46+
"color:#c9d1d9",
47+
"white-space:nowrap",
48+
"overflow:hidden",
49+
].join(";");
50+
51+
const dot = (color: string): HTMLElement => {
52+
const d = document.createElement("span");
53+
d.style.cssText = `width:11px;height:11px;border-radius:50%;flex:none;background:${color}`;
54+
return d;
55+
};
56+
const lights = document.createElement("span");
57+
lights.style.cssText = "display:inline-flex;gap:6px;margin-right:4px";
58+
lights.append(dot("#ff5f57"), dot("#febc2e"), dot("#28c840"));
59+
60+
const lock = document.createElement("span");
61+
lock.textContent = "⌁"; // the viewer's URL-bar glyph
62+
lock.style.cssText = "color:#8b949e;flex:none";
63+
64+
const url = document.createElement("span");
65+
url.style.cssText = "overflow:hidden;text-overflow:ellipsis";
66+
67+
bar.append(lights, lock, url);
68+
shadow.append(bar);
69+
root.appendChild(host);
70+
71+
const render = (): void => {
72+
const next = location.href.replace(/^https?:\/\//, "") || "about:blank";
73+
if (url.textContent !== next) url.textContent = next;
74+
};
75+
render();
76+
77+
// SPA route changes don't reload the document, so re-read the URL on every
78+
// history transition; the interval also re-attaches the bar if a framework
79+
// re-render detached it, and is the catch-all for navigations we can't hook.
80+
window.setInterval(() => {
81+
if (!root.contains(host)) root.appendChild(host);
82+
render();
83+
}, 250);
84+
for (const ev of ["popstate", "hashchange"] as const) window.addEventListener(ev, render);
85+
for (const name of ["pushState", "replaceState"] as const) {
86+
const orig = history[name] as (...args: unknown[]) => unknown;
87+
if (typeof orig === "function") {
88+
history[name] = function (this: History, ...args: unknown[]) {
89+
const result = orig.apply(this, args);
90+
render();
91+
return result;
92+
} as History[typeof name];
93+
}
94+
}
95+
};
96+
97+
if (document.documentElement) install();
98+
else window.addEventListener("DOMContentLoaded", install, { once: true });
99+
}
100+
101+
/**
102+
* Install the recording URL bar on a Playwright context. No-op on the desk
103+
* (E2E_DESK), where the browser is headed and already shows real chrome.
104+
*/
105+
export const installRecordingUrlBar = async (context: BrowserContext): Promise<void> => {
106+
if (process.env.E2E_DESK === "1") return;
107+
await context.addInitScript(injectUrlBar);
108+
};

e2e/src/surfaces/browser.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { chromium, type Page } from "playwright";
1313

1414
import { beat, enterFocus, markNavigation, markRecordingStart } from "../timeline";
1515
import { appendTraces, type TraceEntry } from "../trace-harvest";
16+
import { installRecordingUrlBar } from "../recording-url-bar";
1617
import type { Identity, Target } from "../target";
1718

1819
export interface BrowserSession {
@@ -74,6 +75,9 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface
7475
snapshots: true,
7576
sources: true,
7677
});
78+
// Bake a synthetic URL bar into the recording so a shared session.mp4
79+
// (and the step screenshots) shows which page each moment was on.
80+
await installRecordingUrlBar(context);
7781
if (identity.cookies?.length) {
7882
await context.addCookies(
7983
identity.cookies.map((cookie) => ({

0 commit comments

Comments
 (0)