Files
hyperframes/packages/player/src/hyperframes-player.test.ts
T
Miguel Ángel ea7c48f372 fix(add): make chosen variables actually take effect, in the CLI and the preview (#3316)
* fix(add): apply --vars to components, and explain a failed download

Customising an item on the catalog page, copying the printed command and
running it did nothing for a component. `--vars` was accepted, documented
and then dropped: buildSnippet put the values on a block's mount element
and returned a bare "paste from ..." comment for a component, so 221 of
the 375 catalog items silently ignored every value the page produced.

A component has no mount element to hang values on. It is markup pasted
into a host, and it resolves values through __hyperframes.getVariables(),
which merges the declared defaults of every [data-composition-variables]
element in the document with render-time overrides. So the component's
own declaration is the only place a chosen value can live and still be
there after the paste. `add --vars` now rewrites those defaults.

Blocks keep the mount attribute. Per-mount values are strictly better
where a mount exists: the file on disk stays byte-identical to the
registry's, so a later reinstall can still tell an edit from an update,
and two mounts of the same block can differ.

A value the item cannot accept is now refused rather than written. An
out-of-range number or an unlisted enum value falls back at runtime and
warns, so writing one would produce a file that renders exactly as if the
value had been ignored -- the failure this change exists to remove. Ids
the item never declared are reported too, instead of vanishing. Only the
requested item is rewritten; a dependency dragged in behind it never
declared these variables.

Separately, `Install failed: fetch failed` is now a sentence. Item FILES
are not cached (only manifests are), so a network blip surfaces as node's
bare message with no URL and no cause, immediately after the user copied
a command off a web page -- which reads as "the command was wrong" rather
than "the network was". It now names what failed, says it is usually
connectivity or a proxy rather than a bad command, and mentions
HTTPS_PROXY.

Also fixes the two transcribe tests that were failing before this branch.
They assert the whisper soft-skip path but never pinned the engine, and
`auto` picks Parakeet whenever parakeet-mlx is installed -- so on those
machines the test shelled out to a real ASR binary, failed with "Parakeet
did not produce output", and landed in the generic failure branch it
claims is never taken. Pinned to `engine: "whisper"`, plus an assertion
that the mocked transcribe actually ran, which is what stops the test
passing on a machine without Parakeet while testing nothing on one with
it. The file now runs in 18ms rather than 3.7s, because it no longer
launches a subprocess.

Test plan: 10 new tests for the rewrite (enum and range refusal, the
numeric-string coercion the catalog URL depends on since every query
value is a string, delimiter escaping, unparseable declarations) and 3
for the failure message. Full CLI suite: 2661 passed, ZERO failures.

Verified as a user, not just in unit tests: installed blur-in with the
exact reported command, confirmed the declaration carried 76 / accent /
center, pasted it into a composition and ran `check` -- which reported
canvas_overflow at 76px, which only happens if the baked size is really
in effect. Bad values warn and are refused; blocks still emit
data-variable-values.

* fix(player): load the runtime before the body, not after

Customising a component on a catalog page did nothing to the preview.
badge-pop with count 10 and a green accent rendered 3, in red.

The probe injects the runtime by appending a script to an already loaded
document, and only once it has a reason to: a nested composition, or five
polls with a timeline present. A component has neither. It is markup
pasted into a composition, and it reads its values in an inline IIFE that
runs while the body is parsing:

    var vars = window.__hyperframes && window.__hyperframes.getVariables
      ? window.__hyperframes.getVariables() : {};

With the runtime arriving afterwards that guard always took the empty
branch, so the component used the defaults hardcoded in its own script
and every chosen value was dropped. The values were never the problem:
the preview sets window.__hfVariables correctly, and nothing was there to
read it.

prepareSrcdocForElement now puts the same runtime URL in the document's
head before the srcdoc is set. A classic external script in head is
parser-blocking, so it runs before body scripts without changing what
gets loaded or adding a dependency the player did not already have. A CLI
render never had this bug because the engine already orders it this way.

Skipped when the page carries the runtime already, so a CLI-rendered page
(which inlines it) does not get a second copy re-initialising the runtime
underneath a live composition. The probe's late injection stays for the
src= path, where there is no srcdoc to prepare. The runtime URL moved to
its own module so the two injection points cannot drift apart.

Test plan: 8 new tests for the injection (ordering against the reading
script, head placement, both no-op guards, missing head/body, attributes
on the head tag). Three srcdoc tests asserted byte-identical forwarding
and now assert what they were actually protecting -- that the composition
arrives intact -- plus the new runtime guarantee. player 338 passed,
studio 4249 passed.

Verified end to end against the real runtime and a real registry
component, asking for size 96 / accent / right:
  before  52px, rgb(243,243,243), flex-start, runtime absent
  after   96px, rgb(60,230,172),  flex-end,   runtime present
rgb(60,230,172) is #3ce6ac, the accent green. That is the reported bug
before, and the chosen values after.

* fix(add): name the registry and the real reason an install failed, and retry

`Install failed: fetch failed` was two words that describe every network
problem equally badly. Three things were missing, and each of them was
the whole answer in a different case.

The URL. undici throws with no URL attached, so a project that points
`registry` at a private host in hyperframes.json got a message that
looked like the public registry had failed. Naming the URL is the entire
diagnosis there.

The cause. undici buries the real reason one or two levels down in
`cause`, and it was being dropped. The reported failure turned out to be
`self-signed certificate in certificate chain`: a private registry whose
certificate node refuses and curl accepts, which is why the host looked
healthy from a terminal. That sentence tells the reader which knob to
turn; `fetch failed` sends them to check a connection that is working.

The retry. Item files are the one uncached path -- manifests fall back to
a stale copy, but every install downloads its files fresh -- so a single
blip killed the whole command. Now two extra attempts with short backoff,
and deliberately NOT for TLS failures: a self-signed certificate fails
identically every time, so retrying it only makes the user wait three
times as long for the same message.

Also retypes the declaration reader. It modelled variables as a local
interface of six `unknown` fields and re-checked each one at every use.
Core already owns this shape as a discriminated union and exports
`isCompositionVariable`, the same predicate `parseCompositionVariables`
filters with, so the union is used directly and the duplicate type is
gone. A declaration the schema rejects now leaves the file untouched
rather than being partially rewritten from guesses.

Test plan: 4 retry and URL tests, 5 cause-chain tests, and the add-side
tests now cover the custom-registry hint and its absence on the default
registry. The variableDefaults fixtures gained the `label` the schema
actually requires; without it they were not valid declarations, which the
stricter reader caught. CLI suite 2671 passed, zero failures.

Verified with the BUILT dist rather than the source, in the reporter's
own project directory. The failure now reads:

  File fetch failed: https://<host>/registry/components/blur-in/blur-in.html
    - fetch failed (self-signed certificate in certificate chain
      [SELF_SIGNED_CERT_IN_CHAIN])

and once the project points back at the public registry the original
command succeeds with `variables applied: size, tone, align`.

* fix(registry): name the registry on the not-found path too

The item-file failure now names the host it could not reach, but the
sibling path did not. A project whose registry is unreachable at the
MANIFEST stage got `Item "blur-in" not found - registry unreachable or
empty`, which reads as the public catalog having lost the item and sends
the reader to search a registry that never saw the request.

Same fix, same reason, applied where the other three call sites live so
one of them cannot stay behind: the message names the host and says it
came from this project's hyperframes.json, and only when it is not the
public registry, so the common case stays short.

Test plan: 3 tests covering the private-registry hint and its absence on
the default registry and on no registry at all. CLI suite 2674 passed,
zero failures. Verified with the built dist against a host with a bad
certificate:

  Item "blur-in" not found - registry unreachable or empty. Contacted
  https://self-signed.badssl.com/registry, set by this project's
  hyperframes.json, not the public registry.

* fix(catalog): reconcile the two spellings of a compound word

`countdown` returned exactly one item, the only thing tagged with that
spelling. `count down timer` returned sixteen, and that one was in none
of them. The tokenizer splits on word boundaries, so the two spellings of
a single idea produced disjoint sets, and whichever phrasing an author
happened to type decided which half of the answer they saw. Neither half
was the whole answer: the one-word spelling hid count-up and
decline-chart, which are the two things you would actually build with.

Both directions now, each gated on the catalog's own vocabulary so this
can only add signal. A query token is split when both halves are words
the catalog uses, and adjacent tokens are joined when the compound is.
A word in neither form, like `timer` which appears in no item, is left
alone: this widens phrasing, it does not invent matches.

Everything inferred this way carries a fraction of a real token's weight.
That is the part worth keeping honest, because the first version relied
on the halves being statistically common in a 375-item catalog, which is
not the same as making them count for less. In a small corpus that
version let `type` matching the name of `type-match-cut` outrank
`typewriter` matching the name of `typewriter`: searching a word returned
something that merely contained half of it. Two tests written against
that real failure caught it.

All spellings now return the same 17 items, and each still ranks its own
exact match first: `countdown` leads with yt-circle-pointer, `count down`
leads with the two-word items, and count-up and decline-chart appear in
both.

Test plan: 6 new tests covering both directions, the identical-set
property that was the actual defect, exact-match precedence, an unknown
word left alone, and the typewriter case. Eval set unchanged at 33/39
top-1 and 39/39 top-3, so no query regressed. CLI suite 2680 passed.
2026-08-17 20:31:37 -04:00

2390 lines
80 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("owns exactly one controls, media, and listener set across ten reconnects", () => {
const reconnectingPlayer = player as PlayerElement & {
readonly iframeElement: HTMLIFrameElement;
readonly paused: boolean;
readonly _parentMedia: unknown[];
shadowRoot: ShadowRoot;
};
reconnectingPlayer.setAttribute("controls", "");
reconnectingPlayer.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
const windowAdd = vi.spyOn(window, "addEventListener");
const windowRemove = vi.spyOn(window, "removeEventListener");
const iframeAdd = vi.spyOn(reconnectingPlayer.iframeElement, "addEventListener");
const iframeRemove = vi.spyOn(reconnectingPlayer.iframeElement, "removeEventListener");
for (let cycle = 0; cycle < 10; cycle++) {
document.body.appendChild(reconnectingPlayer);
expect(reconnectingPlayer.shadowRoot.querySelectorAll(".hfp-controls")).toHaveLength(1);
expect(reconnectingPlayer._parentMedia).toHaveLength(1);
reconnectingPlayer.remove();
expect(reconnectingPlayer.shadowRoot.querySelectorAll(".hfp-controls")).toHaveLength(0);
expect(reconnectingPlayer._parentMedia).toHaveLength(0);
expect(reconnectingPlayer.paused).toBe(true);
}
document.body.appendChild(reconnectingPlayer);
expect(reconnectingPlayer.shadowRoot.querySelectorAll(".hfp-controls")).toHaveLength(1);
expect(reconnectingPlayer._parentMedia).toHaveLength(1);
expect(
windowAdd.mock.calls.filter(([eventName]) => eventName === "message").length -
windowRemove.mock.calls.filter(([eventName]) => eventName === "message").length,
).toBe(1);
expect(
iframeAdd.mock.calls.filter(([eventName]) => eventName === "load").length -
iframeRemove.mock.calls.filter(([eventName]) => eventName === "load").length,
).toBe(1);
});
it("returns parent-media ownership to the runtime after reconnect", () => {
player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
document.body.appendChild(player);
player._promoteToParentProxy?.();
expect(player._audioOwner).toBe("parent");
player.remove();
document.body.appendChild(player);
expect(player._audioOwner).toBe("runtime");
});
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",
timeSeconds: 12.5,
frame: 375,
protocolVersion: 1,
}),
"*",
);
});
it("carries a frame fallback for accepted legacy cross-origin runtimes", () => {
// An older runtime ignores protocol-v1 metadata and reads only `frame`.
// Omitting it makes that bridge default every seek to frame zero.
const post = vi.fn();
stubContentWindow({ postMessage: post });
player.seek(7.5);
expect(post).toHaveBeenCalledWith(
expect.objectContaining({
action: "seek",
timeSeconds: 7.5,
frame: 225,
}),
"*",
);
});
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",
timeSeconds: 7,
frame: 210,
protocolVersion: 1,
}),
"*",
);
});
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);
// Not byte-identical: srcdoc now also carries the runtime, injected ahead
// of body scripts so a pasted component can read its variables during
// parse. The composition itself must still arrive intact.
expect(player.iframe.getAttribute("srcdoc")).toContain("<body>hello</body>");
expect(player.iframe.getAttribute("srcdoc")).toContain("hyperframe.runtime.iife.js");
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")).toContain("<body>after connect</body>");
expect(player.iframe.getAttribute("srcdoc")).toContain("hyperframe.runtime.iife.js");
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");
// srcdoc carries the runtime now; what matters here is that both
// attributes are present so the browser can arbitrate.
expect(player.iframe.getAttribute("srcdoc")).toContain("<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);
});
it("rescales the iframe on cross-origin timeline readiness even without a stage-size message", () => {
// Regression: the runtime's postTimeline() only sends `stage-size` when it
// can resolve the root's data-width/data-height at that instant — a race
// that can lose on first paint. onRuntimeTimelineReady must not depend on
// stage-size having arrived, or the iframe is left unscaled/untranslated
// (rendered pinned to the top-left instead of centered and fit).
Object.defineProperty(player, "offsetWidth", { value: 400, configurable: true });
Object.defineProperty(player, "offsetHeight", { value: 300, configurable: true });
expect(player.iframe.style.transform).toBe("");
player._onMessage(timelineMessage(120));
expect(player.iframe.style.transform).not.toBe("");
expect(player.iframe.style.transform).toContain("translate(-50%, -50%)");
});
it("warns at most once per instance when rescale keeps no-oping after ready", () => {
// A player that stays zero-size after ready (hidden tab, collapsed
// carousel card) keeps getting rescale attempts from every subsequent
// width/height attribute change and ResizeObserver tick. The diagnostic
// warning must not spam the console once per instance.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
player._onMessage(timelineMessage(120)); // first no-op after ready
player.setAttribute("width", "800"); // still zero-size — would no-op again
player.setAttribute("height", "450"); // ditto
expect(warnSpy).toHaveBeenCalledTimes(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);
});
});