From 479752fc85b282fc9d6b093b4addef295415ad66 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Mon, 15 Jun 2026 21:41:55 +0000 Subject: [PATCH 1/4] feat: add reusable record player packages --- README.md | 12 +- package-lock.json | 94 +++++++ package.json | 3 +- src/packages/index.ts | 3 + src/packages/player-core/index.ts | 338 ++++++++++++++++++++++++++ src/packages/player-element/index.tsx | 110 +++++++++ src/packages/player-react/index.tsx | 160 ++++++++++++ src/test/player-core.test.ts | 57 +++++ src/test/player-element.test.tsx | 45 ++++ 9 files changed, 820 insertions(+), 2 deletions(-) create mode 100644 src/packages/index.ts create mode 100644 src/packages/player-core/index.ts create mode 100644 src/packages/player-element/index.tsx create mode 100644 src/packages/player-react/index.tsx create mode 100644 src/test/player-core.test.ts create mode 100644 src/test/player-element.test.tsx diff --git a/README.md b/README.md index 26a56cb..da2e3f4 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,21 @@ npm run dev Then open `http://localhost:5173` in your browser. +## Reusable package entry points + +This repository now exposes a first reusable API slice under `src/packages` while preserving the hosted Vite app: + +- `player-core` — pure ZIP loading/parsing (`parseRecording`, `loadRecording`) plus `LoadedRecording`, `TimelineModel`, `RecordingEvent`, `ScreenshotIndex`, and `PlayerError` contracts. URL string loading uses `fetch` with browser `same-origin` credentials by default and supports injected fetch/signal. +- `player-react` — `RecordPlayer` and `RecordPlayerLoader` React components for rendering a loaded recording or fetching one from `src`. +- `player-element` — `defineVibiumRecordPlayerElement()` for registering ``; it dispatches `vibium-player-ready` with `{ recording }` and `vibium-player-error` with `{ error }`. + +Remaining migration work is intentionally staged: the hosted app still uses the existing full-featured studio UI, while future PRs can replace its embedded parser with `player-core`, add formal library build outputs, and migrate the full timeline/compare experience onto these public contracts. + ## Tech Stack - React + Vite + TypeScript - Tailwind CSS -- JSZip (loaded from CDN at runtime) +- JSZip - shadcn/ui ## License diff --git a/package-lock.json b/package-lock.json index aa6cac6..60ce452 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "date-fns": "^3.6.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", + "jszip": "^3.10.1", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", "react": "^18.3.1", @@ -3950,6 +3951,12 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5165,6 +5172,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5202,6 +5215,12 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/input-otp": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", @@ -5294,6 +5313,12 @@ "dev": true, "license": "MIT" }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5410,6 +5435,18 @@ "dev": true, "license": "MIT" }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5434,6 +5471,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -6240,6 +6286,12 @@ "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6562,6 +6614,12 @@ "license": "MIT", "peer": true }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6841,6 +6899,21 @@ "pify": "^2.3.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -7002,6 +7075,12 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -7044,6 +7123,12 @@ "node": ">=10" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7128,6 +7213,15 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", diff --git a/package.json b/package.json index 04b2365..389282f 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "date-fns": "^3.6.0", "embla-carousel-react": "^8.6.0", "input-otp": "^1.4.2", + "jszip": "^3.10.1", "lucide-react": "^0.462.0", "next-themes": "^0.3.0", "react": "^18.3.1", @@ -65,9 +66,9 @@ }, "devDependencies": { "@eslint/js": "^9.32.0", + "@tailwindcss/typography": "^0.5.16", "@testing-library/jest-dom": "^6.6.0", "@testing-library/react": "^16.0.0", - "@tailwindcss/typography": "^0.5.16", "@types/node": "^22.16.5", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", diff --git a/src/packages/index.ts b/src/packages/index.ts new file mode 100644 index 0000000..8934ed2 --- /dev/null +++ b/src/packages/index.ts @@ -0,0 +1,3 @@ +export * from "./player-core"; +export * from "./player-react"; +export * from "./player-element"; diff --git a/src/packages/player-core/index.ts b/src/packages/player-core/index.ts new file mode 100644 index 0000000..0fe1d41 --- /dev/null +++ b/src/packages/player-core/index.ts @@ -0,0 +1,338 @@ +import JSZip from "jszip"; + +export type RecordingEventKind = "action" | "console" | "network" | "screenshot" | "group" | "raw"; + +export interface RecordingEvent { + id: string; + kind: RecordingEventKind; + type?: string; + title?: string; + method?: string; + time: number; + endTime?: number; + duration?: number; + data: Record; +} + +export interface ScreenshotIndex { + id: string; + sha1: string; + time: number; + width?: number; + height?: number; + mimeType: string; + dataUrl?: string; +} + +export interface TimelineModel { + startTime: number; + endTime: number; + duration: number; + events: RecordingEvent[]; + screenshots: ScreenshotIndex[]; +} + +export interface LoadedRecording { + version: 1; + source?: string; + files: string[]; + metadata: { + fileCount: number; + eventCount: number; + traceEventCount: number; + networkEventCount: number; + contextOptions?: unknown; + }; + timeline: TimelineModel; + raw: { + traceEvents: unknown[]; + networkEvents: unknown[]; + }; +} + +export type PlayerErrorCode = "INVALID_INPUT" | "FETCH_ERROR" | "HTTP_ERROR" | "ZIP_ERROR" | "PARSE_ERROR"; + +export class PlayerError extends Error { + readonly code: PlayerErrorCode; + readonly cause?: unknown; + readonly status?: number; + + constructor(code: PlayerErrorCode, message: string, options: { cause?: unknown; status?: number } = {}) { + super(message); + this.name = "PlayerError"; + this.code = code; + this.cause = options.cause; + this.status = options.status; + } +} + +export interface ParseRecordingOptions { + source?: string; + includeResourceDataUrls?: boolean; +} + +export interface LoadRecordingOptions extends ParseRecordingOptions { + fetch?: typeof globalThis.fetch; + signal?: AbortSignal; + credentials?: RequestCredentials; +} + +type ZipFile = JSZip.JSZipObject; + +function parseNDJSON(text: string): unknown[] { + const results: unknown[] = []; + for (const [index, line] of text.split("\n").entries()) { + if (!line.trim()) continue; + try { + results.push(JSON.parse(line)); + } catch (cause) { + throw new PlayerError("PARSE_ERROR", `Invalid NDJSON on line ${index + 1}`, { cause }); + } + } + return results; +} + +function parseMaybeJSON(text: string): unknown[] { + const trimmed = text.trim(); + if (!trimmed) return []; + if (trimmed.startsWith("[") || trimmed.startsWith("{")) { + try { + const parsed = JSON.parse(trimmed); + return Array.isArray(parsed) ? parsed : [parsed]; + } catch { + // Playwright/Vibium .trace and .network files are usually NDJSON, which + // also starts with "{". Fall through to line-oriented parsing. + } + } + return parseNDJSON(text); +} + +function asRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +function numberFrom(...values: unknown[]): number | undefined { + for (const value of values) { + const n = Number(value); + if (Number.isFinite(n)) return n; + } + return undefined; +} + +function eventTitle(event: Record): string | undefined { + const cls = typeof event.class === "string" ? event.class : ""; + const method = typeof event.method === "string" ? event.method : undefined; + if (typeof event.title === "string") return event.title; + if (cls && method) return `${cls}.${method}`; + return method; +} + +function screenshotMime(sha1: string): string { + const lower = sha1.toLowerCase(); + if (lower.endsWith(".png")) return "image/png"; + if (lower.endsWith(".webp")) return "image/webp"; + return "image/jpeg"; +} + +function harMonotonicTimeToMs(value: unknown): number | undefined { + const time = numberFrom(value); + if (time == null) return undefined; + return time >= 1e9 && time < 1e12 ? time * 1000 : time; +} + +function isTimelineEvent(raw: unknown): boolean { + const evt = asRecord(raw); + // Context options are metadata for the recording context. They often carry + // absolute wall-clock values that must not be mixed into the relative + // playback timeline. + if (evt.type === "context-options") return false; + return true; +} + +function normalizeEvent(raw: unknown, index: number): RecordingEvent { + const evt = asRecord(raw); + const type = typeof evt.type === "string" ? evt.type : undefined; + const method = typeof evt.method === "string" ? evt.method : undefined; + const params = asRecord(evt.params); + const snapshot = asRecord(evt.snapshot); + const request = asRecord(snapshot.request); + const response = asRecord(snapshot.response); + + let kind: RecordingEventKind = "raw"; + if (type === "before" || type === "after" || type === "input") kind = "action"; + if (type === "event" && method === "log.entryAdded") kind = "console"; + if (type === "screencast-frame") kind = "screenshot"; + if (method?.startsWith("network.") || evt.snapshot) kind = "network"; + if (type === "before" && evt.class === "Tracing") kind = "group"; + + const time = numberFrom( + evt.startTime, + evt.time, + evt.timestamp, + evt.wallTime, + params.timestamp, + harMonotonicTimeToMs(snapshot._monotonicTime), + snapshot.startedDateTime ? Date.parse(String(snapshot.startedDateTime)) : undefined, + ) ?? 0; + const endTime = numberFrom(evt.endTime, snapshot.time != null ? time + Number(snapshot.time) : undefined); + const title = eventTitle(evt) || (request.url as string | undefined) || (response.url as string | undefined); + + return { + id: String(evt.callId ?? evt.id ?? `${kind}-${index}`), + kind, + type, + title, + method, + time, + endTime, + duration: endTime != null ? Math.max(0, endTime - time) : undefined, + data: evt, + }; +} + +function extractScreenshotRefs(events: unknown[], resources: Map): ScreenshotIndex[] { + const screenshots: ScreenshotIndex[] = []; + events.forEach((raw, index) => { + const evt = asRecord(raw); + if (evt.type !== "screencast-frame" || typeof evt.sha1 !== "string") return; + const resource = resources.get(evt.sha1); + screenshots.push({ + id: `screenshot-${index}`, + sha1: evt.sha1, + time: numberFrom(evt.timestamp, evt.time) ?? 0, + width: numberFrom(evt.width), + height: numberFrom(evt.height), + mimeType: resource?.mimeType ?? screenshotMime(evt.sha1), + dataUrl: resource?.dataUrl, + }); + }); + return screenshots.sort((a, b) => a.time - b.time); +} + +async function readEvents(files: string[], zipFiles: Record, suffix: string): Promise { + const events: unknown[] = []; + for (const name of files) { + if (!name.endsWith(suffix) || zipFiles[name].dir) continue; + const text = await zipFiles[name].async("string"); + events.push(...parseMaybeJSON(text)); + } + return events; +} + +async function readResources( + files: string[], + zipFiles: Record, + includeDataUrls: boolean, +): Promise> { + const resources = new Map(); + for (const name of files) { + if (!name.startsWith("resources/") || zipFiles[name].dir) continue; + const sha1 = name.split("/").pop(); + if (!sha1) continue; + const mimeType = screenshotMime(sha1); + const dataUrl = includeDataUrls ? `data:${mimeType};base64,${await zipFiles[name].async("base64")}` : undefined; + resources.set(sha1, { mimeType, dataUrl }); + } + return resources; +} + +function buildTimeline(events: RecordingEvent[], screenshots: ScreenshotIndex[]): TimelineModel { + const times = [ + ...events.flatMap((event) => [event.time, event.endTime]), + ...screenshots.map((screenshot) => screenshot.time), + ].filter((value): value is number => Number.isFinite(value)); + const startTime = times.length ? Math.min(...times) : 0; + const endTime = times.length ? Math.max(...times) : startTime; + const normalize = (time: number | undefined) => (Number.isFinite(time) ? Number(time) - startTime : undefined); + return { + startTime, + endTime, + duration: Math.max(0, endTime - startTime), + events: events + .map((event) => { + const time = normalize(event.time) ?? 0; + const end = normalize(event.endTime); + return { ...event, time, endTime: end, duration: end != null ? Math.max(0, end - time) : event.duration }; + }) + .sort((a, b) => a.time - b.time), + screenshots: screenshots.map((screenshot) => ({ ...screenshot, time: normalize(screenshot.time) ?? 0 })), + }; +} + +export async function parseRecording( + data: ArrayBuffer | Uint8Array | Blob, + options: ParseRecordingOptions = {}, +): Promise { + if ( + !(data instanceof ArrayBuffer) && + !ArrayBuffer.isView(data) && + !(typeof Blob !== "undefined" && data instanceof Blob) + ) { + throw new PlayerError("INVALID_INPUT", "parseRecording expects an ArrayBuffer, Uint8Array, or Blob"); + } + + let zip: JSZip; + try { + zip = await JSZip.loadAsync(data); + } catch (cause) { + throw new PlayerError("ZIP_ERROR", "Unable to read recording ZIP", { cause }); + } + + try { + const files = Object.keys(zip.files).sort(); + const traceEvents = await readEvents(files, zip.files, ".trace"); + const networkEvents = await readEvents(files, zip.files, ".network"); + const resources = await readResources(files, zip.files, options.includeResourceDataUrls ?? true); + const allRawEvents = [...traceEvents, ...networkEvents].filter(isTimelineEvent); + const events = allRawEvents.map(normalizeEvent); + const screenshots = extractScreenshotRefs(traceEvents, resources); + const contextOptions = traceEvents.map(asRecord).find((event) => event.type === "context-options"); + + return { + version: 1, + source: options.source, + files, + metadata: { + fileCount: files.length, + eventCount: allRawEvents.length, + traceEventCount: traceEvents.length, + networkEventCount: networkEvents.length, + contextOptions, + }, + timeline: buildTimeline(events, screenshots), + raw: { traceEvents, networkEvents }, + }; + } catch (cause) { + if (cause instanceof PlayerError) throw cause; + throw new PlayerError("PARSE_ERROR", "Unable to parse recording contents", { cause }); + } +} + +export async function loadRecording(source: URL | string, options: LoadRecordingOptions = {}): Promise { + const url = source instanceof URL ? source.toString() : source; + if (typeof url !== "string" || !url) { + throw new PlayerError("INVALID_INPUT", "loadRecording expects a URL or URL string"); + } + const fetchImpl = options.fetch ?? globalThis.fetch; + if (!fetchImpl) { + throw new PlayerError("FETCH_ERROR", "No fetch implementation is available"); + } + + let response: Response; + try { + response = await fetchImpl(url, { + signal: options.signal, + credentials: options.credentials ?? "same-origin", + }); + } catch (cause) { + throw new PlayerError("FETCH_ERROR", `Failed to fetch recording: ${url}`, { cause }); + } + + if (!response.ok) { + throw new PlayerError("HTTP_ERROR", `Failed to fetch recording: ${response.status} ${response.statusText}`, { + status: response.status, + }); + } + + return parseRecording(await response.arrayBuffer(), { ...options, source: url }); +} diff --git a/src/packages/player-element/index.tsx b/src/packages/player-element/index.tsx new file mode 100644 index 0000000..161f2fe --- /dev/null +++ b/src/packages/player-element/index.tsx @@ -0,0 +1,110 @@ +import React from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { RecordPlayerLoader } from "../player-react"; +import type { LoadedRecording } from "../player-core"; + +export interface VibiumRecordPlayerReadyDetail { + recording: LoadedRecording; +} + +export interface VibiumRecordPlayerErrorDetail { + error: Error; +} + +const ELEMENT_NAME = "vibium-record-player"; + +function visibilityAttribute(value: string | null): boolean | "visible" | "hidden" | undefined { + if (value == null) return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === "hidden" || normalized === "false" || normalized === "0") return "hidden"; + if (normalized === "visible" || normalized === "true" || normalized === "1" || normalized === "") return "visible"; + return "visible"; +} + +export class VibiumRecordPlayerElement extends HTMLElement { + static get observedAttributes() { + return ["src", "inspector", "timeline", "storage-key"]; + } + + private root: Root | null = null; + private mount: HTMLDivElement | null = null; + + connectedCallback() { + if (!this.mount) { + this.mount = document.createElement("div"); + this.appendChild(this.mount); + } + this.render(); + } + + disconnectedCallback() { + this.root?.unmount(); + this.root = null; + } + + attributeChangedCallback() { + if (this.isConnected) this.render(); + } + + private render() { + if (!this.mount) return; + if (!this.root) this.root = createRoot(this.mount); + const src = this.getAttribute("src"); + const inspector = visibilityAttribute(this.getAttribute("inspector")); + const timeline = visibilityAttribute(this.getAttribute("timeline")); + const storageKey = this.getAttribute("storage-key") ?? undefined; + + if (!src) { + this.root.render(
Missing recording src
); + return; + } + + this.root.render( + { + const detail: VibiumRecordPlayerReadyDetail = { recording }; + this.dispatchEvent( + new CustomEvent("vibium-player-ready", { + bubbles: true, + detail, + }), + ); + this.dispatchEvent( + new CustomEvent("ready", { + bubbles: true, + detail, + }), + ); + }} + onError={(error) => { + const detail: VibiumRecordPlayerErrorDetail = { error }; + this.dispatchEvent( + new CustomEvent("vibium-player-error", { + bubbles: true, + detail, + }), + ); + this.dispatchEvent( + new CustomEvent("error", { + bubbles: false, + cancelable: true, + detail, + }), + ); + }} + />, + ); + } +} + +export function defineVibiumRecordPlayerElement(name = ELEMENT_NAME): CustomElementConstructor { + const existing = customElements.get(name); + if (existing) return existing; + customElements.define(name, VibiumRecordPlayerElement); + return VibiumRecordPlayerElement; +} diff --git a/src/packages/player-react/index.tsx b/src/packages/player-react/index.tsx new file mode 100644 index 0000000..899d646 --- /dev/null +++ b/src/packages/player-react/index.tsx @@ -0,0 +1,160 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { loadRecording, type LoadedRecording, type LoadRecordingOptions, PlayerError } from "../player-core"; + +export interface RecordPlayerProps { + recording: LoadedRecording; + inspector?: boolean | "visible" | "hidden"; + timeline?: boolean | "visible" | "hidden"; + className?: string; + style?: React.CSSProperties; + storageKey?: string | false; +} + +export interface RecordPlayerLoaderProps extends Omit { + src: URL | string; + fetch?: LoadRecordingOptions["fetch"]; + credentials?: RequestCredentials; + onReady?: (recording: LoadedRecording) => void; + onError?: (error: PlayerError | Error) => void; + loadingFallback?: React.ReactNode; + errorFallback?: (error: PlayerError | Error) => React.ReactNode; +} + +function formatMs(value: number): string { + if (!Number.isFinite(value)) return "0ms"; + if (Math.abs(value) >= 1000) return `${(value / 1000).toFixed(2)}s`; + return `${Math.round(value)}ms`; +} + +function optionVisible(value: boolean | "visible" | "hidden" | undefined, defaultValue = true): boolean { + if (value === "hidden") return false; + if (value === "visible") return true; + return value ?? defaultValue; +} + +export function RecordPlayer({ recording, inspector = true, timeline = true, className, style }: RecordPlayerProps) { + const showInspector = optionVisible(inspector); + const showTimeline = optionVisible(timeline); + const events = recording.timeline.events; + const firstScreenshot = recording.timeline.screenshots.find((screenshot) => screenshot.dataUrl); + const counts = useMemo( + () => + events.reduce>((acc, event) => { + acc[event.kind] = (acc[event.kind] ?? 0) + 1; + return acc; + }, {}), + [events], + ); + + return ( +
+
+
+

Vibium Record Player

+
+ {recording.source ? `${recording.source} · ` : ""} + {recording.metadata.fileCount} files · {recording.metadata.eventCount} events · {formatMs(recording.timeline.duration)} +
+
+
+ {Object.entries(counts).map(([kind, count]) => ( + + {kind}: {count} + + ))} +
+
+ + {firstScreenshot?.dataUrl ? ( +
+ First recording screenshot +
Screenshot at {formatMs(firstScreenshot.time)}
+
+ ) : null} + + {showTimeline ? ( +
+
+ {events.slice(0, 250).map((event) => ( + + ))} +
+
+ ) : null} + + {showInspector ? ( +
+
Events
+
    + {events.slice(0, 200).map((event) => ( +
  1. + + {event.kind} + {event.title ?? event.method ?? event.type ?? event.id} +
  2. + ))} +
+
+ ) : null} +
+ ); +} + +export function RecordPlayerLoader({ + src, + fetch, + credentials = "same-origin", + onReady, + onError, + loadingFallback =
Loading recording…
, + errorFallback, + ...playerProps +}: RecordPlayerLoaderProps) { + const [recording, setRecording] = useState(null); + const [error, setError] = useState(null); + + const onReadyRef = useRef(onReady); + const onErrorRef = useRef(onError); + + useEffect(() => { + onReadyRef.current = onReady; + onErrorRef.current = onError; + }, [onReady, onError]); + + useEffect(() => { + const abort = new AbortController(); + setRecording(null); + setError(null); + + loadRecording(src, { fetch, credentials, signal: abort.signal }) + .then((loaded) => { + if (abort.signal.aborted) return; + setRecording(loaded); + onReadyRef.current?.(loaded); + }) + .catch((err) => { + if (abort.signal.aborted) return; + const error = err instanceof Error ? err : new Error(String(err)); + setError(error); + onErrorRef.current?.(error); + }); + + return () => abort.abort(); + }, [src, fetch, credentials]); + + if (error) return <>{errorFallback ? errorFallback(error) :
Failed to load recording: {error.message}
}; + if (!recording) return <>{loadingFallback}; + return ; +} diff --git a/src/test/player-core.test.ts b/src/test/player-core.test.ts new file mode 100644 index 0000000..d69c492 --- /dev/null +++ b/src/test/player-core.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vitest"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { loadRecording, parseRecording, PlayerError } from "../packages/player-core"; + +describe("player-core", () => { + it("parses a real public demo zip into a normalized timeline", async () => { + const data = await readFile(resolve(process.cwd(), "public/vibium-demo-record.zip")); + const recording = await parseRecording(data, { source: "fixture" }); + + expect(recording.version).toBe(1); + expect(recording.source).toBe("fixture"); + expect(recording.files.length).toBeGreaterThan(0); + expect(recording.metadata.traceEventCount).toBeGreaterThan(0); + expect(recording.timeline.events.length).toBeGreaterThan(0); + expect(recording.timeline.duration).toBeGreaterThan(1_000); + expect(recording.timeline.duration).toBeLessThan(60_000); + expect(recording.timeline.events[0]).toHaveProperty("kind"); + }); + + it("loads URL strings with injected fetch, same-origin credentials, and signal", async () => { + const data = await readFile(resolve(process.cwd(), "public/vibium-demo-record.zip")); + const signal = new AbortController().signal; + const fetchMock = vi.fn(async () => new Response(data, { status: 200, statusText: "OK" })); + + const recording = await loadRecording("https://example.test/record.zip", { fetch: fetchMock, signal }); + + expect(recording.source).toBe("https://example.test/record.zip"); + expect(fetchMock).toHaveBeenCalledWith("https://example.test/record.zip", { + credentials: "same-origin", + signal, + }); + }); + + it("surfaces invalid zip data as a PlayerError", async () => { + await expect(parseRecording(new Uint8Array([1, 2, 3, 4]))).rejects.toMatchObject({ + name: "PlayerError", + code: "ZIP_ERROR", + }); + }); + + it("keeps player-core free of React imports", async () => { + const source = await readFile(resolve(process.cwd(), "src/packages/player-core/index.ts"), "utf8"); + expect(source).not.toMatch(/from ["']react["']/); + expect(source).not.toMatch(/react-dom/); + }); + + it("reports non-ok fetches with status", async () => { + const fetchMock = vi.fn(async () => new Response("nope", { status: 404, statusText: "Not Found" })); + + await expect(loadRecording("https://example.test/missing.zip", { fetch: fetchMock })).rejects.toBeInstanceOf(PlayerError); + await expect(loadRecording("https://example.test/missing.zip", { fetch: fetchMock })).rejects.toMatchObject({ + code: "HTTP_ERROR", + status: 404, + }); + }); +}); diff --git a/src/test/player-element.test.tsx b/src/test/player-element.test.tsx new file mode 100644 index 0000000..f5c0f3a --- /dev/null +++ b/src/test/player-element.test.tsx @@ -0,0 +1,45 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { defineVibiumRecordPlayerElement } from "../packages/player-element"; + +function okZipResponse() { + // Invalid payload is enough for the element error path. Core parser tests cover valid zips. + return new Response(new Uint8Array([1, 2, 3]), { status: 200, statusText: "OK" }); +} + +describe("player-element", () => { + beforeEach(() => { + document.body.innerHTML = ""; + vi.restoreAllMocks(); + }); + + it("defines the custom element idempotently", () => { + const first = defineVibiumRecordPlayerElement(); + const second = defineVibiumRecordPlayerElement(); + expect(second).toBe(first); + expect(customElements.get("vibium-record-player")).toBe(first); + }); + + it("upgrades and dispatches an error event when loading fails", async () => { + defineVibiumRecordPlayerElement(); + vi.stubGlobal("fetch", vi.fn(async () => okZipResponse())); + const errorHandler = vi.fn(); + const nativeErrorHandler = vi.fn(); + + const element = document.createElement("vibium-record-player"); + element.setAttribute("src", "https://example.test/bad.zip"); + element.setAttribute("inspector", "hidden"); + element.addEventListener("vibium-player-error", errorHandler); + element.addEventListener("error", nativeErrorHandler); + + document.body.appendChild(element); + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(errorHandler).toHaveBeenCalledTimes(1); + expect(nativeErrorHandler).toHaveBeenCalledTimes(1); + expect(errorHandler.mock.calls[0][0].detail.error).toBeInstanceOf(Error); + element.setAttribute("timeline", "hidden"); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(fetch).toHaveBeenCalledTimes(1); + document.body.removeChild(element); + }); +}); From 857ab0f38475a78b6c903c39983d111b70454d30 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Mon, 15 Jun 2026 23:53:05 +0000 Subject: [PATCH 2/4] Polish embedded record player presentation --- .gitignore | 1 + package.json | 1 + src/packages/player-element/register.ts | 3 ++ src/packages/player-react/index.tsx | 41 ++++++++++++++++++++----- vite.element.config.ts | 24 +++++++++++++++ 5 files changed, 62 insertions(+), 8 deletions(-) create mode 100644 src/packages/player-element/register.ts create mode 100644 vite.element.config.ts diff --git a/.gitignore b/.gitignore index a547bf3..6a7f7ba 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ lerna-debug.log* node_modules dist +dist-element dist-ssr *.local diff --git a/package.json b/package.json index 389282f..12ad429 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "build:dev": "vite build --mode development", + "build:element": "vite build --config vite.element.config.ts", "lint": "eslint .", "preview": "vite preview", "test": "vitest run", diff --git a/src/packages/player-element/register.ts b/src/packages/player-element/register.ts new file mode 100644 index 0000000..bf9c1b8 --- /dev/null +++ b/src/packages/player-element/register.ts @@ -0,0 +1,3 @@ +import { defineVibiumRecordPlayerElement } from "./index"; + +defineVibiumRecordPlayerElement(); diff --git a/src/packages/player-react/index.tsx b/src/packages/player-react/index.tsx index 899d646..4a3f8cd 100644 --- a/src/packages/player-react/index.tsx +++ b/src/packages/player-react/index.tsx @@ -47,18 +47,32 @@ export function RecordPlayer({ recording, inspector = true, timeline = true, cla ); return ( -
-
-
+
+
+

Vibium Record Player

-
+
{recording.source ? `${recording.source} · ` : ""} {recording.metadata.fileCount} files · {recording.metadata.eventCount} events · {formatMs(recording.timeline.duration)}
-
+
{Object.entries(counts).map(([kind, count]) => ( - + {kind}: {count} ))} @@ -99,10 +113,21 @@ export function RecordPlayer({ recording, inspector = true, timeline = true, cla
Events
    {events.slice(0, 200).map((event) => ( -
  1. +
  2. {event.kind} - {event.title ?? event.method ?? event.type ?? event.id} + {event.title ?? event.method ?? event.type ?? event.id}
  3. ))}
diff --git a/vite.element.config.ts b/vite.element.config.ts new file mode 100644 index 0000000..5e51cdc --- /dev/null +++ b/vite.element.config.ts @@ -0,0 +1,24 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react-swc"; +import path from "node:path"; + +export default defineConfig({ + define: { + "process.env.NODE_ENV": JSON.stringify("production"), + }, + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + build: { + lib: { + entry: path.resolve(__dirname, "src/packages/player-element/register.ts"), + formats: ["es"], + fileName: () => "vibium-record-player.js", + }, + outDir: "dist-element", + emptyOutDir: true, + }, +}); From 66023e3f90da89293182b6dc86c9e44e17436223 Mon Sep 17 00:00:00 2001 From: "exe.dev user" Date: Mon, 15 Jun 2026 23:59:11 +0000 Subject: [PATCH 3/4] Add playback controls to embedded player --- src/packages/player-react/index.tsx | 77 +++++++++++++++++++++++++++-- src/test/player-react.test.tsx | 54 ++++++++++++++++++++ 2 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 src/test/player-react.test.tsx diff --git a/src/packages/player-react/index.tsx b/src/packages/player-react/index.tsx index 4a3f8cd..6e66759 100644 --- a/src/packages/player-react/index.tsx +++ b/src/packages/player-react/index.tsx @@ -36,7 +36,14 @@ export function RecordPlayer({ recording, inspector = true, timeline = true, cla const showInspector = optionVisible(inspector); const showTimeline = optionVisible(timeline); const events = recording.timeline.events; - const firstScreenshot = recording.timeline.screenshots.find((screenshot) => screenshot.dataUrl); + const screenshots = useMemo(() => recording.timeline.screenshots.filter((screenshot) => screenshot.dataUrl), [recording.timeline.screenshots]); + const duration = Math.max(0, recording.timeline.duration || 0); + const [currentTime, setCurrentTime] = useState(0); + const [playing, setPlaying] = useState(false); + const currentScreenshot = useMemo(() => { + if (!screenshots.length) return undefined; + return screenshots.reduce((current, screenshot) => (screenshot.time <= currentTime ? screenshot : current), screenshots[0]); + }, [currentTime, screenshots]); const counts = useMemo( () => events.reduce>((acc, event) => { @@ -46,6 +53,32 @@ export function RecordPlayer({ recording, inspector = true, timeline = true, cla [events], ); + useEffect(() => { + setCurrentTime(0); + setPlaying(false); + }, [recording]); + + useEffect(() => { + if (!playing) return; + const startedAt = performance.now(); + const initialTime = currentTime; + const interval = window.setInterval(() => { + setCurrentTime(() => { + const next = Math.min(duration, initialTime + performance.now() - startedAt); + if (next >= duration) { + window.clearInterval(interval); + setPlaying(false); + } + return next; + }); + }, 100); + return () => window.clearInterval(interval); + }, [currentTime, duration, playing]); + + const seek = (value: number) => { + setCurrentTime(Math.min(duration, Math.max(0, value))); + }; + return (
- {firstScreenshot?.dataUrl ? ( +
+ + +
+ + {currentScreenshot?.dataUrl ? (
- First recording screenshot -
Screenshot at {formatMs(firstScreenshot.time)}
+ Current recording screenshot +
Screenshot at {formatMs(currentScreenshot.time)}
) : null} diff --git a/src/test/player-react.test.tsx b/src/test/player-react.test.tsx new file mode 100644 index 0000000..45f1060 --- /dev/null +++ b/src/test/player-react.test.tsx @@ -0,0 +1,54 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { RecordPlayer } from "../packages/player-react"; +import type { LoadedRecording } from "../packages/player-core"; + +function recording(): LoadedRecording { + return { + version: 1, + source: "sample.zip", + files: ["trace.trace", "resources/page.jpeg"], + metadata: { + fileCount: 2, + eventCount: 2, + traceEventCount: 2, + networkEventCount: 0, + }, + timeline: { + startTime: 0, + endTime: 1000, + duration: 1000, + events: [ + { id: "event-1", kind: "action", type: "click", title: "Click button", time: 0, data: {} }, + { id: "event-2", kind: "screenshot", type: "screencast-frame", title: "Frame", time: 1000, data: {} }, + ], + screenshots: [ + { id: "screenshot-1", sha1: "first", time: 0, mimeType: "image/jpeg", dataUrl: "data:image/jpeg;base64,first" }, + { id: "screenshot-2", sha1: "second", time: 1000, mimeType: "image/jpeg", dataUrl: "data:image/jpeg;base64,second" }, + ], + }, + raw: { traceEvents: [], networkEvents: [] }, + }; +} + +describe("RecordPlayer", () => { + it("shows playback controls and toggles play state", () => { + render(); + + const play = screen.getByRole("button", { name: "Play recording" }); + expect(play).toBeInTheDocument(); + + fireEvent.click(play); + + expect(screen.getByRole("button", { name: "Pause recording" })).toBeInTheDocument(); + }); + + it("seeks to screenshots with the playback slider", () => { + render(); + + fireEvent.change(screen.getByRole("slider", { name: "Playback position" }), { target: { value: "1000" } }); + + expect(screen.getByText("Screenshot at 1.00s")).toBeInTheDocument(); + expect(screen.getByAltText("Current recording screenshot")).toHaveAttribute("src", "data:image/jpeg;base64,second"); + }); +}); From e6d84a6ee251ba70a48f56d9b29927db81e3ad54 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Wed, 17 Jun 2026 18:19:03 -0500 Subject: [PATCH 4/4] Graft PR#5 wins onto reusable record player packages - RecordStudio: use bundled jszip instead of runtime cdnjs loader (+regression test) - player-core: fix numberFrom treating null/empty string as 0 - player-element: drop dead storage-key attribute; add happy-path render test - vite.element.config: copyPublicDir:false to keep demo zips out of dist-element --- src/components/RecordStudio.jsx | 15 ++++------ src/packages/player-core/index.ts | 4 +++ src/packages/player-element/index.tsx | 4 +-- src/test/player-element.test.tsx | 41 ++++++++++++++++++++++++++- src/test/record-studio.test.ts | 9 ++++++ vite.element.config.ts | 3 ++ 6 files changed, 63 insertions(+), 13 deletions(-) diff --git a/src/components/RecordStudio.jsx b/src/components/RecordStudio.jsx index e5f781e..cdbdc06 100644 --- a/src/components/RecordStudio.jsx +++ b/src/components/RecordStudio.jsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useMemo, useCallback, forwardRef, useImperativeHandle } from "react"; +import JSZip from "jszip"; /* Vibium Player — player.vibium.dev @@ -8,18 +9,14 @@ import { useState, useRef, useEffect, useMemo, useCallback, forwardRef, useImper files and extracts screenshots from resources. */ -// We'll load JSZip from CDN at runtime +// Prefer an explicitly injected global (tests and embedding pages can set +// window.JSZip), otherwise use the bundled dependency so the player has no +// runtime CDN requirement. let JSZipLoaded = null; function loadJSZip() { if (JSZipLoaded) return JSZipLoaded; - JSZipLoaded = new Promise((resolve, reject) => { - if (window.JSZip) return resolve(window.JSZip); - const s = document.createElement("script"); - s.src = "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"; - s.onload = () => resolve(window.JSZip); - s.onerror = reject; - document.head.appendChild(s); - }); + const injected = typeof window !== "undefined" ? window.JSZip : undefined; + JSZipLoaded = Promise.resolve(injected || JSZip); return JSZipLoaded; } diff --git a/src/packages/player-core/index.ts b/src/packages/player-core/index.ts index 0fe1d41..c073f53 100644 --- a/src/packages/player-core/index.ts +++ b/src/packages/player-core/index.ts @@ -113,6 +113,10 @@ function asRecord(value: unknown): Record { function numberFrom(...values: unknown[]): number | undefined { for (const value of values) { + // Skip empty values: Number(null), Number(""), and Number(" ") all coerce + // to 0, which would otherwise masquerade as a real timeline time of 0. + if (value == null) continue; + if (typeof value === "string" && value.trim() === "") continue; const n = Number(value); if (Number.isFinite(n)) return n; } diff --git a/src/packages/player-element/index.tsx b/src/packages/player-element/index.tsx index 161f2fe..b9f45f9 100644 --- a/src/packages/player-element/index.tsx +++ b/src/packages/player-element/index.tsx @@ -23,7 +23,7 @@ function visibilityAttribute(value: string | null): boolean | "visible" | "hidde export class VibiumRecordPlayerElement extends HTMLElement { static get observedAttributes() { - return ["src", "inspector", "timeline", "storage-key"]; + return ["src", "inspector", "timeline"]; } private root: Root | null = null; @@ -52,7 +52,6 @@ export class VibiumRecordPlayerElement extends HTMLElement { const src = this.getAttribute("src"); const inspector = visibilityAttribute(this.getAttribute("inspector")); const timeline = visibilityAttribute(this.getAttribute("timeline")); - const storageKey = this.getAttribute("storage-key") ?? undefined; if (!src) { this.root.render(
Missing recording src
); @@ -65,7 +64,6 @@ export class VibiumRecordPlayerElement extends HTMLElement { src={src} inspector={inspector} timeline={timeline} - storageKey={storageKey} onReady={(recording) => { const detail: VibiumRecordPlayerReadyDetail = { recording }; this.dispatchEvent( diff --git a/src/test/player-element.test.tsx b/src/test/player-element.test.tsx index f5c0f3a..2ecace3 100644 --- a/src/test/player-element.test.tsx +++ b/src/test/player-element.test.tsx @@ -1,4 +1,7 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { act, screen, waitFor } from "@testing-library/react"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { defineVibiumRecordPlayerElement } from "../packages/player-element"; function okZipResponse() { @@ -12,6 +15,10 @@ describe("player-element", () => { vi.restoreAllMocks(); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("defines the custom element idempotently", () => { const first = defineVibiumRecordPlayerElement(); const second = defineVibiumRecordPlayerElement(); @@ -19,6 +26,38 @@ describe("player-element", () => { expect(customElements.get("vibium-record-player")).toBe(first); }); + it("renders the player and dispatches a ready event for a valid recording", async () => { + defineVibiumRecordPlayerElement(); + const data = await readFile(resolve(process.cwd(), "public/vibium-demo-record.zip")); + vi.stubGlobal("fetch", vi.fn(async () => new Response(data, { status: 200, statusText: "OK" }))); + const readyHandler = vi.fn(); + + const element = document.createElement("vibium-record-player"); + element.setAttribute("src", "https://example.test/record.zip"); + element.addEventListener("vibium-player-ready", readyHandler); + + act(() => { + document.body.appendChild(element); + }); + + expect(await screen.findByRole("button", { name: "Play recording" })).toBeInTheDocument(); + await waitFor(() => { + expect(readyHandler).toHaveBeenCalledTimes(1); + }); + + const detail = readyHandler.mock.calls[0][0].detail; + expect(detail.recording.version).toBe(1); + expect(detail.recording.timeline.events.length).toBeGreaterThan(0); + expect(fetch).toHaveBeenCalledWith("https://example.test/record.zip", { + credentials: "same-origin", + signal: expect.any(AbortSignal), + }); + + act(() => { + document.body.removeChild(element); + }); + }); + it("upgrades and dispatches an error event when loading fails", async () => { defineVibiumRecordPlayerElement(); vi.stubGlobal("fetch", vi.fn(async () => okZipResponse())); diff --git a/src/test/record-studio.test.ts b/src/test/record-studio.test.ts index 8215c43..9232cb4 100644 --- a/src/test/record-studio.test.ts +++ b/src/test/record-studio.test.ts @@ -1,3 +1,5 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { __recordStudioInternals } from "../components/RecordStudio"; @@ -5,6 +7,13 @@ const { finiteTimelineTimes, harMonotonicTimeToMs, harSnapshotStartTimeToMs, nor __recordStudioInternals; describe("RecordStudio trace timing", () => { + it("uses the bundled JSZip dependency instead of loading JSZip from a runtime CDN", async () => { + const source = await readFile(resolve(process.cwd(), "src/components/RecordStudio.jsx"), "utf8"); + + expect(source).toMatch(/from "jszip"/); + expect(source).not.toContain("cdnjs.cloudflare.com/ajax/libs/jszip"); + }); + it("reads HAR monotonic time as ms, rescaling only legacy epoch-seconds", () => { // Relative-ms (Playwright/current recordings, << 1e9) — returned raw, no scaling. expect(harMonotonicTimeToMs(915)).toBe(915); diff --git a/vite.element.config.ts b/vite.element.config.ts index 5e51cdc..996af6e 100644 --- a/vite.element.config.ts +++ b/vite.element.config.ts @@ -13,6 +13,9 @@ export default defineConfig({ }, }, build: { + // The element bundle ships only the compiled component, not the hosted + // app's demo recordings under public/. + copyPublicDir: false, lib: { entry: path.resolve(__dirname, "src/packages/player-element/register.ts"), formats: ["es"],