From 3a27c2bc319ac75766353209494b29a98a9fcd96 Mon Sep 17 00:00:00 2001 From: Paul Smith Date: Tue, 9 Jun 2026 16:03:37 -0500 Subject: [PATCH] Add skip idle playback option --- src/components/CompareStudio.jsx | 34 +++- src/components/RecordStudio.jsx | 193 +++++++++++++++++++-- src/test/record-studio-console-ui.test.tsx | 153 ++++++++++++++++ src/test/record-studio.test.ts | 74 +++++++- 4 files changed, 432 insertions(+), 22 deletions(-) diff --git a/src/components/CompareStudio.jsx b/src/components/CompareStudio.jsx index e4883b6..9f62c49 100644 --- a/src/components/CompareStudio.jsx +++ b/src/components/CompareStudio.jsx @@ -44,6 +44,7 @@ export default function CompareStudio() { const [isPlaying, setIsPlaying] = useState(false); const [speed, setSpeed] = useState(1); const [loop, setLoop] = useState(false); + const [skipIdle, setSkipIdle] = useState(false); const [overlayEnabled, setOverlayEnabled] = useState(true); const [playhead, setPlayhead] = useState(0); const [duration, setDuration] = useState(0); @@ -55,6 +56,7 @@ export default function CompareStudio() { const V = useMemo(() => ({ ...brand, ...(dark ? darkSurface : lightSurface) }), [dark]); const bothLoaded = leftFile && rightFile; + const both = useCallback((fn) => { fn(leftRef.current); fn(rightRef.current); }, []); // Poll playhead from left player to keep shared display in sync useEffect(() => { @@ -72,9 +74,12 @@ export default function CompareStudio() { return () => cancelAnimationFrame(pollRef.current); }, [bothLoaded]); - // Shared control helpers - const both = (fn) => { fn(leftRef.current); fn(rightRef.current); }; + useEffect(() => { + if (!bothLoaded) return; + both((r) => r?.setSkipIdle?.(skipIdle)); + }, [both, bothLoaded, skipIdle]); + // Shared control helpers const togglePlay = () => { both((r) => r?.togglePlay?.()); }; @@ -93,6 +98,11 @@ export default function CompareStudio() { setLoop(next); both((r) => r?.setLoop?.(next)); }; + const toggleSkipIdle = () => { + const next = !skipIdle; + setSkipIdle(next); + both((r) => r?.setSkipIdle?.(next)); + }; const handleDrop = useCallback((side) => (e) => { e.preventDefault(); @@ -330,6 +340,26 @@ export default function CompareStudio() { > ⟲ +
{ beforeEach(() => { window.history.pushState({}, "", "/?i=v&t=h&c=h"); + Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 768 }); localStorage.clear(); vi.stubGlobal( "ResizeObserver", @@ -63,4 +66,154 @@ describe("RecordStudio console UI", () => { expect(screen.getByText(/at renderApp \(http:\/\/localhost:5173\/src\/App.js:12:5\)/)).toBeInTheDocument(); }); }); + + it("shows a toggle for skip-idle playback", async () => { + window.history.pushState({}, "", "/?i=h&t=h&c=v"); + const traceLines = [ + { + type: "before", + title: "Page.navigate", + callId: "call@1", + startTime: 0, + class: "Page", + method: "vibium:page.navigate", + params: { url: "http://localhost:5173" }, + }, + { type: "after", callId: "call@1", endTime: 100 }, + { + type: "before", + title: "Page.wait", + callId: "call@2", + startTime: 5000, + class: "Page", + method: "vibium:page.wait", + params: { text: "Ready" }, + }, + { type: "after", callId: "call@2", endTime: 5100 }, + ] + .map((event) => JSON.stringify(event)) + .join("\n"); + + vi.stubGlobal("JSZip", { + loadAsync: vi.fn(async () => ({ + files: { + "trace.trace": { + dir: false, + async: vi.fn(async () => traceLines), + }, + }, + })), + }); + + render(); + + const skipIdle = await screen.findByRole("button", { name: "Skip idle" }); + expect(skipIdle).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click(skipIdle); + + expect(skipIdle).toHaveAttribute("aria-pressed", "true"); + }); + + it("shows the skip-idle toggle on mobile", async () => { + Object.defineProperty(window, "innerWidth", { configurable: true, value: 390 }); + Object.defineProperty(window, "innerHeight", { configurable: true, value: 844 }); + window.history.pushState({}, "", "/?i=h&t=h&c=v"); + const traceLines = [ + { + type: "before", + title: "Page.navigate", + callId: "call@1", + startTime: 0, + class: "Page", + method: "vibium:page.navigate", + params: { url: "http://localhost:5173" }, + }, + { type: "after", callId: "call@1", endTime: 100 }, + { + type: "before", + title: "Page.wait", + callId: "call@2", + startTime: 5000, + class: "Page", + method: "vibium:page.wait", + params: { text: "Ready" }, + }, + { type: "after", callId: "call@2", endTime: 5100 }, + ] + .map((event) => JSON.stringify(event)) + .join("\n"); + + vi.stubGlobal("JSZip", { + loadAsync: vi.fn(async () => ({ + files: { + "trace.trace": { + dir: false, + async: vi.fn(async () => traceLines), + }, + }, + })), + }); + + render(); + + const skipIdle = await screen.findByRole("button", { name: "Skip idle" }); + expect(skipIdle).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click(skipIdle); + + expect(skipIdle).toHaveAttribute("aria-pressed", "true"); + }); + + it("shows a shared skip-idle toggle in compare mode", async () => { + const traceLines = [ + { + type: "before", + title: "Page.navigate", + callId: "call@1", + startTime: 0, + class: "Page", + method: "vibium:page.navigate", + params: { url: "http://localhost:5173" }, + }, + { type: "after", callId: "call@1", endTime: 100 }, + { + type: "before", + title: "Page.wait", + callId: "call@2", + startTime: 5000, + class: "Page", + method: "vibium:page.wait", + params: { text: "Ready" }, + }, + { type: "after", callId: "call@2", endTime: 5100 }, + ] + .map((event) => JSON.stringify(event)) + .join("\n"); + + vi.stubGlobal("JSZip", { + loadAsync: vi.fn(async () => ({ + files: { + "trace.trace": { + dir: false, + async: vi.fn(async () => traceLines), + }, + }, + })), + }); + + const { container } = render(); + const inputs = Array.from(container.querySelectorAll("input[type='file']")); + expect(inputs).toHaveLength(2); + + fireEvent.change(inputs[0], { target: { files: [new File(["zip"], "before.zip", { type: "application/zip" })] } }); + fireEvent.change(inputs[1], { target: { files: [new File(["zip"], "after.zip", { type: "application/zip" })] } }); + + const skipIdle = await screen.findByRole("button", { name: "Skip idle" }); + expect(skipIdle).toHaveAttribute("aria-pressed", "false"); + + fireEvent.click(skipIdle); + + expect(skipIdle).toHaveAttribute("aria-pressed", "true"); + }); }); diff --git a/src/test/record-studio.test.ts b/src/test/record-studio.test.ts index 8215c43..42d7dff 100644 --- a/src/test/record-studio.test.ts +++ b/src/test/record-studio.test.ts @@ -1,7 +1,16 @@ import { describe, expect, it } from "vitest"; import { __recordStudioInternals } from "../components/RecordStudio"; -const { finiteTimelineTimes, harMonotonicTimeToMs, harSnapshotStartTimeToMs, normalizeActionCoords, processNetworkEvents } = +const { + advancePlayheadWithSkip, + buildSkipIdleSegments, + finiteTimelineTimes, + harMonotonicTimeToMs, + harSnapshotStartTimeToMs, + normalizeActionCoords, + processTraceEvents, + processNetworkEvents, +} = __recordStudioInternals; describe("RecordStudio trace timing", () => { @@ -61,6 +70,69 @@ describe("RecordStudio trace timing", () => { expect(finiteTimelineTimes([0, 1, NaN, Infinity, -Infinity, "2"])).toEqual([0, 1, 2]); }); + it("builds skip-idle segments from gaps between recorded activity", () => { + const segments = buildSkipIdleSegments( + { + duration: 10700, + actions: [ + { startTime: 0, endTime: 250 }, + { startTime: 10500, endTime: 10700 }, + ], + screenshots: [{ time: 300 }, { time: 10300 }], + console: [{ time: 500 }], + network: [], + groups: [], + }, + { thresholdMs: 2000, paddingMs: 300 }, + ); + + expect(segments).toEqual([{ start: 800, end: 10000, duration: 9200 }]); + }); + + it("does not skip across DOM snapshot activity", () => { + const segments = buildSkipIdleSegments( + { + duration: 10700, + actions: [ + { startTime: 0, endTime: 250 }, + { startTime: 10500, endTime: 10700 }, + ], + domSnapshots: [{ time: 5200 }], + screenshots: [], + console: [], + network: [], + groups: [], + }, + { thresholdMs: 2000, paddingMs: 300 }, + ); + + expect(segments).toEqual([ + { start: 550, end: 4900, duration: 4350 }, + { start: 5500, end: 10200, duration: 4700 }, + ]); + }); + + it("preserves frame-snapshot timestamps as DOM activity", () => { + const { domSnapshots } = processTraceEvents([ + { + type: "frame-snapshot", + timestamp: 5200, + snapshotName: "after-click", + pageId: "page@1", + }, + ]); + + expect(domSnapshots).toEqual([{ time: 5200, pageId: "page@1", name: "after-click" }]); + }); + + it("advances over skipped idle time without spending playback time inside it", () => { + const segments = [{ start: 2000, end: 10000, duration: 8000 }]; + + expect(advancePlayheadWithSkip(1900, 2100, segments)).toBe(10100); + expect(advancePlayheadWithSkip(5000, 5200, segments)).toBe(10200); + expect(advancePlayheadWithSkip(11000, 11200, segments)).toBe(11200); + }); + it("uses inferred action coordinate boost for viewport-sized screenshots", () => { const norm = normalizeActionCoords({ action: {