Skip to content

Commit 8accae7

Browse files
committed
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.
1 parent efa0fb3 commit 8accae7

6 files changed

Lines changed: 934 additions & 51 deletions

File tree

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: 18 additions & 2 deletions
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");
@@ -30,9 +32,15 @@ const write = (runDir: string, timeline: Timeline) =>
3032
writeFileSync(fileFor(runDir), JSON.stringify(timeline, null, 1));
3133

3234
/** Record that `window`'s recording clock starts now. */
33-
export const markRecordingStart = (runDir: string, window: TimelineWindow): void => {
35+
export const markRecordingStart = (
36+
runDir: string,
37+
window: TimelineWindow,
38+
): void => {
3439
const timeline = read(runDir);
35-
write(runDir, { ...timeline, anchors: { ...timeline.anchors, [window]: Date.now() } });
40+
write(runDir, {
41+
...timeline,
42+
anchors: { ...timeline.anchors, [window]: Date.now() },
43+
});
3644
};
3745

3846
/** Record that the scenario is acting on `window` (deduped per run). */
@@ -43,5 +51,13 @@ export const markFocus = (runDir: string, window: TimelineWindow): void => {
4351
write(runDir, timeline);
4452
};
4553

54+
/** Record a main-frame navigation (deduped against the previous URL). */
55+
export const markNavigation = (runDir: string, url: string): void => {
56+
const timeline = read(runDir);
57+
const nav = timeline.nav ?? [];
58+
if (nav.at(-1)?.url === url) return;
59+
write(runDir, { ...timeline, nav: [...nav, { at: Date.now(), url }] });
60+
};
61+
4662
export const readTimeline = (runDir: string): Timeline | null =>
4763
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)