Skip to content

Commit bf23a23

Browse files
authored
Runs viewer: live session player with URL bar, trace rail, self-hosted trace viewer (#980)
* Viewer plays the session live: synced recordings, URL bar, trace lane 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. * Self-host Playwright's trace viewer next to the runs trace.playwright.dev is HTTPS, so when the runs viewer is reached over plain HTTP (tailscale IP) the browser blocks its fetch of trace.zip as mixed content and the trace never loads. playwright-core ships the trace viewer as a static PWA — rebuild-viewer.ts now copies it to runs/trace-viewer and the run page links there same-origin, which also keeps traces viewable offline.
1 parent 59c3e24 commit bf23a23

7 files changed

Lines changed: 913 additions & 52 deletions

File tree

e2e/scripts/rebuild-viewer.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
// test: refresh runs/manifest.json + vite-build the SPA into runs/.
33
// Usage: bun e2e/scripts/rebuild-viewer.ts
44
import { execFileSync } from "node:child_process";
5-
import { rmSync } from "node:fs";
6-
import { join } from "node:path";
5+
import { cpSync, rmSync } from "node:fs";
6+
import { createRequire } from "node:module";
7+
import { dirname, join } from "node:path";
78
import { fileURLToPath } from "node:url";
89

910
import { buildManifest } from "../src/viewer/manifest";
@@ -17,4 +18,18 @@ execFileSync("bunx", ["vite", "build", "--config", "viewer/vite.config.ts"], {
1718
cwd: e2eDir,
1819
stdio: "inherit",
1920
});
21+
22+
// Self-host Playwright's trace viewer (a static PWA shipped inside
23+
// playwright-core) next to the runs. trace.playwright.dev is HTTPS and
24+
// browsers refuse to let it fetch a trace.zip from a plain-HTTP server
25+
// (mixed content) — which is exactly how this viewer is reached over
26+
// tailscale. Same-origin, the restriction disappears.
27+
// playwright-core isn't a direct dependency — resolve it through
28+
// playwright (which is), then walk from its entry to the package root.
29+
const require = createRequire(import.meta.url);
30+
const playwrightCoreEntry = createRequire(require.resolve("playwright")).resolve("playwright-core");
31+
const traceViewerSrc = join(dirname(playwrightCoreEntry), "lib/vite/traceViewer");
32+
rmSync(join(runsDir, "trace-viewer"), { recursive: true, force: true });
33+
cpSync(traceViewerSrc, join(runsDir, "trace-viewer"), { recursive: true });
34+
2035
console.log(`viewer rebuilt at ${runsDir}`);

e2e/src/surfaces/browser.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { promisify } from "node:util";
1111
import { Effect } from "effect";
1212
import { chromium, type Page } from "playwright";
1313

14-
import { markFocus, markRecordingStart } from "../timeline";
14+
import { markFocus, markNavigation, markRecordingStart } from "../timeline";
1515
import { appendTraces, type TraceEntry } from "../trace-harvest";
1616
import type { Identity, Target } from "../target";
1717

@@ -78,6 +78,12 @@ export const makeBrowserSurface = (dir: string, target: Target): BrowserSurface
7878
// The session video's clock starts with the page; anchor it for the
7979
// run's focus timeline (scripts/film.ts cuts on these).
8080
markRecordingStart(dir, "browser");
81+
// Main-frame navigations feed the viewer's synthetic URL bar — the
82+
// recording itself is chromeless, so this is the only place the
83+
// address the developer "typed" survives.
84+
page.on("framenavigated", (frame) => {
85+
if (frame === page.mainFrame()) markNavigation(dir, frame.url());
86+
});
8187
// Harvest distributed-trace ids: every app API request carries a W3C
8288
// traceparent (Effect's HttpClient), and each id names one
8389
// click→server→DB trace in whatever OTLP store the run exported to

e2e/src/timeline.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export interface Timeline {
1616
readonly anchors: { terminal?: number; browser?: number };
1717
/** Focus transitions (first event per contiguous run of a window). */
1818
readonly focus: Array<{ at: number; window: TimelineWindow }>;
19+
/** Main-frame navigations — lets the viewer render a live URL bar. */
20+
readonly nav?: Array<{ at: number; url: string }>;
1921
}
2022

2123
const fileFor = (runDir: string) => join(runDir, "timeline.json");
@@ -32,7 +34,10 @@ const write = (runDir: string, timeline: Timeline) =>
3234
/** Record that `window`'s recording clock starts now. */
3335
export const markRecordingStart = (runDir: string, window: TimelineWindow): void => {
3436
const timeline = read(runDir);
35-
write(runDir, { ...timeline, anchors: { ...timeline.anchors, [window]: Date.now() } });
37+
write(runDir, {
38+
...timeline,
39+
anchors: { ...timeline.anchors, [window]: Date.now() },
40+
});
3641
};
3742

3843
/** Record that the scenario is acting on `window` (deduped per run). */
@@ -43,5 +48,13 @@ export const markFocus = (runDir: string, window: TimelineWindow): void => {
4348
write(runDir, timeline);
4449
};
4550

51+
/** Record a main-frame navigation (deduped against the previous URL). */
52+
export const markNavigation = (runDir: string, url: string): void => {
53+
const timeline = read(runDir);
54+
const nav = timeline.nav ?? [];
55+
if (nav.at(-1)?.url === url) return;
56+
write(runDir, { ...timeline, nav: [...nav, { at: Date.now(), url }] });
57+
};
58+
4659
export const readTimeline = (runDir: string): Timeline | null =>
4760
existsSync(fileFor(runDir)) ? read(runDir) : null;

e2e/viewer/src/App.tsx

Lines changed: 100 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import React, { Suspense, useEffect, useState } from "react";
22

3+
import type { SessionTimeline } from "./SessionPlayer";
4+
35
const TestSource = React.lazy(() => import("./TestSource"));
46
const TerminalCast = React.lazy(() => import("./TerminalCast"));
7+
const SessionPlayer = React.lazy(() => import("./SessionPlayer"));
58

69
// ---------------------------------------------------------------------------
710
// The matrix (scenario × target health) plus a per-run artifact page. The
@@ -134,7 +137,10 @@ const Matrix = () => {
134137

135138
// ---------------------------------------------------------------------------
136139
// Run page: status + error + artifacts. The trace opens in Playwright's own
137-
// viewer (trace.playwright.dev fetches the zip from this server, client-side).
140+
// viewer, SELF-HOSTED at /trace-viewer (copied out of playwright-core by
141+
// rebuild-viewer.ts). trace.playwright.dev would work on localhost but not
142+
// over tailscale: it's HTTPS, this server is HTTP, and browsers block the
143+
// mixed-content fetch of trace.zip. Same-origin avoids all of it.
138144
// ---------------------------------------------------------------------------
139145

140146
// The suite's motel (local OTLP store, booted by the global setup on a
@@ -148,12 +154,15 @@ interface RunTraceRef {
148154
url: string;
149155
}
150156

157+
type RunTab = "session" | "browser" | "terminal" | "source";
158+
151159
const RunView = ({ target, slug }: { target: string; slug: string }) => {
152160
const base = `${target}/${slug}`;
153161
const [result, setResult] = useState<RunResult | null>(null);
154162
const [error, setError] = useState<string | null>(null);
155-
const [tab, setTab] = useState<"media" | "source">("media");
163+
const [tab, setTab] = useState<RunTab | null>(null);
156164
const [traces, setTraces] = useState<RunTraceRef[]>([]);
165+
const [timeline, setTimeline] = useState<SessionTimeline | null>(null);
157166

158167
useEffect(() => {
159168
fetch(`${base}/result.json`)
@@ -164,26 +173,48 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => {
164173
.then((r) => (r.ok ? r.json() : []))
165174
.then(setTraces)
166175
.catch(() => setTraces([]));
176+
fetch(`${base}/timeline.json`)
177+
.then((r) => (r.ok ? r.json() : null))
178+
.then(setTimeline)
179+
.catch(() => setTimeline(null));
167180
}, [base]);
168181

169182
if (error) return <div className="page error-text">failed to load run: {error}</div>;
170183
if (!result) return <div className="page dim">loading…</div>;
171184

172185
const has = (name: string) => result.artifacts.includes(name);
173186
const screenshots = result.artifacts.filter((a) => a.endsWith(".png")).sort();
174-
// film.mp4 (scripts/film.ts) is the whole session as one video — terminal,
175-
// browser hop, terminal, cut like tabbing between full-screen windows.
176-
// When present it IS the session; the parts stay on disk for debugging.
187+
const video = has("session.mp4") ? "session.mp4" : has("session.webm") ? "session.webm" : null;
177188
const film = has("film.mp4") ? "film.mp4" : null;
178-
const video =
179-
film ?? (has("session.mp4") ? "session.mp4" : has("session.webm") ? "session.webm" : null);
180-
const cast = film ? null : has("terminal.cast") ? "terminal.cast" : null;
181-
const media = video ?? cast;
189+
const cast = has("terminal.cast") ? "terminal.cast" : null;
182190
const traceUrl = has("trace.zip")
183-
? `https://trace.playwright.dev/?trace=${encodeURIComponent(
184-
new URL(`${base}/trace.zip`, window.location.href).toString(),
185-
)}`
191+
? new URL(
192+
`trace-viewer/index.html?trace=${encodeURIComponent(
193+
new URL(`${base}/trace.zip`, window.location.href).toString(),
194+
)}`,
195+
window.location.href,
196+
).toString()
186197
: null;
198+
// The live two-recording player needs both recordings AND a focus
199+
// timeline whose clocks are anchored. Anything less falls back to
200+
// film.mp4 (pre-rendered cuts) or the single recording.
201+
const playable = Boolean(
202+
cast &&
203+
video &&
204+
timeline &&
205+
timeline.focus.length >= 2 &&
206+
timeline.anchors.terminal !== undefined &&
207+
timeline.anchors.browser !== undefined,
208+
);
209+
210+
const tabs: Array<{ id: RunTab; label: string; available: boolean }> = [
211+
{ id: "session", label: "▶ session", available: playable || Boolean(film) },
212+
{ id: "browser", label: "browser", available: Boolean(video) },
213+
{ id: "terminal", label: "terminal", available: Boolean(cast) },
214+
{ id: "source", label: "</> test source", available: has("test.ts") },
215+
];
216+
const available = tabs.filter((entry) => entry.available);
217+
const active: RunTab | undefined = tab ?? available[0]?.id;
187218

188219
return (
189220
<div className="page">
@@ -208,46 +239,52 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => {
208239
{new Date(result.endedAt).toLocaleString()}
209240
</p>
210241
{result.error && <pre className="errbox">{result.error}</pre>}
211-
{media && has("test.ts") && (
242+
243+
{available.length > 1 && (
212244
<div className="tabs">
213-
<button
214-
className={tab === "media" ? "tab active" : "tab"}
215-
onClick={() => setTab("media")}
216-
>
217-
{cast && video ? "session" : video ? "video" : "terminal"}
218-
</button>
219-
<button
220-
className={tab === "source" ? "tab active" : "tab"}
221-
onClick={() => setTab("source")}
222-
>
223-
{"</>"} test source
224-
</button>
245+
{available.map((entry) => (
246+
<button
247+
key={entry.id}
248+
className={active === entry.id ? "tab active" : "tab"}
249+
onClick={() => setTab(entry.id)}
250+
>
251+
{entry.label}
252+
</button>
253+
))}
225254
</div>
226255
)}
227-
{(!media || tab === "source") && has("test.ts") && (
228-
<Suspense fallback={<p className="dim">loading test source…</p>}>
229-
{!media && <h2 className="section">The test</h2>}
230-
<TestSource url={`${base}/test.ts`} />
231-
</Suspense>
232-
)}
233-
{/* A run with BOTH recordings is one session in time order: the
234-
terminal chat is the spine, the browser video is the hop that
235-
happens in the middle of it. Show them in that order. */}
236-
{cast && tab === "media" && (
237-
<Suspense fallback={<p className="dim">loading recording…</p>}>
238-
{video && <h2 className="section">1 · the chat, in the terminal</h2>}
239-
<TerminalCast url={`${base}/${cast}`} />
240-
</Suspense>
241-
)}
242-
{video && tab === "media" && (
256+
257+
{active === "session" &&
258+
(playable && cast && video && timeline ? (
259+
<Suspense fallback={<p className="dim">loading session player…</p>}>
260+
<SessionPlayer
261+
castUrl={`${base}/${cast}`}
262+
videoUrl={`${base}/${video}`}
263+
timeline={timeline}
264+
traces={traces}
265+
playwrightTraceUrl={traceUrl}
266+
motelViewer={MOTEL_VIEWER}
267+
/>
268+
</Suspense>
269+
) : (
270+
film && (
271+
<video
272+
className="hero-video"
273+
controls
274+
autoPlay
275+
muted
276+
playsInline
277+
preload="auto"
278+
src={`${base}/${film}`}
279+
/>
280+
)
281+
))}
282+
283+
{active === "browser" && video && (
243284
<>
244-
{cast && <h2 className="section">2 · the browser hop, mid-chat</h2>}
245-
{/* muted is required for browsers to honor autoplay; don't
246-
autoplay when it's the second act of a session */}
247285
<video
248286
className="hero-video"
249287
controls
250-
autoPlay={!cast}
251288
muted
252289
playsInline
253290
preload="auto"
@@ -269,12 +306,28 @@ const RunView = ({ target, slug }: { target: string; slug: string }) => {
269306
)}
270307
</>
271308
)}
272-
{!media && !has("test.ts") && screenshots.length === 0 && (
309+
310+
{active === "terminal" && cast && (
311+
<Suspense fallback={<p className="dim">loading recording…</p>}>
312+
<TerminalCast url={`${base}/${cast}`} />
313+
</Suspense>
314+
)}
315+
316+
{active === "source" && has("test.ts") && (
317+
<Suspense fallback={<p className="dim">loading test source…</p>}>
318+
<TestSource url={`${base}/test.ts`} />
319+
</Suspense>
320+
)}
321+
322+
{!active && screenshots.length === 0 && (
273323
<p className="dim">
274324
No visual artifacts — this surface's source of truth is the test code and its assertions.
275325
</p>
276326
)}
277-
{traces.length > 0 && (
327+
328+
{/* The session tab carries its own timeline-aligned trace table; the
329+
flat list stays for runs viewed through any other lens. */}
330+
{active !== "session" && traces.length > 0 && (
278331
<>
279332
<h2 className="section">Distributed traces</h2>
280333
<p className="hint">

0 commit comments

Comments
 (0)