diff --git a/e2e/scripts/rebuild-viewer.ts b/e2e/scripts/rebuild-viewer.ts index 7869bc602..f45075401 100644 --- a/e2e/scripts/rebuild-viewer.ts +++ b/e2e/scripts/rebuild-viewer.ts @@ -2,8 +2,9 @@ // test: refresh runs/manifest.json + vite-build the SPA into runs/. // Usage: bun e2e/scripts/rebuild-viewer.ts import { execFileSync } from "node:child_process"; -import { rmSync } from "node:fs"; -import { join } from "node:path"; +import { cpSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { buildManifest } from "../src/viewer/manifest"; @@ -17,4 +18,18 @@ execFileSync("bunx", ["vite", "build", "--config", "viewer/vite.config.ts"], { cwd: e2eDir, stdio: "inherit", }); + +// Self-host Playwright's trace viewer (a static PWA shipped inside +// playwright-core) next to the runs. trace.playwright.dev is HTTPS and +// browsers refuse to let it fetch a trace.zip from a plain-HTTP server +// (mixed content) — which is exactly how this viewer is reached over +// tailscale. Same-origin, the restriction disappears. +// playwright-core isn't a direct dependency — resolve it through +// playwright (which is), then walk from its entry to the package root. +const require = createRequire(import.meta.url); +const playwrightCoreEntry = createRequire(require.resolve("playwright")).resolve("playwright-core"); +const traceViewerSrc = join(dirname(playwrightCoreEntry), "lib/vite/traceViewer"); +rmSync(join(runsDir, "trace-viewer"), { recursive: true, force: true }); +cpSync(traceViewerSrc, join(runsDir, "trace-viewer"), { recursive: true }); + console.log(`viewer rebuilt at ${runsDir}`); 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..061c8c93b 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"); @@ -32,7 +34,10 @@ const write = (runDir: string, timeline: Timeline) => /** Record that `window`'s recording clock starts now. */ 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 +48,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 && ( +