11import React , { Suspense , useEffect , useState } from "react" ;
22
3+ import type { SessionTimeline } from "./SessionPlayer" ;
4+
35const TestSource = React . lazy ( ( ) => import ( "./TestSource" ) ) ;
46const 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+
151159const 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