mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
2272 lines
75 KiB
TypeScript
2272 lines
75 KiB
TypeScript
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<typeof vi.fn> };
|
|
audio: HTMLMediaElement & { pause: ReturnType<typeof vi.fn> };
|
|
} {
|
|
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<typeof vi.fn> },
|
|
audio: audio as unknown as HTMLMediaElement & { pause: ReturnType<typeof vi.fn> },
|
|
};
|
|
}
|
|
|
|
// ── 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<typeof vi.fn>;
|
|
pause: ReturnType<typeof vi.fn>;
|
|
load: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
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",
|
|
'<!doctype html><html><head><script src="composition.js"></script></head><body></body></html>',
|
|
);
|
|
|
|
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 <style> element. This is
|
|
// the studio thumbnail-grid win — N players, one parsed sheet.
|
|
|
|
describe("HyperframesPlayer adoptedStyleSheets", () => {
|
|
type AdoptingShadowRoot = ShadowRoot & { adoptedStyleSheets: CSSStyleSheet[] };
|
|
type PlayerWithShadow = HTMLElement & { shadowRoot: AdoptingShadowRoot | null };
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("shares a single CSSStyleSheet across multiple player instances", () => {
|
|
const a = document.createElement("hyperframes-player") as PlayerWithShadow;
|
|
const b = document.createElement("hyperframes-player") as PlayerWithShadow;
|
|
document.body.appendChild(a);
|
|
document.body.appendChild(b);
|
|
|
|
const sheetsA = a.shadowRoot?.adoptedStyleSheets ?? [];
|
|
const sheetsB = b.shadowRoot?.adoptedStyleSheets ?? [];
|
|
|
|
expect(sheetsA.length).toBeGreaterThan(0);
|
|
expect(sheetsB.length).toBeGreaterThan(0);
|
|
expect(sheetsA.at(-1)).toBe(sheetsB.at(-1));
|
|
});
|
|
|
|
it("does not inject a per-instance <style> when adoption succeeds", () => {
|
|
const player = document.createElement("hyperframes-player") as PlayerWithShadow;
|
|
document.body.appendChild(player);
|
|
|
|
expect(player.shadowRoot?.querySelector("style")).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ── Media MutationObserver scoping ──
|
|
//
|
|
// The observer that catches late-attached `<audio data-start>` from
|
|
// sub-composition activation used to watch `iframe.contentDocument.body`
|
|
// wholesale. That fired on every body-level mutation — analytics scripts,
|
|
// runtime telemetry markers, dev-only overlays — even though only
|
|
// composition-tree changes can introduce new timed media. The fix is to
|
|
// scope per top-level composition host (see `selectMediaObserverTargets`);
|
|
// these tests verify the player honors that scoping.
|
|
|
|
describe("HyperframesPlayer media MutationObserver scoping", () => {
|
|
type PlayerInternal = HTMLElement & {
|
|
_observeDynamicMedia?: (doc: Document) => void;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = "";
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("attaches the observer to each top-level composition host (not the body)", () => {
|
|
const observeSpy = vi.spyOn(MutationObserver.prototype, "observe");
|
|
|
|
const player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
document.body.appendChild(player);
|
|
// The constructor doesn't install an observer — only `_observeDynamicMedia`
|
|
// does — so the spy starts clean for the call we care about.
|
|
observeSpy.mockClear();
|
|
|
|
// Simulates the iframe document the runtime hands the player after mount.
|
|
// Bypassing the iframe lifecycle keeps the test deterministic; the
|
|
// selection logic itself is exercised in `mediaObserverScope.test.ts`.
|
|
const fakeDoc = document.implementation.createHTMLDocument("test");
|
|
fakeDoc.body.innerHTML = `
|
|
<div data-composition-id="root-a"></div>
|
|
<div data-composition-id="root-b"></div>
|
|
<script>// runtime telemetry — body-level, must NOT be observed</script>
|
|
`;
|
|
|
|
player._observeDynamicMedia?.(fakeDoc);
|
|
|
|
expect(observeSpy).toHaveBeenCalledTimes(2);
|
|
const observedTargets = observeSpy.mock.calls.map((call) => call[0]);
|
|
expect(observedTargets.map((t) => (t as Element).getAttribute("data-composition-id"))).toEqual([
|
|
"root-a",
|
|
"root-b",
|
|
]);
|
|
expect(observedTargets).not.toContain(fakeDoc.body);
|
|
// Subtree is still required — sub-composition media can be deeply nested
|
|
// inside the host (e.g. wrapper div around the `<audio>`).
|
|
// Attribute observation on "preload" is required so the player creates
|
|
// parent proxies just-in-time when the preloader promotes a clip.
|
|
for (const call of observeSpy.mock.calls) {
|
|
expect(call[1]).toEqual({
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: true,
|
|
attributeFilter: ["preload"],
|
|
});
|
|
}
|
|
});
|
|
|
|
it("falls back to observing the document body when no composition hosts exist", () => {
|
|
// Preserves the legacy behavior for documents that haven't bootstrapped
|
|
// a composition tree yet (e.g. a blank iframe between src changes).
|
|
const observeSpy = vi.spyOn(MutationObserver.prototype, "observe");
|
|
|
|
const player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
document.body.appendChild(player);
|
|
observeSpy.mockClear();
|
|
|
|
const fakeDoc = document.implementation.createHTMLDocument("test");
|
|
fakeDoc.body.innerHTML = `<div class="not-a-composition"></div>`;
|
|
|
|
player._observeDynamicMedia?.(fakeDoc);
|
|
|
|
expect(observeSpy).toHaveBeenCalledTimes(1);
|
|
expect(observeSpy.mock.calls[0]?.[0]).toBe(fakeDoc.body);
|
|
});
|
|
});
|
|
|
|
// ── Parent-proxy time-mirror coalescing ──
|
|
//
|
|
// `_mirrorParentMediaTime` is the steady-state correction loop that nudges
|
|
// every parent-frame audio/video proxy back onto the iframe's timeline. The
|
|
// post-`P1-4` contract: a single over-threshold sample (one slow bridge tick,
|
|
// one tab-throttled rAF, one GC pause) is absorbed by a per-proxy counter and
|
|
// does NOT cost a `currentTime` write. Only a *trending* drift — two
|
|
// consecutive samples above the 50 ms threshold — triggers a seek. Forced
|
|
// callers (audio-ownership promotion, brand-new proxy initialization) bypass
|
|
// the gate so the listener never hears a misaligned sample on cut-over.
|
|
|
|
describe("HyperframesPlayer parent-proxy time-mirror coalescing", () => {
|
|
type DriftEntry = {
|
|
el: { currentTime: number; src: string; pause: () => void };
|
|
start: number;
|
|
duration: number;
|
|
driftSamples: number;
|
|
};
|
|
type PlayerInternal = HTMLElement & {
|
|
_parentMedia: DriftEntry[];
|
|
_mirrorParentMediaTime: (timelineSeconds: number, options?: { force?: boolean }) => void;
|
|
_promoteToParentProxy?: () => void;
|
|
};
|
|
|
|
let player: PlayerInternal;
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
document.body.appendChild(player);
|
|
// No audio-src was set, so `_parentMedia` is empty. Tests push synthetic
|
|
// POJO entries — `_mirrorParentMediaTime` only reads/writes
|
|
// `el.currentTime`, so a plain object stands in fine for HTMLMediaElement.
|
|
});
|
|
|
|
afterEach(() => {
|
|
player.remove();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function makeEntry(
|
|
opts: {
|
|
currentTime?: number;
|
|
start?: number;
|
|
duration?: number;
|
|
driftSamples?: number;
|
|
} = {},
|
|
): DriftEntry {
|
|
// Include `pause`/`src` so `disconnectedCallback`'s teardown loop
|
|
// (`m.el.pause(); m.el.src = ""`) doesn't blow up when the player is
|
|
// removed at the end of the test — `_mirrorParentMediaTime` itself only
|
|
// touches `currentTime`.
|
|
const entry: DriftEntry = {
|
|
el: {
|
|
currentTime: opts.currentTime ?? 0,
|
|
src: "",
|
|
pause: vi.fn(),
|
|
},
|
|
start: opts.start ?? 0,
|
|
duration: opts.duration ?? 100,
|
|
driftSamples: opts.driftSamples ?? 0,
|
|
};
|
|
player._parentMedia.push(entry);
|
|
return entry;
|
|
}
|
|
|
|
it("initializes new parent-media entries with driftSamples=0", () => {
|
|
// Mock Audio just for this test so the audio-src bootstrap path produces
|
|
// a real entry rather than throwing on construction.
|
|
const 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,
|
|
);
|
|
|
|
const fresh = document.createElement("hyperframes-player") as PlayerInternal;
|
|
fresh.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(fresh);
|
|
|
|
expect(fresh._parentMedia).toHaveLength(1);
|
|
expect(fresh._parentMedia[0]?.driftSamples).toBe(0);
|
|
fresh.remove();
|
|
});
|
|
|
|
it("does nothing when drift is within the 50 ms threshold", () => {
|
|
const m = makeEntry({ currentTime: 5 });
|
|
player._mirrorParentMediaTime(5.04);
|
|
expect(m.el.currentTime).toBe(5);
|
|
expect(m.driftSamples).toBe(0);
|
|
});
|
|
|
|
it("absorbs a single over-threshold spike without writing currentTime", () => {
|
|
const m = makeEntry({ currentTime: 5 });
|
|
player._mirrorParentMediaTime(5.5);
|
|
expect(m.el.currentTime).toBe(5);
|
|
expect(m.driftSamples).toBe(1);
|
|
});
|
|
|
|
it("issues a seek on the second consecutive over-threshold sample", () => {
|
|
const m = makeEntry({ currentTime: 5 });
|
|
player._mirrorParentMediaTime(5.5);
|
|
expect(m.el.currentTime).toBe(5);
|
|
expect(m.driftSamples).toBe(1);
|
|
// Second sample with the same drift: the gate trips, the write fires,
|
|
// and the counter resets so the proxy doesn't re-seek every later tick.
|
|
player._mirrorParentMediaTime(5.5);
|
|
expect(m.el.currentTime).toBe(5.5);
|
|
expect(m.driftSamples).toBe(0);
|
|
});
|
|
|
|
it("resets the counter when a sample comes back within threshold", () => {
|
|
const m = makeEntry({ currentTime: 5 });
|
|
player._mirrorParentMediaTime(5.5);
|
|
expect(m.driftSamples).toBe(1);
|
|
// Recovery — counter must clear so a later isolated spike doesn't
|
|
// accidentally satisfy the 2-sample gate by piggy-backing on stale state.
|
|
player._mirrorParentMediaTime(5.02);
|
|
expect(m.driftSamples).toBe(0);
|
|
expect(m.el.currentTime).toBe(5);
|
|
player._mirrorParentMediaTime(5.5);
|
|
expect(m.driftSamples).toBe(1);
|
|
expect(m.el.currentTime).toBe(5);
|
|
});
|
|
|
|
it("force: true writes immediately on the first over-threshold sample", () => {
|
|
const m = makeEntry({ currentTime: 5 });
|
|
player._mirrorParentMediaTime(5.5, { force: true });
|
|
expect(m.el.currentTime).toBe(5.5);
|
|
expect(m.driftSamples).toBe(0);
|
|
});
|
|
|
|
it("force: true clears any pre-existing drift counter", () => {
|
|
const m = makeEntry({ currentTime: 5, driftSamples: 1 });
|
|
player._mirrorParentMediaTime(5.5, { force: true });
|
|
expect(m.el.currentTime).toBe(5.5);
|
|
expect(m.driftSamples).toBe(0);
|
|
});
|
|
|
|
it("does not seek out-of-range entries and resets their counters", () => {
|
|
// Active window [10, 15). currentTime=99 is a sentinel — if the function
|
|
// ever writes inside an out-of-range branch the test catches it because
|
|
// relTime would be 5 (or 15), not 99.
|
|
const m = makeEntry({
|
|
currentTime: 99,
|
|
start: 10,
|
|
duration: 5,
|
|
driftSamples: 5,
|
|
});
|
|
player._mirrorParentMediaTime(5);
|
|
expect(m.el.currentTime).toBe(99);
|
|
expect(m.driftSamples).toBe(0);
|
|
// Boundary: relTime === duration → still out of range (the loop uses `>=`).
|
|
m.driftSamples = 7;
|
|
player._mirrorParentMediaTime(15);
|
|
expect(m.el.currentTime).toBe(99);
|
|
expect(m.driftSamples).toBe(0);
|
|
});
|
|
|
|
it("tracks drift independently across multiple proxies", () => {
|
|
// a is drifted; b is aligned. A single tick must increment a's counter
|
|
// and reset b's — proving the per-entry state is genuinely per-entry.
|
|
const a = makeEntry({ currentTime: 5 });
|
|
const b = makeEntry({ currentTime: 7.01, driftSamples: 1 });
|
|
player._mirrorParentMediaTime(7);
|
|
expect(a.el.currentTime).toBe(5);
|
|
expect(a.driftSamples).toBe(1);
|
|
expect(b.el.currentTime).toBe(7.01);
|
|
expect(b.driftSamples).toBe(0);
|
|
});
|
|
|
|
it("force: true bypasses the gate for every proxy in a single sweep", () => {
|
|
const a = makeEntry({ currentTime: 5 });
|
|
const b = makeEntry({ currentTime: 8 });
|
|
player._mirrorParentMediaTime(7, { force: true });
|
|
expect(a.el.currentTime).toBe(7);
|
|
expect(b.el.currentTime).toBe(7);
|
|
expect(a.driftSamples).toBe(0);
|
|
expect(b.driftSamples).toBe(0);
|
|
});
|
|
|
|
it("_promoteToParentProxy invokes _mirrorParentMediaTime with force: true", () => {
|
|
// Integration check of the promotion call site — we cannot tolerate even
|
|
// ~80 ms of audible drift across an ownership flip, so the call site
|
|
// must opt out of the jitter gate.
|
|
const spy = vi.spyOn(player, "_mirrorParentMediaTime");
|
|
player._promoteToParentProxy?.();
|
|
const forcedCall = spy.mock.calls.find(([, opts]) => opts?.force === true);
|
|
expect(forcedCall).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ── Synchronous seek() with same-origin detection ──
|
|
//
|
|
// Studio has long reached past the postMessage bridge and called the runtime's
|
|
// `__player.seek` directly (`useTimelinePlayer.ts:233`) — that's the only way
|
|
// to land a scrubbed frame in the same task as the input event so the user
|
|
// sees no perceived lag. P3-1 promotes that pattern to a public API: the
|
|
// player element's own `seek()` now tries the same shortcut first, and only
|
|
// falls back to the async postMessage bridge when the iframe is genuinely
|
|
// cross-origin (or the runtime hasn't installed `__player` yet). The tests
|
|
// here stub `iframe.contentWindow` so we can exercise the branch matrix
|
|
// without booting an actual runtime.
|
|
|
|
describe("HyperframesPlayer seek() sync path", () => {
|
|
type SyncPlayerStub = {
|
|
seek?: (t: number) => void;
|
|
play?: () => void;
|
|
pause?: () => void;
|
|
};
|
|
type TimelineStub = {
|
|
duration: () => number;
|
|
time: () => number;
|
|
seek: (t: number) => void;
|
|
play: () => void;
|
|
pause: () => void;
|
|
};
|
|
type FakeContentWindow = {
|
|
__player?: SyncPlayerStub;
|
|
__timelines?: Record<string, TimelineStub>;
|
|
postMessage?: ReturnType<typeof vi.fn>;
|
|
};
|
|
type PlayerInternal = HTMLElement & {
|
|
seek: (t: number) => void;
|
|
play: () => void;
|
|
pause: () => void;
|
|
stopMedia: () => void;
|
|
iframe: HTMLIFrameElement;
|
|
_currentTime: number;
|
|
_parentMedia: Array<{
|
|
el: { pause: ReturnType<typeof vi.fn>; src: string };
|
|
start: number;
|
|
duration: number;
|
|
driftSamples: number;
|
|
source?: HTMLMediaElement | null;
|
|
}>;
|
|
};
|
|
|
|
let player: PlayerInternal;
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
document.body.appendChild(player);
|
|
});
|
|
|
|
afterEach(() => {
|
|
player.remove();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// Replace the iframe's `contentWindow` getter so the test controls what the
|
|
// sync path sees. Passing `"throw"` simulates the cross-origin SecurityError
|
|
// a real browser raises when reading `contentWindow.<anything>`.
|
|
function stubContentWindow(stub: FakeContentWindow | "throw") {
|
|
Object.defineProperty(player.iframe, "contentWindow", {
|
|
configurable: true,
|
|
get() {
|
|
if (stub === "throw") throw new Error("SecurityError");
|
|
return stub;
|
|
},
|
|
});
|
|
}
|
|
|
|
it("calls __player.seek directly on the same-origin path", () => {
|
|
// The whole point of P3-1: when the runtime is reachable, scrubs land in
|
|
// the same task as the input. `postMessage` must NOT also fire — that
|
|
// would cause a duplicate, async re-seek a tick later.
|
|
const sync = vi.fn();
|
|
const post = vi.fn();
|
|
stubContentWindow({ __player: { seek: sync }, postMessage: post });
|
|
|
|
player.seek(12.5);
|
|
|
|
expect(sync).toHaveBeenCalledTimes(1);
|
|
expect(sync).toHaveBeenCalledWith(12.5);
|
|
expect(post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes the raw time-in-seconds through, not a rounded frame number", () => {
|
|
// The postMessage bridge has to round to a frame at the wire boundary,
|
|
// but the in-process call accepts seconds directly — preserving the
|
|
// caller's precision for fractional scrubs.
|
|
const sync = vi.fn();
|
|
stubContentWindow({ __player: { seek: sync } });
|
|
|
|
player.seek(7.3333);
|
|
|
|
expect(sync).toHaveBeenCalledWith(7.3333);
|
|
});
|
|
|
|
it("falls back to postMessage when __player has not been installed yet", () => {
|
|
// Before the runtime bootstraps, `contentWindow` exists but `__player` is
|
|
// undefined. The fallback queues the seek via postMessage, which the
|
|
// runtime drains once `installRuntimeControlBridge` runs.
|
|
const post = vi.fn();
|
|
stubContentWindow({ postMessage: post });
|
|
|
|
player.seek(12.5);
|
|
|
|
expect(post).toHaveBeenCalledTimes(1);
|
|
expect(post).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
source: "hf-parent",
|
|
type: "control",
|
|
action: "seek",
|
|
frame: Math.round(12.5 * 30),
|
|
}),
|
|
"*",
|
|
);
|
|
});
|
|
|
|
it("seeks same-origin __timelines when no runtime bridge exists", () => {
|
|
const timeline: TimelineStub = {
|
|
duration: vi.fn(() => 5),
|
|
time: vi.fn(() => 0),
|
|
seek: vi.fn(),
|
|
play: vi.fn(),
|
|
pause: vi.fn(),
|
|
};
|
|
const post = vi.fn();
|
|
stubContentWindow({ __timelines: { main: timeline }, postMessage: post });
|
|
|
|
player.seek(2);
|
|
|
|
expect(timeline.seek).toHaveBeenCalledTimes(1);
|
|
// suppressEvents=false so onUpdate fires (imperative-visibility compositions repaint).
|
|
expect(timeline.seek).toHaveBeenCalledWith(2, false);
|
|
expect(post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("plays and pauses same-origin __timelines when no runtime bridge exists", () => {
|
|
const timeline: TimelineStub = {
|
|
duration: vi.fn(() => 5),
|
|
time: vi.fn(() => 0),
|
|
seek: vi.fn(),
|
|
play: vi.fn(),
|
|
pause: vi.fn(),
|
|
};
|
|
const post = vi.fn();
|
|
stubContentWindow({ __timelines: { main: timeline }, postMessage: post });
|
|
|
|
player.play();
|
|
player.pause();
|
|
|
|
expect(timeline.play).toHaveBeenCalledTimes(1);
|
|
expect(timeline.pause).toHaveBeenCalledTimes(1);
|
|
expect(post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("pauses same-origin __timelines after seek while playing", () => {
|
|
const pause = vi.fn();
|
|
const timeline: TimelineStub = {
|
|
duration: vi.fn(() => 5),
|
|
time: vi.fn(() => 0),
|
|
seek: vi.fn(),
|
|
play: vi.fn(),
|
|
pause,
|
|
};
|
|
const post = vi.fn();
|
|
stubContentWindow({ __timelines: { main: timeline }, postMessage: post });
|
|
|
|
player.play();
|
|
pause.mockClear();
|
|
player.seek(2);
|
|
|
|
expect(timeline.seek).toHaveBeenCalledWith(2, false);
|
|
expect(pause).toHaveBeenCalledTimes(1);
|
|
expect(post).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stopMedia pauses slide media without stopping global audio-src proxies", () => {
|
|
const post = vi.fn();
|
|
const doc = document.implementation.createHTMLDocument("composition");
|
|
const iframeVideo = doc.createElement("video");
|
|
const iframeAudio = doc.createElement("audio");
|
|
const iframeVideoPause = vi.fn();
|
|
const iframeAudioPause = vi.fn();
|
|
Object.defineProperty(iframeVideo, "pause", { configurable: true, value: iframeVideoPause });
|
|
Object.defineProperty(iframeAudio, "pause", { configurable: true, value: iframeAudioPause });
|
|
doc.body.append(iframeVideo, iframeAudio);
|
|
stubIframeContentDocument(player.iframe, doc);
|
|
stubContentWindow({ postMessage: post });
|
|
|
|
const slideProxyPause = vi.fn();
|
|
const globalProxyPause = vi.fn();
|
|
player._parentMedia.push(
|
|
{
|
|
el: { pause: slideProxyPause, src: "https://cdn.example.com/slide.mp4" },
|
|
start: 0,
|
|
duration: 5,
|
|
driftSamples: 0,
|
|
source: iframeVideo,
|
|
},
|
|
{
|
|
el: { pause: globalProxyPause, src: "https://cdn.example.com/background.mp3" },
|
|
start: 0,
|
|
duration: Infinity,
|
|
driftSamples: 0,
|
|
source: null,
|
|
},
|
|
);
|
|
|
|
player.stopMedia();
|
|
|
|
expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "stop-media" }), "*");
|
|
expect(iframeVideoPause).toHaveBeenCalledOnce();
|
|
expect(iframeAudioPause).toHaveBeenCalledOnce();
|
|
expect(slideProxyPause).toHaveBeenCalledOnce();
|
|
expect(globalProxyPause).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("stopMedia pauses iframe-realm media elements", () => {
|
|
const post = vi.fn();
|
|
const { doc, video, audio } = createForeignFrameMediaDocument();
|
|
stubIframeContentDocument(player.iframe, doc);
|
|
stubContentWindow({ postMessage: post });
|
|
|
|
player.stopMedia();
|
|
|
|
expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "stop-media" }), "*");
|
|
expect(video.pause).toHaveBeenCalledOnce();
|
|
expect(audio.pause).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("does not bypass an installed runtime bridge for direct __timelines playback", () => {
|
|
const timeline: TimelineStub = {
|
|
duration: vi.fn(() => 5),
|
|
time: vi.fn(() => 0),
|
|
seek: vi.fn(),
|
|
play: vi.fn(),
|
|
pause: vi.fn(),
|
|
};
|
|
const post = vi.fn();
|
|
stubContentWindow({
|
|
__player: { play: vi.fn(), pause: vi.fn() },
|
|
__timelines: { main: timeline },
|
|
postMessage: post,
|
|
});
|
|
|
|
player.play();
|
|
player.pause();
|
|
|
|
expect(timeline.play).not.toHaveBeenCalled();
|
|
expect(timeline.pause).not.toHaveBeenCalled();
|
|
expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "play" }), "*");
|
|
expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "pause" }), "*");
|
|
});
|
|
|
|
it("falls back to postMessage when __player exists but lacks seek()", () => {
|
|
// Defensive: a partial `__player` (e.g. older runtime, mocked stub) must
|
|
// not be assumed callable. `typeof seek !== "function"` guards this.
|
|
const post = vi.fn();
|
|
stubContentWindow({
|
|
__player: { play: vi.fn(), pause: vi.fn() },
|
|
postMessage: post,
|
|
});
|
|
|
|
player.seek(7);
|
|
|
|
expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "seek", frame: 210 }), "*");
|
|
});
|
|
|
|
it("does not throw when contentWindow access raises (cross-origin embed)", () => {
|
|
// Reading `iframe.contentWindow` on a true cross-origin iframe throws a
|
|
// DOMException. Both `_trySyncSeek` AND the postMessage fallback hit the
|
|
// same getter, so both swallow the error — the public seek() must remain
|
|
// a clean no-op surface for the caller.
|
|
stubContentWindow("throw");
|
|
|
|
expect(() => player.seek(12.5)).not.toThrow();
|
|
});
|
|
|
|
it("falls back to postMessage when __player.seek throws at runtime", () => {
|
|
// If the runtime's seek implementation panics, we catch in `_trySyncSeek`
|
|
// and degrade to the bridge. The postMessage path runs in a separate
|
|
// task — it may succeed where the sync call failed, and at worst the
|
|
// failure mode is identical.
|
|
const sync = vi.fn(() => {
|
|
throw new Error("runtime panic");
|
|
});
|
|
const post = vi.fn();
|
|
stubContentWindow({ __player: { seek: sync }, postMessage: post });
|
|
|
|
expect(() => player.seek(12.5)).not.toThrow();
|
|
expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "seek" }), "*");
|
|
});
|
|
|
|
it("updates _currentTime regardless of which path is taken", () => {
|
|
// `_currentTime` is the parent-side cache that drives controls and parent
|
|
// proxy mirroring. It must update unconditionally — otherwise scrubs on a
|
|
// cross-origin embed leave the controls UI showing stale time.
|
|
const sync = vi.fn();
|
|
stubContentWindow({ __player: { seek: sync } });
|
|
player.seek(8.25);
|
|
expect(player._currentTime).toBe(8.25);
|
|
|
|
// Reset and verify the fallback path produces the same caching behavior.
|
|
stubContentWindow({ postMessage: vi.fn() });
|
|
player.seek(11);
|
|
expect(player._currentTime).toBe(11);
|
|
});
|
|
});
|
|
|
|
describe("HyperframesPlayer loop end-state handling", () => {
|
|
type PlayerInternal = HTMLElement & {
|
|
iframe: HTMLIFrameElement;
|
|
play: () => void;
|
|
seek: (timeInSeconds: number) => void;
|
|
loop: boolean;
|
|
_duration: number;
|
|
_currentTime: number;
|
|
_paused: boolean;
|
|
_onMessage: (event: MessageEvent) => void;
|
|
};
|
|
|
|
let player: PlayerInternal;
|
|
let frameWindow: Window;
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
frameWindow = window;
|
|
vi.spyOn(frameWindow, "postMessage").mockImplementation(() => undefined);
|
|
Object.defineProperty(player.iframe, "contentWindow", {
|
|
configurable: true,
|
|
get: () => frameWindow,
|
|
});
|
|
document.body.appendChild(player);
|
|
});
|
|
|
|
afterEach(() => {
|
|
player.remove();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("wraps and keeps playing when a looping composition posts its final paused state", () => {
|
|
const seek = vi.spyOn(player, "seek");
|
|
const play = vi.spyOn(player, "play");
|
|
player.loop = true;
|
|
player._duration = 4;
|
|
player._paused = false;
|
|
|
|
player._onMessage(
|
|
new MessageEvent("message", {
|
|
source: frameWindow,
|
|
data: {
|
|
source: "hf-preview",
|
|
type: "state",
|
|
frame: 120,
|
|
isPlaying: false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(seek).toHaveBeenCalledWith(0);
|
|
expect(play).toHaveBeenCalled();
|
|
expect(player._paused).toBe(false);
|
|
});
|
|
|
|
it("fires ended and stays paused when a non-looping composition posts its final paused state", () => {
|
|
const seek = vi.spyOn(player, "seek");
|
|
const play = vi.spyOn(player, "play");
|
|
const ended = vi.fn();
|
|
player.addEventListener("ended", ended);
|
|
player.loop = false;
|
|
player._duration = 4;
|
|
player._paused = false;
|
|
|
|
player._onMessage(
|
|
new MessageEvent("message", {
|
|
source: frameWindow,
|
|
data: {
|
|
source: "hf-preview",
|
|
type: "state",
|
|
frame: 120,
|
|
isPlaying: false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(seek).not.toHaveBeenCalled();
|
|
expect(play).not.toHaveBeenCalled();
|
|
expect(ended).toHaveBeenCalledTimes(1);
|
|
expect(player._paused).toBe(true);
|
|
});
|
|
|
|
it("play() seeks to 0 and replays when called after the video has ended", () => {
|
|
const seek = vi.spyOn(player, "seek");
|
|
player.loop = false;
|
|
player._duration = 4;
|
|
player._paused = false;
|
|
|
|
player._onMessage(
|
|
new MessageEvent("message", {
|
|
source: frameWindow,
|
|
data: {
|
|
source: "hf-preview",
|
|
type: "state",
|
|
frame: 120,
|
|
isPlaying: false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(player._paused).toBe(true);
|
|
seek.mockClear();
|
|
|
|
player.play();
|
|
|
|
expect(seek).toHaveBeenCalledWith(0);
|
|
expect(player._paused).toBe(false);
|
|
});
|
|
|
|
it("play() does not seek to 0 when called mid-playback", () => {
|
|
const seek = vi.spyOn(player, "seek");
|
|
player._duration = 4;
|
|
player._paused = true;
|
|
// Simulate mid-video position (frame 60 = 2s into a 4s video)
|
|
player._onMessage(
|
|
new MessageEvent("message", {
|
|
source: frameWindow,
|
|
data: {
|
|
source: "hf-preview",
|
|
type: "state",
|
|
frame: 60,
|
|
isPlaying: false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
player.play();
|
|
|
|
expect(seek).not.toHaveBeenCalled();
|
|
expect(player._paused).toBe(false);
|
|
});
|
|
|
|
it("clamps _currentTime to _duration when a state message reports a frame past the end", () => {
|
|
// Regression test: the postMessage state path previously set _currentTime
|
|
// without clamping, while the direct timeline path already clamped. A frame
|
|
// count slightly past the end (common on final-frame messages) would set
|
|
// _currentTime > _duration, causing the progress bar to overflow the
|
|
// scrubber track and the time display to show e.g. "0:05 / 0:04".
|
|
player._duration = 4; // 4s = 120 frames at 30fps
|
|
player._paused = false;
|
|
|
|
player._onMessage(
|
|
new MessageEvent("message", {
|
|
source: frameWindow,
|
|
data: {
|
|
source: "hf-preview",
|
|
type: "state",
|
|
frame: 150, // 5s — past the 4s duration
|
|
isPlaying: false,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(player._currentTime).toBe(4);
|
|
});
|
|
});
|
|
|
|
describe("HyperframesPlayer srcdoc attribute", () => {
|
|
type PlayerInternal = HTMLElement & {
|
|
iframe: HTMLIFrameElement;
|
|
_ready: boolean;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
});
|
|
|
|
it("includes srcdoc in observedAttributes", () => {
|
|
// `attributeChangedCallback` only fires for observed attributes. Without
|
|
// this, runtime srcdoc swaps from studio would silently drop on the floor.
|
|
const ctor = customElements.get("hyperframes-player") as
|
|
| (typeof HTMLElement & { observedAttributes: string[] })
|
|
| undefined;
|
|
expect(ctor).toBeDefined();
|
|
expect(ctor!.observedAttributes).toContain("srcdoc");
|
|
});
|
|
|
|
it("forwards an initial srcdoc attribute to the iframe on connect", () => {
|
|
// Studio's primary use case: render the player with composition HTML
|
|
// already in hand, no network round-trip. Setting the attribute before
|
|
// the element is connected must still apply on connect.
|
|
const player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
const html = "<!doctype html><html><body>hello</body></html>";
|
|
player.setAttribute("srcdoc", html);
|
|
document.body.appendChild(player);
|
|
|
|
expect(player.iframe.getAttribute("srcdoc")).toBe(html);
|
|
|
|
player.remove();
|
|
});
|
|
|
|
it("forwards a srcdoc attribute set after connect to the iframe", () => {
|
|
// The composition-switching flow: same player element, new HTML.
|
|
// Without `attributeChangedCallback` wiring this would no-op.
|
|
const player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
document.body.appendChild(player);
|
|
|
|
const html = "<!doctype html><html><body>after connect</body></html>";
|
|
player.setAttribute("srcdoc", html);
|
|
|
|
expect(player.iframe.getAttribute("srcdoc")).toBe(html);
|
|
|
|
player.remove();
|
|
});
|
|
|
|
it("resets _ready when srcdoc changes so onIframeLoad replays setup", () => {
|
|
// The ready flag gates probe intervals, controls hookup, and poster
|
|
// tear-down. Switching documents must invalidate it so the next `load`
|
|
// event re-runs that setup against the fresh window.
|
|
const player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
document.body.appendChild(player);
|
|
player._ready = true;
|
|
|
|
player.setAttribute("srcdoc", "<!doctype html><html></html>");
|
|
|
|
expect(player._ready).toBe(false);
|
|
|
|
player.remove();
|
|
});
|
|
|
|
it("removes iframe.srcdoc when the attribute is removed so src can take over", () => {
|
|
// Per HTML spec, iframe.srcdoc beats iframe.src whenever both are
|
|
// present. Studio's fetch-fail fallback path needs srcdoc cleared so
|
|
// setting src afterwards actually navigates to that URL.
|
|
const player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
player.setAttribute("srcdoc", "<!doctype html><html></html>");
|
|
document.body.appendChild(player);
|
|
expect(player.iframe.hasAttribute("srcdoc")).toBe(true);
|
|
|
|
player.removeAttribute("srcdoc");
|
|
|
|
expect(player.iframe.hasAttribute("srcdoc")).toBe(false);
|
|
|
|
player.remove();
|
|
});
|
|
|
|
it("treats an empty-string srcdoc as a deliberate empty document, not removal", () => {
|
|
// `setAttribute("srcdoc", "")` and `removeAttribute("srcdoc")` send
|
|
// different signals from the caller — empty string means "load a blank
|
|
// doc," removal means "fall back to src." We have to distinguish them.
|
|
const player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
document.body.appendChild(player);
|
|
|
|
player.setAttribute("srcdoc", "");
|
|
|
|
expect(player.iframe.hasAttribute("srcdoc")).toBe(true);
|
|
expect(player.iframe.getAttribute("srcdoc")).toBe("");
|
|
|
|
player.remove();
|
|
});
|
|
|
|
it("forwards both src and srcdoc to the iframe and lets the browser arbitrate", () => {
|
|
// We deliberately don't strip src when srcdoc is set: the HTML spec
|
|
// already says srcdoc wins, and keeping both lets the browser fall back
|
|
// to src automatically if the embed re-renders without srcdoc.
|
|
const player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
player.setAttribute("src", "/api/projects/foo/preview");
|
|
player.setAttribute("srcdoc", "<!doctype html><html></html>");
|
|
document.body.appendChild(player);
|
|
|
|
expect(player.iframe.getAttribute("src")).toBe("/api/projects/foo/preview");
|
|
expect(player.iframe.getAttribute("srcdoc")).toBe("<!doctype html><html></html>");
|
|
|
|
player.remove();
|
|
});
|
|
});
|
|
|
|
// ── Volume / Mute controls ──
|
|
|
|
describe("HyperframesPlayer volume and mute", () => {
|
|
let player: HTMLElement & {
|
|
muted: boolean;
|
|
volume: number;
|
|
iframeElement: HTMLIFrameElement;
|
|
};
|
|
let mockAudio: {
|
|
preload: string;
|
|
src: string;
|
|
muted: boolean;
|
|
volume: number;
|
|
playbackRate: number;
|
|
currentTime: number;
|
|
load: ReturnType<typeof vi.fn>;
|
|
play: ReturnType<typeof vi.fn>;
|
|
pause: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
|
|
mockAudio = {
|
|
preload: "",
|
|
src: "",
|
|
muted: false,
|
|
volume: 1,
|
|
playbackRate: 1,
|
|
currentTime: 0,
|
|
load: vi.fn(),
|
|
play: vi.fn().mockResolvedValue(undefined),
|
|
pause: vi.fn(),
|
|
};
|
|
vi.spyOn(globalThis, "Audio").mockImplementation(
|
|
() => mockAudio as unknown as HTMLAudioElement,
|
|
);
|
|
|
|
player = document.createElement("hyperframes-player") as typeof player;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("defaults volume to 1", () => {
|
|
document.body.appendChild(player);
|
|
expect(player.volume).toBe(1);
|
|
});
|
|
|
|
it("sets volume on parent media when audio-src is configured", () => {
|
|
player.setAttribute("volume", "0.5");
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
expect(mockAudio.volume).toBe(0.5);
|
|
});
|
|
|
|
it("updates parent media volume when volume attribute changes", () => {
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
player.setAttribute("volume", "0.3");
|
|
expect(mockAudio.volume).toBe(0.3);
|
|
});
|
|
|
|
it("clamps volume to [0, 1]", () => {
|
|
document.body.appendChild(player);
|
|
|
|
player.volume = 1.5;
|
|
expect(player.volume).toBe(1);
|
|
|
|
player.volume = -0.5;
|
|
expect(player.volume).toBe(0);
|
|
});
|
|
|
|
it("dispatches volumechange event when volume changes", () => {
|
|
document.body.appendChild(player);
|
|
|
|
const handler = vi.fn();
|
|
player.addEventListener("volumechange", handler);
|
|
|
|
player.setAttribute("volume", "0.7");
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("muted property toggles the muted attribute", () => {
|
|
document.body.appendChild(player);
|
|
|
|
player.muted = true;
|
|
expect(player.hasAttribute("muted")).toBe(true);
|
|
|
|
player.muted = false;
|
|
expect(player.hasAttribute("muted")).toBe(false);
|
|
});
|
|
|
|
it("muted property directly mutes same-origin iframe media", () => {
|
|
document.body.appendChild(player);
|
|
const doc = document.implementation.createHTMLDocument("composition");
|
|
const video = doc.createElement("video");
|
|
const authoredMuted = doc.createElement("audio");
|
|
authoredMuted.defaultMuted = true;
|
|
doc.body.append(video, authoredMuted);
|
|
stubIframeContentDocument(player.iframeElement, doc);
|
|
|
|
player.muted = true;
|
|
expect(video.muted).toBe(true);
|
|
expect(authoredMuted.muted).toBe(true);
|
|
|
|
player.muted = false;
|
|
expect(video.muted).toBe(false);
|
|
expect(authoredMuted.muted).toBe(true);
|
|
});
|
|
|
|
it("muted property mutes iframe-realm media elements", () => {
|
|
document.body.appendChild(player);
|
|
const { doc, video, audio } = createForeignFrameMediaDocument();
|
|
audio.defaultMuted = true;
|
|
stubIframeContentDocument(player.iframeElement, doc);
|
|
|
|
player.muted = true;
|
|
expect(video.muted).toBe(true);
|
|
expect(audio.muted).toBe(true);
|
|
|
|
player.muted = false;
|
|
expect(video.muted).toBe(false);
|
|
expect(audio.muted).toBe(true);
|
|
});
|
|
|
|
it("sends set-volume control to iframe", () => {
|
|
document.body.appendChild(player);
|
|
|
|
const postMessageSpy = vi.fn();
|
|
Object.defineProperty(player.iframeElement, "contentWindow", {
|
|
value: { postMessage: postMessageSpy },
|
|
configurable: true,
|
|
});
|
|
|
|
player.setAttribute("volume", "0.6");
|
|
expect(postMessageSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
source: "hf-parent",
|
|
type: "control",
|
|
action: "set-volume",
|
|
volume: 0.6,
|
|
}),
|
|
"*",
|
|
);
|
|
});
|
|
|
|
it("controls bar shows mute button when controls are enabled", () => {
|
|
player.setAttribute("controls", "");
|
|
document.body.appendChild(player);
|
|
|
|
const shadow = player.shadowRoot!;
|
|
const muteBtn = shadow.querySelector(".hfp-mute-btn");
|
|
expect(muteBtn).toBeTruthy();
|
|
expect(muteBtn?.getAttribute("aria-label")).toBe("Mute");
|
|
});
|
|
|
|
it("controls bar shows volume slider when controls are enabled", () => {
|
|
player.setAttribute("controls", "");
|
|
document.body.appendChild(player);
|
|
|
|
const shadow = player.shadowRoot!;
|
|
const slider = shadow.querySelector(".hfp-volume-slider");
|
|
expect(slider).toBeTruthy();
|
|
});
|
|
|
|
it("volume slider has ARIA slider attributes", () => {
|
|
player.setAttribute("controls", "");
|
|
document.body.appendChild(player);
|
|
|
|
const shadow = player.shadowRoot!;
|
|
const slider = shadow.querySelector(".hfp-volume-slider")!;
|
|
expect(slider.getAttribute("role")).toBe("slider");
|
|
expect(slider.getAttribute("aria-label")).toBe("Volume");
|
|
expect(slider.getAttribute("aria-valuemin")).toBe("0");
|
|
expect(slider.getAttribute("aria-valuemax")).toBe("100");
|
|
expect(slider.getAttribute("aria-valuenow")).toBe("100");
|
|
expect(slider.getAttribute("tabindex")).toBe("0");
|
|
});
|
|
|
|
it("removes and recreates one controls bar when the controls attribute toggles", () => {
|
|
document.body.appendChild(player);
|
|
|
|
player.setAttribute("controls", "");
|
|
expect(player.shadowRoot!.querySelectorAll(".hfp-controls")).toHaveLength(1);
|
|
|
|
player.removeAttribute("controls");
|
|
expect(player.shadowRoot!.querySelectorAll(".hfp-controls")).toHaveLength(0);
|
|
|
|
player.setAttribute("controls", "");
|
|
expect(player.shadowRoot!.querySelectorAll(".hfp-controls")).toHaveLength(1);
|
|
});
|
|
|
|
it("dispatches volumechange when muted toggles (HTML5 spec)", () => {
|
|
document.body.appendChild(player);
|
|
|
|
const handler = vi.fn();
|
|
player.addEventListener("volumechange", handler);
|
|
|
|
player.muted = true;
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
|
|
player.muted = false;
|
|
expect(handler).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("muted icon differs from volume=0 unmuted icon", () => {
|
|
player.setAttribute("controls", "");
|
|
document.body.appendChild(player);
|
|
|
|
const shadow = player.shadowRoot!;
|
|
const muteBtn = shadow.querySelector(".hfp-mute-btn")!;
|
|
|
|
player.setAttribute("volume", "0");
|
|
const zeroVolumeHtml = muteBtn.innerHTML;
|
|
|
|
player.muted = true;
|
|
const mutedHtml = muteBtn.innerHTML;
|
|
|
|
expect(zeroVolumeHtml).not.toBe(mutedHtml);
|
|
});
|
|
});
|
|
|
|
// ── Audio lock ──
|
|
|
|
describe("HyperframesPlayer audio lock", () => {
|
|
let player: HTMLElement & { muted: boolean; audioLocked: boolean };
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
player = document.createElement("hyperframes-player") as typeof player;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("audioLocked property toggles the audio-locked attribute", () => {
|
|
document.body.appendChild(player);
|
|
|
|
player.audioLocked = true;
|
|
expect(player.hasAttribute("audio-locked")).toBe(true);
|
|
|
|
player.audioLocked = false;
|
|
expect(player.hasAttribute("audio-locked")).toBe(false);
|
|
});
|
|
|
|
it("forces muted when audio-locked is set", () => {
|
|
document.body.appendChild(player);
|
|
expect(player.hasAttribute("muted")).toBe(false);
|
|
|
|
player.setAttribute("audio-locked", "");
|
|
expect(player.muted).toBe(true);
|
|
expect(player.hasAttribute("muted")).toBe(true);
|
|
});
|
|
|
|
it("re-asserts mute when something tries to unmute while locked", () => {
|
|
document.body.appendChild(player);
|
|
player.setAttribute("audio-locked", "");
|
|
|
|
// Direct property unmute
|
|
player.muted = false;
|
|
expect(player.hasAttribute("muted")).toBe(true);
|
|
|
|
// Raw attribute removal
|
|
player.removeAttribute("muted");
|
|
expect(player.hasAttribute("muted")).toBe(true);
|
|
});
|
|
|
|
it("allows unmute again once unlocked", () => {
|
|
document.body.appendChild(player);
|
|
player.setAttribute("audio-locked", "");
|
|
expect(player.muted).toBe(true);
|
|
|
|
// Unlock does NOT auto-unmute — it only lifts the restriction.
|
|
player.removeAttribute("audio-locked");
|
|
expect(player.muted).toBe(true);
|
|
|
|
// Now the viewer/host can unmute.
|
|
player.muted = false;
|
|
expect(player.hasAttribute("muted")).toBe(false);
|
|
});
|
|
|
|
it("hides the volume controls when locked after controls exist", () => {
|
|
player.setAttribute("controls", "");
|
|
document.body.appendChild(player);
|
|
|
|
const volumeWrap = player.shadowRoot!.querySelector(".hfp-volume-wrap") as HTMLElement;
|
|
expect(volumeWrap.style.display).not.toBe("none");
|
|
|
|
player.setAttribute("audio-locked", "");
|
|
expect(volumeWrap.style.display).toBe("none");
|
|
});
|
|
|
|
it("hides the volume controls when controls are created while already locked", () => {
|
|
player.setAttribute("audio-locked", "");
|
|
player.setAttribute("controls", "");
|
|
document.body.appendChild(player);
|
|
|
|
const volumeWrap = player.shadowRoot!.querySelector(".hfp-volume-wrap") as HTMLElement;
|
|
expect(volumeWrap.style.display).toBe("none");
|
|
});
|
|
|
|
it("restores the volume controls when unlocked", () => {
|
|
player.setAttribute("controls", "");
|
|
player.setAttribute("audio-locked", "");
|
|
document.body.appendChild(player);
|
|
|
|
const volumeWrap = player.shadowRoot!.querySelector(".hfp-volume-wrap") as HTMLElement;
|
|
expect(volumeWrap.style.display).toBe("none");
|
|
|
|
player.removeAttribute("audio-locked");
|
|
expect(volumeWrap.style.display).not.toBe("none");
|
|
});
|
|
});
|
|
|
|
describe("HyperframesPlayer runtime ready handshake", () => {
|
|
// When the iframe runtime announces `{type: "ready"}` the player replays
|
|
// current bridge state (muted, volume, playback rate) so any control message
|
|
// that arrived before the iframe runtime registered its listener isn't lost.
|
|
// This fixes a deterministic race on warm-cache reloads of claude.ai and
|
|
// inside the Claude desktop Electron client where the iframe finishes
|
|
// loading after the player has already set audio-locked.
|
|
interface PlayerInternal extends HTMLElement {
|
|
muted: boolean;
|
|
volume: number;
|
|
audioLocked: boolean;
|
|
playbackRate: number;
|
|
ready: boolean;
|
|
duration: number;
|
|
paused: boolean;
|
|
iframe: HTMLIFrameElement;
|
|
_onMessage: (event: MessageEvent) => void;
|
|
}
|
|
|
|
let player: PlayerInternal;
|
|
let frameWindow: Window;
|
|
let postSpy: ReturnType<typeof vi.spyOn>;
|
|
|
|
function readyMessage() {
|
|
return new MessageEvent("message", {
|
|
source: frameWindow,
|
|
data: { source: "hf-preview", type: "ready" },
|
|
});
|
|
}
|
|
|
|
function timelineMessage(durationInFrames = 120) {
|
|
return new MessageEvent("message", {
|
|
source: frameWindow,
|
|
data: {
|
|
source: "hf-preview",
|
|
type: "timeline",
|
|
durationInFrames,
|
|
scenes: [],
|
|
},
|
|
});
|
|
}
|
|
|
|
function findControlCalls(action: string) {
|
|
return postSpy.mock.calls.filter((call) => {
|
|
const data = call[0] as { type?: string; action?: string };
|
|
return data?.type === "control" && data?.action === action;
|
|
});
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
player = document.createElement("hyperframes-player") as PlayerInternal;
|
|
frameWindow = window;
|
|
postSpy = vi.spyOn(frameWindow, "postMessage").mockImplementation(() => undefined);
|
|
Object.defineProperty(player.iframe, "contentWindow", {
|
|
configurable: true,
|
|
get: () => frameWindow,
|
|
});
|
|
document.body.appendChild(player);
|
|
});
|
|
|
|
afterEach(() => {
|
|
player.remove();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("replays current muted state when runtime emits ready", () => {
|
|
player.muted = true;
|
|
postSpy.mockClear();
|
|
|
|
player._onMessage(readyMessage());
|
|
|
|
const muteCalls = findControlCalls("set-muted");
|
|
expect(muteCalls).toHaveLength(1);
|
|
expect(muteCalls[0]?.[0]).toMatchObject({
|
|
source: "hf-parent",
|
|
type: "control",
|
|
action: "set-muted",
|
|
muted: true,
|
|
});
|
|
});
|
|
|
|
it("replays volume and playback-rate alongside muted", () => {
|
|
player.volume = 0.5;
|
|
player.playbackRate = 1.25;
|
|
postSpy.mockClear();
|
|
|
|
player._onMessage(readyMessage());
|
|
|
|
expect(findControlCalls("set-muted")).toHaveLength(1);
|
|
expect(findControlCalls("set-volume")[0]?.[0]).toMatchObject({
|
|
action: "set-volume",
|
|
volume: 0.5,
|
|
});
|
|
expect(findControlCalls("set-playback-rate")[0]?.[0]).toMatchObject({
|
|
action: "set-playback-rate",
|
|
playbackRate: 1.25,
|
|
});
|
|
});
|
|
|
|
it("keeps runtime WebAudio media enabled outside slideshow embeds", () => {
|
|
postSpy.mockClear();
|
|
|
|
player._onMessage(readyMessage());
|
|
|
|
expect(findControlCalls("set-native-media-sync-disabled")[0]?.[0]).toMatchObject({
|
|
action: "set-native-media-sync-disabled",
|
|
disabled: false,
|
|
});
|
|
expect(findControlCalls("set-web-audio-media-disabled")[0]?.[0]).toMatchObject({
|
|
action: "set-web-audio-media-disabled",
|
|
disabled: false,
|
|
});
|
|
});
|
|
|
|
it("disables runtime WebAudio media inside slideshow embeds", () => {
|
|
const slideshow = document.createElement("hyperframes-slideshow");
|
|
slideshow.appendChild(player);
|
|
document.body.appendChild(slideshow);
|
|
postSpy.mockClear();
|
|
|
|
player._onMessage(readyMessage());
|
|
|
|
expect(findControlCalls("set-native-media-sync-disabled")[0]?.[0]).toMatchObject({
|
|
action: "set-native-media-sync-disabled",
|
|
disabled: true,
|
|
});
|
|
expect(findControlCalls("set-web-audio-media-disabled")[0]?.[0]).toMatchObject({
|
|
action: "set-web-audio-media-disabled",
|
|
disabled: true,
|
|
});
|
|
slideshow.remove();
|
|
});
|
|
|
|
it("replays the muted state forced by audio-locked", () => {
|
|
// The audio-locked attribute is the original motivating case for this
|
|
// handshake — its `muted = true` side effect must survive an iframe race.
|
|
player.setAttribute("audio-locked", "");
|
|
expect(player.muted).toBe(true);
|
|
postSpy.mockClear();
|
|
|
|
player._onMessage(readyMessage());
|
|
|
|
const muteCalls = findControlCalls("set-muted");
|
|
expect(muteCalls).toHaveLength(1);
|
|
expect(muteCalls[0]?.[0]).toMatchObject({ action: "set-muted", muted: true });
|
|
});
|
|
|
|
it("replays again on a second ready (idempotent — iframe reloads emit again)", () => {
|
|
player.muted = true;
|
|
postSpy.mockClear();
|
|
|
|
player._onMessage(readyMessage());
|
|
player._onMessage(readyMessage());
|
|
|
|
expect(findControlCalls("set-muted")).toHaveLength(2);
|
|
});
|
|
|
|
it("ignores ready events from a different window", () => {
|
|
postSpy.mockClear();
|
|
const otherSource = {} as Window;
|
|
|
|
player._onMessage(
|
|
new MessageEvent("message", {
|
|
source: otherSource,
|
|
data: { source: "hf-preview", type: "ready" },
|
|
}),
|
|
);
|
|
|
|
expect(findControlCalls("set-muted")).toHaveLength(0);
|
|
});
|
|
|
|
it("treats a cross-origin runtime timeline message as player ready", () => {
|
|
const readyEvents: Array<{ duration: number }> = [];
|
|
player.addEventListener("ready", (event) => {
|
|
readyEvents.push((event as CustomEvent<{ duration: number }>).detail);
|
|
});
|
|
|
|
player._onMessage(timelineMessage(120));
|
|
|
|
expect(player.ready).toBe(true);
|
|
expect(player.duration).toBe(4);
|
|
expect(readyEvents).toEqual([{ duration: 4 }]);
|
|
});
|
|
|
|
it("honors autoplay after cross-origin runtime timeline readiness", () => {
|
|
player.setAttribute("autoplay", "");
|
|
postSpy.mockClear();
|
|
|
|
player._onMessage(timelineMessage(120));
|
|
|
|
expect(player.paused).toBe(false);
|
|
expect(findControlCalls("play")).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("HyperframesPlayer audio lock — Claude desktop UA fallback", () => {
|
|
// Some host renderers (observed on the Claude desktop Electron client) strip
|
|
// unknown custom-element attributes before they reach the DOM, so the
|
|
// `audio-locked` attribute is lost. The player self-imposes the lock based
|
|
// on UA detection so chat-host audio stays muted even without the attribute.
|
|
let player: HTMLElement & { muted: boolean; audioLocked: boolean };
|
|
let originalUserAgent: PropertyDescriptor | undefined;
|
|
|
|
function stubUserAgent(ua: string) {
|
|
Object.defineProperty(navigator, "userAgent", {
|
|
value: ua,
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
originalUserAgent = Object.getOwnPropertyDescriptor(
|
|
Object.getPrototypeOf(navigator),
|
|
"userAgent",
|
|
);
|
|
await import("./hyperframes-player.js");
|
|
player = document.createElement("hyperframes-player") as typeof player;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (originalUserAgent) {
|
|
Object.defineProperty(Object.getPrototypeOf(navigator), "userAgent", originalUserAgent);
|
|
}
|
|
vi.restoreAllMocks();
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("forces muted on Claude desktop UA even without the audio-locked attribute", () => {
|
|
stubUserAgent(
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
|
"(KHTML, like Gecko) Claude/1.11187.4 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36",
|
|
);
|
|
|
|
document.body.appendChild(player);
|
|
|
|
expect(player.hasAttribute("audio-locked")).toBe(false);
|
|
expect(player.muted).toBe(true);
|
|
expect(player.hasAttribute("muted")).toBe(true);
|
|
});
|
|
|
|
it("re-asserts mute on Claude desktop when something tries to unmute", () => {
|
|
stubUserAgent("Claude/1.11187.4 Chrome/126.0.0.0 Electron/31.0.0");
|
|
document.body.appendChild(player);
|
|
|
|
player.muted = false;
|
|
expect(player.hasAttribute("muted")).toBe(true);
|
|
|
|
player.removeAttribute("muted");
|
|
expect(player.hasAttribute("muted")).toBe(true);
|
|
});
|
|
|
|
it("hides the volume controls on Claude desktop without the attribute", () => {
|
|
stubUserAgent("Claude/1.11187.4 Chrome/126.0.0.0 Electron/31.0.0");
|
|
player.setAttribute("controls", "");
|
|
document.body.appendChild(player);
|
|
|
|
const volumeWrap = player.shadowRoot!.querySelector(".hfp-volume-wrap") as HTMLElement;
|
|
expect(volumeWrap.style.display).toBe("none");
|
|
});
|
|
|
|
it("does NOT force mute on a regular browser UA", () => {
|
|
stubUserAgent(
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
|
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
|
);
|
|
|
|
document.body.appendChild(player);
|
|
|
|
expect(player.hasAttribute("audio-locked")).toBe(false);
|
|
expect(player.muted).toBe(false);
|
|
expect(player.hasAttribute("muted")).toBe(false);
|
|
});
|
|
|
|
it("does NOT force mute on Electron apps that aren't Claude desktop", () => {
|
|
// Other Electron clients (e.g. VS Code embedded view) shouldn't be muted.
|
|
stubUserAgent(
|
|
"Mozilla/5.0 (Macintosh) AppleWebKit/537.36 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36",
|
|
);
|
|
|
|
document.body.appendChild(player);
|
|
|
|
expect(player.muted).toBe(false);
|
|
});
|
|
|
|
it("keeps `audioLocked` property reflecting only the attribute, not the UA fallback", () => {
|
|
// External consumers (pacific widget, etc.) read `audioLocked` to mirror
|
|
// their own state. The UA fallback is an internal safety net and must not
|
|
// leak into the public property — otherwise unsetting `audioLocked` would
|
|
// appear to have no effect from the consumer's perspective.
|
|
stubUserAgent("Claude/1.11187.4 Chrome/126.0.0.0 Electron/31.0.0");
|
|
document.body.appendChild(player);
|
|
|
|
expect(player.audioLocked).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── Playback rate ──
|
|
|
|
describe("HyperframesPlayer playback rate", () => {
|
|
let player: HTMLElement & {
|
|
playbackRate: number;
|
|
iframeElement: HTMLIFrameElement;
|
|
};
|
|
let mockAudio: {
|
|
preload: string;
|
|
src: string;
|
|
muted: boolean;
|
|
volume: number;
|
|
playbackRate: number;
|
|
currentTime: number;
|
|
load: ReturnType<typeof vi.fn>;
|
|
play: ReturnType<typeof vi.fn>;
|
|
pause: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
|
|
mockAudio = {
|
|
preload: "",
|
|
src: "",
|
|
muted: false,
|
|
volume: 1,
|
|
playbackRate: 1,
|
|
currentTime: 0,
|
|
load: vi.fn(),
|
|
play: vi.fn().mockResolvedValue(undefined),
|
|
pause: vi.fn(),
|
|
};
|
|
vi.spyOn(globalThis, "Audio").mockImplementation(
|
|
() => mockAudio as unknown as HTMLAudioElement,
|
|
);
|
|
|
|
player = document.createElement("hyperframes-player") as typeof player;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("defaults playbackRate to 1", () => {
|
|
document.body.appendChild(player);
|
|
expect(player.playbackRate).toBe(1);
|
|
});
|
|
|
|
it("clamps playbackRate to [0.1, 5]", () => {
|
|
document.body.appendChild(player);
|
|
|
|
player.playbackRate = 100;
|
|
expect(player.playbackRate).toBe(5);
|
|
// Assert the reflected attribute directly so the setter clamp is pinned
|
|
// independently of the getter clamp.
|
|
expect(player.getAttribute("playback-rate")).toBe("5");
|
|
|
|
player.playbackRate = 0.01;
|
|
expect(player.playbackRate).toBe(0.1);
|
|
expect(player.getAttribute("playback-rate")).toBe("0.1");
|
|
});
|
|
|
|
it("falls back to 1 for a non-positive or non-finite playbackRate", () => {
|
|
document.body.appendChild(player);
|
|
|
|
player.playbackRate = -2;
|
|
expect(player.playbackRate).toBe(1);
|
|
expect(player.getAttribute("playback-rate")).toBe("1");
|
|
|
|
player.playbackRate = 0;
|
|
expect(player.playbackRate).toBe(1);
|
|
|
|
player.playbackRate = NaN;
|
|
expect(player.playbackRate).toBe(1);
|
|
});
|
|
|
|
it("propagates the clamped rate to parent media, never an out-of-range value", () => {
|
|
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
|
|
document.body.appendChild(player);
|
|
|
|
player.setAttribute("playback-rate", "50");
|
|
expect(mockAudio.playbackRate).toBe(5);
|
|
|
|
player.setAttribute("playback-rate", "0.01");
|
|
expect(mockAudio.playbackRate).toBe(0.1);
|
|
});
|
|
|
|
it("sends the clamped rate as a set-playback-rate control to the iframe", () => {
|
|
document.body.appendChild(player);
|
|
|
|
const postMessageSpy = vi.fn();
|
|
Object.defineProperty(player.iframeElement, "contentWindow", {
|
|
value: { postMessage: postMessageSpy },
|
|
configurable: true,
|
|
});
|
|
|
|
player.setAttribute("playback-rate", "50");
|
|
expect(postMessageSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
source: "hf-parent",
|
|
type: "control",
|
|
action: "set-playback-rate",
|
|
playbackRate: 5,
|
|
}),
|
|
"*",
|
|
);
|
|
});
|
|
|
|
it("clamps an out-of-range value set directly via the attribute on read", () => {
|
|
document.body.appendChild(player);
|
|
|
|
player.setAttribute("playback-rate", "999");
|
|
expect(player.playbackRate).toBe(5);
|
|
});
|
|
});
|
|
|
|
// ── Composition dimension attributes ──
|
|
//
|
|
// width/height feed scaleIframeToFit's `w / compositionWidth` division. A
|
|
// non-numeric, zero, or negative attribute must fall back to the defaults
|
|
// instead of reaching the scale math as NaN (invalid `scale(NaN)` transform)
|
|
// or zero (division by zero) — both blank the player with no signal.
|
|
|
|
describe("HyperframesPlayer composition dimension attributes", () => {
|
|
type PlayerWithDimensions = HTMLElement & {
|
|
_compositionWidth?: number;
|
|
_compositionHeight?: number;
|
|
};
|
|
|
|
let player: PlayerWithDimensions;
|
|
|
|
beforeEach(async () => {
|
|
await import("./hyperframes-player.js");
|
|
player = document.createElement("hyperframes-player") as PlayerWithDimensions;
|
|
document.body.appendChild(player);
|
|
});
|
|
|
|
afterEach(() => {
|
|
document.body.innerHTML = "";
|
|
});
|
|
|
|
it("applies a valid width and height", () => {
|
|
player.setAttribute("width", "1280");
|
|
player.setAttribute("height", "720");
|
|
expect(player._compositionWidth).toBe(1280);
|
|
expect(player._compositionHeight).toBe(720);
|
|
});
|
|
|
|
it("falls back to defaults for non-numeric values", () => {
|
|
player.setAttribute("width", "abc");
|
|
player.setAttribute("height", "abc");
|
|
expect(player._compositionWidth).toBe(1920);
|
|
expect(player._compositionHeight).toBe(1080);
|
|
});
|
|
|
|
it("falls back to defaults for zero", () => {
|
|
player.setAttribute("width", "0");
|
|
player.setAttribute("height", "0");
|
|
expect(player._compositionWidth).toBe(1920);
|
|
expect(player._compositionHeight).toBe(1080);
|
|
});
|
|
|
|
it("falls back to defaults for negative values", () => {
|
|
player.setAttribute("width", "-500");
|
|
player.setAttribute("height", "-500");
|
|
expect(player._compositionWidth).toBe(1920);
|
|
expect(player._compositionHeight).toBe(1080);
|
|
});
|
|
|
|
it("recovers the defaults when the attribute is removed", () => {
|
|
player.setAttribute("width", "1280");
|
|
player.removeAttribute("width");
|
|
expect(player._compositionWidth).toBe(1920);
|
|
});
|
|
});
|