import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { formatTime, formatSpeed, SPEED_PRESETS } from "./controls.js"; // Install a stubbed contentDocument getter on the given iframe element. The // new stopMedia / muted tests repeat this `Object.defineProperty(... { get })` // shape; routing through a named helper keeps the per-test bodies focused on // the actual assertion. function stubIframeContentDocument(iframe: HTMLIFrameElement, doc: Document): void { Object.defineProperty(iframe, "contentDocument", { configurable: true, get: () => doc, }); } function createForeignFrameMediaDocument(): { doc: Document; video: HTMLMediaElement & { pause: ReturnType }; audio: HTMLMediaElement & { pause: ReturnType }; } { class FrameElement { readonly tagName: string; ownerDocument: { defaultView: { Element: typeof FrameElement; HTMLMediaElement: typeof FrameElement }; } | null = null; constructor(tagName: string) { this.tagName = tagName; } } class FrameMedia extends FrameElement { muted = false; defaultMuted = false; pause = vi.fn(); } const video = new FrameMedia("VIDEO"); const audio = new FrameMedia("AUDIO"); const fakeDoc = { defaultView: { Element: FrameElement, HTMLMediaElement: FrameMedia }, querySelectorAll: () => [video, audio], }; video.ownerDocument = fakeDoc; audio.ownerDocument = fakeDoc; return { doc: fakeDoc as unknown as Document, video: video as unknown as HTMLMediaElement & { pause: ReturnType }, audio: audio as unknown as HTMLMediaElement & { pause: ReturnType }, }; } // ── Controls unit tests ── describe("SPEED_PRESETS", () => { it("contains logarithmic speed steps", () => { expect(SPEED_PRESETS).toEqual([0.25, 0.5, 1, 1.5, 2, 4]); }); it("includes 1x as default speed", () => { expect(SPEED_PRESETS).toContain(1); }); }); describe("formatSpeed", () => { it("formats integer speeds", () => { expect(formatSpeed(1)).toBe("1x"); expect(formatSpeed(2)).toBe("2x"); expect(formatSpeed(4)).toBe("4x"); }); it("formats fractional speeds", () => { expect(formatSpeed(0.25)).toBe("0.25x"); expect(formatSpeed(0.5)).toBe("0.5x"); expect(formatSpeed(1.5)).toBe("1.5x"); }); }); describe("formatTime", () => { it("formats 0 seconds", () => { expect(formatTime(0)).toBe("0:00"); }); it("formats seconds under a minute", () => { expect(formatTime(45)).toBe("0:45"); }); it("formats exact minutes", () => { expect(formatTime(120)).toBe("2:00"); }); it("formats minutes and seconds", () => { expect(formatTime(95)).toBe("1:35"); }); it("pads seconds with leading zero", () => { expect(formatTime(61)).toBe("1:01"); }); it("floors fractional seconds", () => { expect(formatTime(3.7)).toBe("0:03"); }); it("handles negative input", () => { expect(formatTime(-5)).toBe("0:00"); }); }); // ── Parent-frame audio proxies (ownership-based) ── // // Parent-frame audio/video copies are preloaded mirror proxies of the iframe's // timed media. They exist as a fallback for environments that block iframe // `.play()`. Under the default `runtime` audio ownership, the iframe drives // audible playback and the proxies stay paused. Ownership flips to `parent` // only when the runtime posts `media-autoplay-blocked` — then the proxies // become the audible source and the iframe is silenced via bridge. describe("HyperframesPlayer parent-frame media", () => { type PlayerElement = HTMLElement & { play: () => void; pause: () => void; seek: (t: number) => void; _audioOwner?: "runtime" | "parent"; _promoteToParentProxy?: () => void; }; let player: PlayerElement; let mockAudio: { src: string; preload: string; muted: boolean; playbackRate: number; currentTime: number; paused: boolean; play: ReturnType; pause: ReturnType; load: ReturnType; }; beforeEach(async () => { await import("./hyperframes-player.js"); mockAudio = { src: "", preload: "", muted: false, playbackRate: 1, currentTime: 0, paused: true, play: vi.fn().mockResolvedValue(undefined), pause: vi.fn(), load: vi.fn(), }; vi.spyOn(globalThis, "Audio").mockImplementation( () => mockAudio as unknown as HTMLAudioElement, ); player = document.createElement("hyperframes-player") as PlayerElement; }); afterEach(() => { player.remove(); vi.restoreAllMocks(); }); it("includes audio-src in observedAttributes", () => { const Ctor = player.constructor as typeof HTMLElement & { observedAttributes: string[]; }; expect(Ctor.observedAttributes).toContain("audio-src"); }); it("creates Audio and starts preloading when audio-src is set", () => { player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); document.body.appendChild(player); expect(globalThis.Audio).toHaveBeenCalled(); expect(mockAudio.preload).toBe("auto"); expect(mockAudio.src).toBe("https://cdn.example.com/narration.mp3"); expect(mockAudio.load).toHaveBeenCalled(); }); it("syncs muted attribute to parent media", () => { player.setAttribute("muted", ""); player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); document.body.appendChild(player); expect(mockAudio.muted).toBe(true); }); it("syncs playback-rate to parent media", () => { player.setAttribute("playback-rate", "1.5"); player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); document.body.appendChild(player); expect(mockAudio.playbackRate).toBe(1.5); }); it("play() does NOT start parent-proxy under runtime ownership", () => { // Default ownership is `runtime` — the iframe drives audible playback. // If we also started parent proxies here, both would play and the user // would hear doubled, slightly-offset audio (the original bug). player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); document.body.appendChild(player); player.play(); expect(mockAudio.play).not.toHaveBeenCalled(); expect(player._audioOwner).toBe("runtime"); }); it("pause() does NOT touch parent-proxy under runtime ownership", () => { player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); document.body.appendChild(player); player.pause(); expect(mockAudio.pause).not.toHaveBeenCalled(); }); it("seek() does NOT update parent currentTime under runtime ownership", () => { // Under runtime ownership the iframe is authoritative for time; touching // the proxy's currentTime would just trigger a re-buffer for no gain. player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); document.body.appendChild(player); player.seek(12.5); expect(mockAudio.currentTime).toBe(0); }); it("after promotion to parent ownership: play/pause/seek drive parent proxy", () => { // Simulates the runtime having posted `media-autoplay-blocked`. Post // promotion: the web component owns audible output and fully drives // the parent proxy. player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); document.body.appendChild(player); player._promoteToParentProxy?.(); expect(player._audioOwner).toBe("parent"); player.play(); expect(mockAudio.play).toHaveBeenCalled(); player.seek(12.5); expect(mockAudio.currentTime).toBe(12.5); player.pause(); expect(mockAudio.pause).toHaveBeenCalled(); }); function dispatchAutoplayBlockedFromPlayerFrame(player: HTMLElement): HTMLMediaElement { const iframe = player.shadowRoot?.querySelector("iframe"); if (!(iframe instanceof HTMLIFrameElement)) throw new Error("expected player iframe"); const iframeDoc = iframe.contentDocument; if (!iframeDoc) throw new Error("expected player iframe document"); const video = iframeDoc.createElement("video"); video.setAttribute("data-start", "0"); video.setAttribute("data-duration", "10"); video.muted = false; iframeDoc.body.appendChild(video); window.dispatchEvent( new MessageEvent("message", { source: iframe.contentWindow, data: { source: "hf-preview", type: "media-autoplay-blocked" }, }), ); return video; } it("does not mute iframe media on autoplay fallback inside presenter slideshow", () => { const slideshow = document.createElement("hyperframes-slideshow"); slideshow.appendChild(player); document.body.appendChild(slideshow); const video = dispatchAutoplayBlockedFromPlayerFrame(player); expect(video.muted).toBe(false); expect(player._audioOwner).toBe("runtime"); slideshow.remove(); }); it("does not promote autoplay fallback inside audience slideshow", () => { const slideshow = document.createElement("hyperframes-slideshow"); slideshow.setAttribute("mode", "audience"); slideshow.appendChild(player); document.body.appendChild(slideshow); const video = dispatchAutoplayBlockedFromPlayerFrame(player); expect(video.muted).toBe(false); expect(player._audioOwner).toBe("runtime"); slideshow.remove(); }); it("seek() while playing pauses parent proxy (prevents mirrorTime stutter loop)", () => { // Regression: previously `seek()` only called `seekAll()`, leaving the // proxy playing. With the timeline frozen at the new seek target, the // parent's `mirrorTime` drift-correction would yank `currentTime` back // every ~80ms of accumulated drift, producing an audible audio stutter // loop while the video frame stayed frozen. `seek()` must be symmetric // with `pause()` for the parent-owned audio path. player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); document.body.appendChild(player); player._promoteToParentProxy?.(); player.play(); expect(mockAudio.play).toHaveBeenCalled(); mockAudio.pause.mockClear(); player.seek(12.5); expect(mockAudio.pause).toHaveBeenCalled(); expect(mockAudio.currentTime).toBe(12.5); }); it("promotion is idempotent", () => { player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); document.body.appendChild(player); player._promoteToParentProxy?.(); player._promoteToParentProxy?.(); player._promoteToParentProxy?.(); // Only one play() attempt is triggered by promotion itself (gated on // `!this._paused`, which is true by default so it doesn't trigger at all). // The test's meaning is: ownership stays `parent`, no thrash, no errors. expect(player._audioOwner).toBe("parent"); }); it("dispatches audioownershipchange on promotion", () => { player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); document.body.appendChild(player); const events: Array<{ owner: string; reason: string }> = []; player.addEventListener("audioownershipchange", (e: Event) => { const detail = (e as CustomEvent<{ owner: string; reason: string }>).detail; events.push(detail); }); player._promoteToParentProxy?.(); expect(events).toEqual([{ owner: "parent", reason: "autoplay-blocked" }]); // Second promote is idempotent — no duplicate event. player._promoteToParentProxy?.(); expect(events).toHaveLength(1); }); it("promotion mid-playback plays parent proxy immediately", () => { // Previously-missing coverage: if the user is already playing when // the runtime reports autoplay-blocked, the proxy must start audible // right away — not wait for the user to hit pause/play again. player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); document.body.appendChild(player); player.play(); // `_paused = false`, owner still `runtime` → no parent play yet expect(mockAudio.play).not.toHaveBeenCalled(); player._promoteToParentProxy?.(); expect(mockAudio.play).toHaveBeenCalled(); }); it("surfaces playbackerror when parent proxy play() rejects", async () => { player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); document.body.appendChild(player); const rejection = Object.assign(new Error("blocked"), { name: "NotAllowedError" }); mockAudio.play = vi.fn().mockRejectedValueOnce(rejection); const errors: unknown[] = []; player.addEventListener("playbackerror", (e: Event) => { errors.push((e as CustomEvent).detail); }); player._promoteToParentProxy?.(); player.play(); // Promise rejection delivered on a microtask — flush. await Promise.resolve(); await Promise.resolve(); expect(errors.length).toBeGreaterThan(0); expect((errors[0] as { source: string }).source).toBe("parent-proxy"); }); it("playbackerror dedup: fires at most once per parent-ownership session", async () => { // Under parent ownership with parent-also-blocked, every iframe // paused→playing transition in the state loop re-invokes `_playParentMedia`. // Without a latch, each rejection would re-fire `playbackerror`, spamming // subscribers. Mirrors the runtime's `mediaAutoplayBlockedPosted` latch. player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); document.body.appendChild(player); const rejection = Object.assign(new Error("blocked"), { name: "NotAllowedError" }); mockAudio.play = vi.fn().mockRejectedValue(rejection); const errors: unknown[] = []; player.addEventListener("playbackerror", (e: Event) => { errors.push((e as CustomEvent).detail); }); player._promoteToParentProxy?.(); player.play(); player.pause(); player.play(); player.pause(); player.play(); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); expect(errors).toHaveLength(1); }); it("cleans up parent media on disconnect", () => { player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); document.body.appendChild(player); player.remove(); expect(mockAudio.pause).toHaveBeenCalled(); expect(mockAudio.src).toBe(""); }); it("updates parent media when playback-rate changes after setup", () => { player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); document.body.appendChild(player); player.setAttribute("playback-rate", "2"); expect(mockAudio.playbackRate).toBe(2); }); it("updates parent media when muted toggles after setup", () => { player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3"); document.body.appendChild(player); player.setAttribute("muted", ""); expect(mockAudio.muted).toBe(true); player.removeAttribute("muted"); expect(mockAudio.muted).toBe(false); }); }); // ── Shader transition preview controls ── // // Shader transition capture scale and loading UI ownership are player-level // preview concerns. The player forwards those options into the iframe before // the composition runs, then renders transition-prep progress from runtime // messages when `shader-loading="player"` is enabled. describe("HyperframesPlayer shader transition options", () => { type PlayerWithIframe = HTMLElement & { iframeElement: HTMLIFrameElement; }; beforeEach(async () => { await import("./hyperframes-player.js"); }); afterEach(() => { vi.useRealTimers(); document.body.innerHTML = ""; }); it("observes shader capture scale and loading attributes", () => { const player = document.createElement("hyperframes-player"); const Ctor = player.constructor as typeof HTMLElement & { observedAttributes: string[]; }; expect(Ctor.observedAttributes).toContain("shader-capture-scale"); expect(Ctor.observedAttributes).toContain("shader-loading"); }); it("passes shader options through src query parameters", () => { const player = document.createElement("hyperframes-player") as PlayerWithIframe; player.setAttribute("shader-capture-scale", "0.5"); player.setAttribute("shader-loading", "player"); player.setAttribute("src", "/api/projects/demo/preview?x=1#stage"); const url = new URL(player.iframeElement.src); expect(url.pathname).toBe("/api/projects/demo/preview"); expect(url.searchParams.get("x")).toBe("1"); expect(url.searchParams.get("__hf_shader_capture_scale")).toBe("0.5"); expect(url.searchParams.get("__hf_shader_loading")).toBe("player"); expect(url.hash).toBe("#stage"); }); it("injects shader options into srcdoc before composition scripts run", () => { const player = document.createElement("hyperframes-player") as PlayerWithIframe; player.setAttribute("shader-capture-scale", "0.5"); player.setAttribute("shader-loading", "player"); player.setAttribute( "srcdoc", '', ); const srcdoc = player.iframeElement.srcdoc; expect(srcdoc).toContain('window.__HF_SHADER_CAPTURE_SCALE="0.5";'); expect(srcdoc).toContain('window.__HF_SHADER_LOADING="player";'); expect(srcdoc.indexOf("data-hyperframes-player-shader-options")).toBeLessThan( srcdoc.indexOf("composition.js"), ); }); it("shows and hides the player-owned shader loader from transition state messages", () => { vi.useFakeTimers(); const player = document.createElement("hyperframes-player") as PlayerWithIframe; player.setAttribute("shader-loading", "player"); document.body.appendChild(player); const iframeWindow = player.iframeElement.contentWindow; expect(iframeWindow).toBeTruthy(); window.dispatchEvent( new MessageEvent("message", { source: iframeWindow, data: { source: "hf-preview", type: "shader-transition-state", compositionId: "main", state: { loading: true, progress: 3, total: 10, currentTransition: 1, transitionTotal: 2, transitionFrame: 3, transitionFrames: 5, phase: "capturing", }, }, }), ); const loader = player.shadowRoot?.querySelector(".hfp-shader-loader"); expect(loader?.classList.contains("hfp-visible")).toBe(true); expect(loader?.textContent).toContain("1/2"); expect(loader?.textContent).toContain("3/5"); const playEvents: Event[] = []; player.addEventListener("play", (event) => playEvents.push(event)); loader?.dispatchEvent(new MouseEvent("click", { bubbles: true, composed: true })); expect(playEvents).toHaveLength(0); window.dispatchEvent( new MessageEvent("message", { source: iframeWindow, data: { source: "hf-preview", type: "shader-transition-state", compositionId: "main", state: { loading: false, ready: true }, }, }), ); window.dispatchEvent( new MessageEvent("message", { source: iframeWindow, data: { source: "hf-preview", type: "shader-transition-state", compositionId: "main", state: { loading: false, ready: true }, }, }), ); expect(loader?.classList.contains("hfp-visible")).toBe(false); expect(loader?.classList.contains("hfp-hiding")).toBe(true); vi.advanceTimersByTime(420); expect(loader?.classList.contains("hfp-hiding")).toBe(false); vi.useRealTimers(); }); }); // ── Shared stylesheet (adoptedStyleSheets) ── // // Every player constructed in the same document should adopt the *same* // CSSStyleSheet instance instead of getting its own