import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { initSandboxRuntimeModular } from "./init"; import type { RuntimeTimelineLike } from "./types"; function createMockTimeline(duration: number): RuntimeTimelineLike { const state = { time: 0, paused: true, duration }; return { play: () => { state.paused = false; }, pause: () => { state.paused = true; }, seek: (time: number) => { state.time = time; }, totalTime: (time: number) => { state.time = time; }, time: () => state.time, duration: () => state.duration, add: () => {}, paused: (value?: boolean) => { if (typeof value === "boolean") { state.paused = value; } return state.paused; }, timeScale: () => {}, set: () => {}, getChildren: () => [], }; } function createPaddableMockTimeline(duration: number): RuntimeTimelineLike { const timeline = createMockTimeline(duration) as RuntimeTimelineLike & { to: (_target: object, vars: { duration: number }, position: number) => void; }; const baseDuration = timeline.duration; let paddedDuration = baseDuration(); timeline.duration = () => paddedDuration; timeline.to = (_target, vars, position) => { paddedDuration = Math.max(paddedDuration, position + Math.max(0, Number(vars.duration) || 0)); }; return timeline; } function createManualRaf() { let now = 0; let nextId = 0; const callbacks = new Map(); return { requestAnimationFrame: (callback: FrameRequestCallback) => { nextId += 1; callbacks.set(nextId, callback); return nextId; }, cancelAnimationFrame: (id: number) => { callbacks.delete(id); }, step: (milliseconds: number) => { now += milliseconds; const pending = Array.from(callbacks.entries()); callbacks.clear(); for (const [, callback] of pending) { callback(now); } }, now: () => now, }; } describe("initSandboxRuntimeModular", () => { const originalRequestAnimationFrame = window.requestAnimationFrame; const originalCancelAnimationFrame = window.cancelAnimationFrame; beforeEach(() => { document.body.innerHTML = ""; (globalThis as typeof globalThis & { CSS?: { escape?: (value: string) => string } }).CSS ??= {}; globalThis.CSS.escape ??= (value: string) => value; window.requestAnimationFrame = ((callback: FrameRequestCallback) => { callback(0); return 1; }) as typeof window.requestAnimationFrame; window.cancelAnimationFrame = (() => {}) as typeof window.cancelAnimationFrame; }); afterEach(() => { (window as Window & { __hfRuntimeTeardown?: (() => void) | null }).__hfRuntimeTeardown?.(); document.body.innerHTML = ""; delete (window as Window & { __timelines?: Record }).__timelines; delete (window as Window & { __player?: unknown }).__player; delete (window as Window & { __playerReady?: boolean }).__playerReady; delete (window as Window & { __renderReady?: boolean }).__renderReady; vi.restoreAllMocks(); window.requestAnimationFrame = originalRequestAnimationFrame; window.cancelAnimationFrame = originalCancelAnimationFrame; }); it("uses the shorter live child timeline when the authored window is longer", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); root.setAttribute("data-root", "true"); root.setAttribute("data-start", "0"); root.setAttribute("data-width", "1920"); root.setAttribute("data-height", "1080"); document.body.appendChild(root); const child = document.createElement("div"); child.setAttribute("data-composition-id", "slide-1"); child.setAttribute("data-start", "0"); child.setAttribute("data-hf-authored-duration", "14"); root.appendChild(child); (window as Window & { __timelines?: Record }).__timelines = { main: createMockTimeline(20), "slide-1": createMockTimeline(8), }; initSandboxRuntimeModular(); const player = ( window as Window & { __player?: { renderSeek: (timeSeconds: number) => void }; } ).__player; expect(player).toBeDefined(); player?.renderSeek(9); expect(child.style.visibility).toBe("hidden"); }); it("uses the shorter authored host window when the child timeline is longer", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); root.setAttribute("data-root", "true"); root.setAttribute("data-start", "0"); root.setAttribute("data-width", "1920"); root.setAttribute("data-height", "1080"); document.body.appendChild(root); const child = document.createElement("div"); child.setAttribute("data-composition-id", "slide-1"); child.setAttribute("data-start", "0"); child.setAttribute("data-hf-authored-duration", "2"); root.appendChild(child); (window as Window & { __timelines?: Record }).__timelines = { main: createMockTimeline(20), "slide-1": createMockTimeline(8), }; initSandboxRuntimeModular(); const player = ( window as Window & { __player?: { renderSeek: (timeSeconds: number) => void }; } ).__player; expect(player).toBeDefined(); player?.renderSeek(3); expect(child.style.visibility).toBe("hidden"); }); it("keeps external composition hosts visible through their authored duration", async () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); root.setAttribute("data-root", "true"); root.setAttribute("data-start", "0"); root.setAttribute("data-width", "1920"); root.setAttribute("data-height", "1080"); document.body.appendChild(root); const child = document.createElement("div"); child.setAttribute("data-composition-id", "sub"); child.setAttribute("data-composition-src", "compositions/sub.html"); child.setAttribute("data-start", "0"); child.setAttribute("data-duration", "3"); root.appendChild(child); const template = document.createElement("template"); template.id = "sub-template"; template.innerHTML = `
HOLD ME
`; document.body.appendChild(template); (window as Window & { __timelines?: Record }).__timelines = { main: createMockTimeline(3), sub: createMockTimeline(1), }; initSandboxRuntimeModular(); await new Promise((resolve) => window.setTimeout(resolve, 0)); const player = ( window as Window & { __player?: { renderSeek: (timeSeconds: number) => void }; } ).__player; expect(player).toBeDefined(); expect(child.querySelector("#hold-marker")?.textContent).toBe("HOLD ME"); player?.renderSeek(2); expect(child.style.visibility).toBe("visible"); }); it("pads the root timeline to the authored composition schedule before seeking visibility", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); root.setAttribute("data-root", "true"); root.setAttribute("data-start", "0"); root.setAttribute("data-width", "1920"); root.setAttribute("data-height", "1080"); document.body.appendChild(root); const slide1 = document.createElement("div"); slide1.id = "slide-1"; slide1.setAttribute("data-composition-id", "slide-1"); slide1.setAttribute("data-start", "0"); slide1.setAttribute("data-hf-authored-duration", "14"); root.appendChild(slide1); const slide2 = document.createElement("div"); slide2.id = "slide-2"; slide2.setAttribute("data-composition-id", "slide-2"); slide2.setAttribute("data-start", "slide-1"); slide2.setAttribute("data-hf-authored-duration", "12"); root.appendChild(slide2); const slide3 = document.createElement("div"); slide3.id = "slide-3"; slide3.setAttribute("data-composition-id", "slide-3"); slide3.setAttribute("data-start", "slide-2"); slide3.setAttribute("data-hf-authored-duration", "16"); root.appendChild(slide3); (window as Window & { __timelines?: Record }).__timelines = { main: createPaddableMockTimeline(14), }; initSandboxRuntimeModular(); const player = ( window as Window & { __player?: { getDuration: () => number; seek: (timeSeconds: number) => void }; } ).__player; expect(player).toBeDefined(); expect(player?.getDuration()).toBe(42); player?.seek(30); expect(root.style.visibility).toBe("visible"); expect(slide1.style.visibility).toBe("hidden"); expect(slide2.style.visibility).toBe("hidden"); expect(slide3.style.visibility).toBe("visible"); }); it("pauses nested media that is outside the timed-media cache after a seek", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); root.setAttribute("data-root", "true"); root.setAttribute("data-width", "1920"); root.setAttribute("data-height", "1080"); document.body.appendChild(root); const child = document.createElement("div"); child.setAttribute("data-composition-id", "slide-translation"); child.setAttribute("data-start", "20"); child.setAttribute("data-duration", "16"); root.appendChild(child); const video = document.createElement("video"); child.appendChild(video); Object.defineProperty(video, "duration", { value: 20, writable: true, configurable: true }); Object.defineProperty(video, "paused", { value: false, writable: true, configurable: true }); Object.defineProperty(video, "readyState", { value: 4, writable: true, configurable: true }); Object.defineProperty(video, "currentTime", { value: 0, writable: true, configurable: true }); const pause = () => { Object.defineProperty(video, "paused", { value: true, writable: true, configurable: true }); }; video.load = () => {}; video.pause = pause; (window as Window & { __timelines?: Record }).__timelines = { main: createMockTimeline(40), "slide-translation": createMockTimeline(16), }; initSandboxRuntimeModular(); const player = ( window as Window & { __player?: { seek: (timeSeconds: number) => void }; } ).__player; expect(player).toBeDefined(); player?.seek(29); expect(video.paused).toBe(true); expect(video.currentTime).toBe(9); }); it("updates visibility for timed elements inside nested compositions", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); root.setAttribute("data-root", "true"); root.setAttribute("data-start", "0"); root.setAttribute("data-width", "1920"); root.setAttribute("data-height", "1080"); document.body.appendChild(root); const child = document.createElement("div"); child.setAttribute("data-composition-id", "nested"); child.setAttribute("data-start", "10"); child.setAttribute("data-duration", "10"); root.appendChild(child); const sceneA = document.createElement("section"); sceneA.id = "scene-a"; sceneA.setAttribute("data-start", "0"); sceneA.setAttribute("data-duration", "4"); child.appendChild(sceneA); const sceneB = document.createElement("section"); sceneB.id = "scene-b"; sceneB.setAttribute("data-start", "4"); sceneB.setAttribute("data-duration", "4"); child.appendChild(sceneB); (window as Window & { __timelines?: Record }).__timelines = { main: createMockTimeline(20), nested: createMockTimeline(8), }; initSandboxRuntimeModular(); const player = ( window as Window & { __player?: { seek: (timeSeconds: number) => void }; } ).__player; expect(player).toBeDefined(); player?.seek(11); expect(sceneA.style.visibility).toBe("visible"); expect(sceneB.style.visibility).toBe("hidden"); player?.seek(15); expect(sceneA.style.visibility).toBe("hidden"); expect(sceneB.style.visibility).toBe("visible"); }); it("clamps nested media to the authored host window on seek", () => { const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); root.setAttribute("data-root", "true"); root.setAttribute("data-width", "1920"); root.setAttribute("data-height", "1080"); document.body.appendChild(root); const child = document.createElement("div"); child.setAttribute("data-composition-id", "slide-translation"); child.setAttribute("data-start", "20"); child.setAttribute("data-duration", "16"); root.appendChild(child); const video = document.createElement("video"); child.appendChild(video); Object.defineProperty(video, "duration", { value: 20, writable: true, configurable: true }); Object.defineProperty(video, "paused", { value: false, writable: true, configurable: true }); Object.defineProperty(video, "readyState", { value: 4, writable: true, configurable: true }); Object.defineProperty(video, "currentTime", { value: 0, writable: true, configurable: true }); const pause = () => { Object.defineProperty(video, "paused", { value: true, writable: true, configurable: true }); }; video.load = () => {}; video.pause = pause; (window as Window & { __timelines?: Record }).__timelines = { main: createMockTimeline(40), "slide-translation": createMockTimeline(16), }; initSandboxRuntimeModular(); const player = ( window as Window & { __player?: { seek: (timeSeconds: number) => void }; } ).__player; expect(player).toBeDefined(); player?.seek(37); expect(video.paused).toBe(true); expect(video.currentTime).toBe(0); }); it("plays scheduled child timelines without a captured root timeline when audio has failed", () => { const raf = createManualRaf(); vi.spyOn(performance, "now").mockImplementation(() => raf.now()); window.requestAnimationFrame = raf.requestAnimationFrame as typeof window.requestAnimationFrame; window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame; const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); root.setAttribute("data-root", "true"); root.setAttribute("data-start", "0"); root.setAttribute("data-duration", "4"); root.setAttribute("data-width", "1920"); root.setAttribute("data-height", "1080"); document.body.appendChild(root); const child = document.createElement("div"); child.setAttribute("data-composition-id", "scene"); child.setAttribute("data-start", "0"); child.setAttribute("data-duration", "4"); root.appendChild(child); const audio = document.createElement("audio"); audio.setAttribute("data-start", "0"); audio.setAttribute("data-duration", "4"); Object.defineProperty(audio, "error", { value: { code: 4, message: "format error" }, configurable: true, }); Object.defineProperty(audio, "networkState", { value: HTMLMediaElement.NETWORK_NO_SOURCE, configurable: true, }); Object.defineProperty(audio, "readyState", { value: HTMLMediaElement.HAVE_NOTHING, configurable: true, }); Object.defineProperty(audio, "paused", { value: true, configurable: true }); Object.defineProperty(audio, "currentTime", { value: 0, writable: true, configurable: true }); audio.load = () => {}; audio.play = vi.fn(() => Promise.reject(new Error("format error"))); root.appendChild(audio); const childTimeline = createMockTimeline(4); (window as Window & { __timelines?: Record }).__timelines = { scene: childTimeline, }; initSandboxRuntimeModular(); const player = ( window as Window & { __player?: { play: () => void; getTime: () => number; isPlaying: () => boolean }; } ).__player; expect(player).toBeDefined(); player?.play(); raf.step(1_000); expect(player?.isPlaying()).toBe(true); expect(player?.getTime()).toBeCloseTo(1, 1); expect(childTimeline.time()).toBeCloseTo(1, 1); }); });