From 5458da1beee3859aa0af638a38535e8ac283e071 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Fri, 12 Jun 2026 00:10:19 -0700 Subject: [PATCH 1/2] Viewer plays the session live: synced recordings, URL bar, trace lane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The run page's session tab now performs the cuts the film bakes into pixels: one playback head drives both real recordings (asciinema cast + browser video), the focus timeline decides which window is on screen, and a synthetic window chrome floats above the stage — a terminal title bar, or a browser URL bar fed by the timeline's new nav track (main- frame navigations captured by the browser surface) with a deep link into the Playwright trace. The scrubber shows the acts (terminal/browser segments) and a tick per distributed trace; the trace table is timeline-aligned — click a row's timestamp to jump the player there, the row highlights as playback passes it, and the trace id still opens motel's waterfall. Tabs split the lenses: session / browser / terminal / source. film.mp4 remains the fallback (and the PR-media artifact) for runs without an anchored timeline. --- e2e/src/surfaces/browser.ts | 8 +- e2e/src/timeline.ts | 20 +- e2e/viewer/src/App.tsx | 147 ++++++---- e2e/viewer/src/SessionPlayer.tsx | 470 +++++++++++++++++++++++++++++++ e2e/viewer/src/styles.css | 330 ++++++++++++++++++++++ e2e/viewer/src/vite-env.d.ts | 10 +- 6 files changed, 934 insertions(+), 51 deletions(-) create mode 100644 e2e/viewer/src/SessionPlayer.tsx diff --git a/e2e/src/surfaces/browser.ts b/e2e/src/surfaces/browser.ts index c11268f8d..a7c82744b 100644 --- a/e2e/src/surfaces/browser.ts +++ b/e2e/src/surfaces/browser.ts @@ -11,7 +11,7 @@ import { promisify } from "node:util"; import { Effect } from "effect"; import { chromium, type Page } from "playwright"; -import { markFocus, markRecordingStart } from "../timeline"; +import { markFocus, markNavigation, markRecordingStart } from "../timeline"; import { appendTraces, type TraceEntry } from "../trace-harvest"; import type { Identity, Target } from "../target"; @@ -78,6 +78,12 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface // 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 diff --git a/e2e/src/timeline.ts b/e2e/src/timeline.ts index 4de414fed..9f726551e 100644 --- a/e2e/src/timeline.ts +++ b/e2e/src/timeline.ts @@ -16,6 +16,8 @@ export interface Timeline { readonly anchors: { terminal?: number; browser?: number }; /** Focus transitions (first event per contiguous run of a window). */ readonly focus: Array<{ at: number; window: TimelineWindow }>; + /** Main-frame navigations — lets the viewer render a live URL bar. */ + readonly nav?: Array<{ at: number; url: string }>; } const fileFor = (runDir: string) => join(runDir, "timeline.json"); @@ -30,9 +32,15 @@ const write = (runDir: string, timeline: Timeline) => writeFileSync(fileFor(runDir), JSON.stringify(timeline, null, 1)); /** Record that `window`'s recording clock starts now. */ -export const markRecordingStart = (runDir: string, window: TimelineWindow): void => { +export const markRecordingStart = ( + runDir: string, + window: TimelineWindow, +): void => { const timeline = read(runDir); - write(runDir, { ...timeline, anchors: { ...timeline.anchors, [window]: Date.now() } }); + write(runDir, { + ...timeline, + anchors: { ...timeline.anchors, [window]: Date.now() }, + }); }; /** Record that the scenario is acting on `window` (deduped per run). */ @@ -43,5 +51,13 @@ export const markFocus = (runDir: string, window: TimelineWindow): void => { write(runDir, timeline); }; +/** Record a main-frame navigation (deduped against the previous URL). */ +export const markNavigation = (runDir: string, url: string): void => { + const timeline = read(runDir); + const nav = timeline.nav ?? []; + if (nav.at(-1)?.url === url) return; + write(runDir, { ...timeline, nav: [...nav, { at: Date.now(), url }] }); +}; + export const readTimeline = (runDir: string): Timeline | null => existsSync(fileFor(runDir)) ? read(runDir) : null; diff --git a/e2e/viewer/src/App.tsx b/e2e/viewer/src/App.tsx index 3704562d5..5370284fd 100644 --- a/e2e/viewer/src/App.tsx +++ b/e2e/viewer/src/App.tsx @@ -1,7 +1,10 @@ import React, { Suspense, useEffect, useState } from "react"; +import type { SessionTimeline } from "./SessionPlayer"; + const TestSource = React.lazy(() => import("./TestSource")); const TerminalCast = React.lazy(() => import("./TerminalCast")); +const SessionPlayer = React.lazy(() => import("./SessionPlayer")); // --------------------------------------------------------------------------- // The matrix (scenario × target health) plus a per-run artifact page. The @@ -134,7 +137,10 @@ const Matrix = () => { // --------------------------------------------------------------------------- // Run page: status + error + artifacts. The trace opens in Playwright's own -// viewer (trace.playwright.dev fetches the zip from this server, client-side). +// viewer, SELF-HOSTED at /trace-viewer (copied out of playwright-core by +// rebuild-viewer.ts). trace.playwright.dev would work on localhost but not +// over tailscale: it's HTTPS, this server is HTTP, and browsers block the +// mixed-content fetch of trace.zip. Same-origin avoids all of it. // --------------------------------------------------------------------------- // The suite's motel (local OTLP store, booted by the global setup on a @@ -148,12 +154,15 @@ interface RunTraceRef { url: string; } +type RunTab = "session" | "browser" | "terminal" | "source"; + const RunView = ({ target, slug }: { target: string; slug: string }) => { const base = `${target}/${slug}`; const [result, setResult] = useState(null); const [error, setError] = useState(null); - const [tab, setTab] = useState<"media" | "source">("media"); + const [tab, setTab] = useState(null); const [traces, setTraces] = useState([]); + const [timeline, setTimeline] = useState(null); useEffect(() => { fetch(`${base}/result.json`) @@ -164,6 +173,10 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { .then((r) => (r.ok ? r.json() : [])) .then(setTraces) .catch(() => setTraces([])); + fetch(`${base}/timeline.json`) + .then((r) => (r.ok ? r.json() : null)) + .then(setTimeline) + .catch(() => setTimeline(null)); }, [base]); if (error) return
failed to load run: {error}
; @@ -171,19 +184,37 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { const has = (name: string) => result.artifacts.includes(name); const screenshots = result.artifacts.filter((a) => a.endsWith(".png")).sort(); - // film.mp4 (scripts/film.ts) is the whole session as one video — terminal, - // browser hop, terminal, cut like tabbing between full-screen windows. - // When present it IS the session; the parts stay on disk for debugging. + const video = has("session.mp4") ? "session.mp4" : has("session.webm") ? "session.webm" : null; const film = has("film.mp4") ? "film.mp4" : null; - const video = - film ?? (has("session.mp4") ? "session.mp4" : has("session.webm") ? "session.webm" : null); - const cast = film ? null : has("terminal.cast") ? "terminal.cast" : null; - const media = video ?? cast; + const cast = has("terminal.cast") ? "terminal.cast" : null; const traceUrl = has("trace.zip") - ? `https://trace.playwright.dev/?trace=${encodeURIComponent( - new URL(`${base}/trace.zip`, window.location.href).toString(), - )}` + ? new URL( + `trace-viewer/index.html?trace=${encodeURIComponent( + new URL(`${base}/trace.zip`, window.location.href).toString(), + )}`, + window.location.href, + ).toString() : null; + // The live two-recording player needs both recordings AND a focus + // timeline whose clocks are anchored. Anything less falls back to + // film.mp4 (pre-rendered cuts) or the single recording. + const playable = Boolean( + cast && + video && + timeline && + timeline.focus.length >= 2 && + timeline.anchors.terminal !== undefined && + timeline.anchors.browser !== undefined, + ); + + const tabs: Array<{ id: RunTab; label: string; available: boolean }> = [ + { id: "session", label: "▶ session", available: playable || Boolean(film) }, + { id: "browser", label: "browser", available: Boolean(video) }, + { id: "terminal", label: "terminal", available: Boolean(cast) }, + { id: "source", label: " test source", available: has("test.ts") }, + ]; + const available = tabs.filter((entry) => entry.available); + const active: RunTab | undefined = tab ?? available[0]?.id; return (
@@ -208,46 +239,52 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => { {new Date(result.endedAt).toLocaleString()}

{result.error &&
{result.error}
} - {media && has("test.ts") && ( + + {available.length > 1 && (
- - + {available.map((entry) => ( + + ))}
)} - {(!media || tab === "source") && has("test.ts") && ( - loading test source…

}> - {!media &&

The test

} - -
- )} - {/* A run with BOTH recordings is one session in time order: the - terminal chat is the spine, the browser video is the hop that - happens in the middle of it. Show them in that order. */} - {cast && tab === "media" && ( - loading recording…

}> - {video &&

1 · the chat, in the terminal

} - -
- )} - {video && tab === "media" && ( + + {active === "session" && + (playable && cast && video && timeline ? ( + loading session player…

}> + +
+ ) : ( + film && ( +
@@ -361,9 +336,7 @@ export const SessionPlayer = ({ className="scrub" onClick={(event) => { const rect = event.currentTarget.getBoundingClientRect(); - void seekTo( - ((event.clientX - rect.left) / rect.width) * duration, - ); + void seekTo(((event.clientX - rect.left) / rect.width) * duration); }} > {ready && @@ -387,12 +360,7 @@ export const SessionPlayer = ({ title={`${mark.url.replace(/^https?:\/\/[^/]+/, "")} → trace ${mark.id.slice(0, 8)}`} /> ))} - {ready && ( - - )} + {ready && } {fmt(t)} / {ready ? fmt(duration) : "…"} @@ -432,9 +400,7 @@ export const SessionPlayer = ({ = 400 - ? " err" - : "" + mark.status !== undefined && mark.status >= 400 ? " err" : "" }`} > {mark.ms !== undefined ? `${mark.ms}ms` : "·"}