Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions e2e/src/recording-url-bar.ts
Original file line number Diff line number Diff line change
@@ -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";
Comment on lines +64 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The url span is missing flex:1;min-width:0, which are required for text-overflow:ellipsis to fire on a flex item. Without min-width:0, a flex item's minimum size defaults to min-content (the full un-broken text width), so the item never shrinks below the URL's length. The bar's own overflow:hidden still clips the text, but clipping is silent — you get a hard cut instead of the trailing that signals truncation. Any URL longer than the available space after the traffic lights and lock icon will be silently clipped rather than ellipsis-terminated.

Suggested change
const url = document.createElement("span");
url.style.cssText = "overflow:hidden;text-overflow:ellipsis";
const url = document.createElement("span");
url.style.cssText = "flex:1;min-width:0;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<void> => {
if (process.env.E2E_DESK === "1") return;
await context.addInitScript(injectUrlBar);
};
4 changes: 4 additions & 0 deletions e2e/src/surfaces/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => ({
Expand Down
Loading