|
| 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 | +}; |
0 commit comments