/*
RFC-0950: tests for video-player runtime — conditional Plyr controls, viewport play/pause, HLS+autoplay timing.
- Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only.
- RFC-0950: initial video-player tests.
- RFC-1175: reduced-motion autoplay suppression test.
- RFC-1175: close Lighthouse gaps — directive-allowlist robots (Schemamap retired, ROBOTS-SURFACE-01), capability-gated video (preload=none, data-bg-video, saveData/reduced-motion), single fetchpriority=high per page (IMG-DELIVERY-05), inlined shared texture token, 24px QR touch targets; RFC-1177 security-headers draft
*/
import { test, expect, vi, beforeEach, afterEach, type Mock } from "vitest";
// ── Mock types ──────────────────────────────────────────────────────────────
interface MockVideo {
dataset: Record;
canPlayType: ReturnType;
play: ReturnType;
pause: ReturnType;
load: ReturnType;
insertBefore: ReturnType;
firstChild: null;
}
interface MockHlsInstance {
loadSource: ReturnType;
attachMedia: ReturnType;
destroy: ReturnType;
on: Mock<(event: string, cb: () => void) => void>;
}
interface MockEntry {
isIntersecting: boolean;
target: MockVideo;
}
interface MockObserver {
unobserve: () => void;
}
interface MockDocument {
querySelectorAll: ReturnType;
createElement: ReturnType;
head: { appendChild: ReturnType };
}
// ── Mocks ───────────────────────────────────────────────────────────────────
const mockPlyr = vi.hoisted(() => vi.fn(() => ({ destroy: vi.fn() })));
vi.mock("plyr", () => ({ default: mockPlyr }));
vi.mock("plyr/dist/plyr.css?inline", () => ({ default: "/* plyr css */" }));
vi.mock("../assets/plyr.sprite.svg?url", () => ({ default: "/_astro/plyr.sprite.svg" }));
vi.mock("hls.js/light", () => {
const hlsRef = { current: null as MockHlsInstance | null };
const fn = vi.fn(function Hls() {
return hlsRef.current;
});
const Hls = Object.assign(fn, {
isSupported: vi.fn(() => true),
_hlsRef: hlsRef,
});
return { default: Hls };
});
// ── Helpers ─────────────────────────────────────────────────────────────────
function createMockVideo(attrs: Record = {}): MockVideo {
const video: MockVideo = {
dataset: {},
canPlayType: vi.fn(() => ""),
play: vi.fn(() => Promise.resolve()),
pause: vi.fn(),
load: vi.fn(),
insertBefore: vi.fn(),
firstChild: null,
};
for (const [k, v] of Object.entries(attrs)) {
if (v !== undefined) video.dataset[k] = v;
}
return video;
}
let observerCallbacks: ((entries: MockEntry[], obs: MockObserver) => void)[] = [];
let observerOptions: { threshold?: number; rootMargin?: string }[] = [];
let mockDocument: MockDocument;
class MockIntersectionObserver {
callback: (entries: MockEntry[], obs: MockObserver) => void;
options: { threshold?: number; rootMargin?: string };
constructor(
cb: (entries: MockEntry[], obs: MockObserver) => void,
opts: { threshold?: number; rootMargin?: string },
) {
this.callback = cb;
this.options = opts;
observerCallbacks.push(cb);
observerOptions.push(opts);
}
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
// ── Setup / teardown ────────────────────────────────────────────────────────
beforeEach(() => {
vi.clearAllMocks();
observerCallbacks = [];
observerOptions = [];
mockDocument = {
querySelectorAll: vi.fn(() => []),
createElement: vi.fn((tag: string) => ({ tagName: tag.toUpperCase() })),
head: { appendChild: vi.fn() },
};
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
vi.stubGlobal("window", globalThis);
vi.stubGlobal("document", mockDocument);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
async function triggerUpgrade(video: MockVideo): Promise {
const { initVideoPlayers } = await import("../video-player.ts");
mockDocument.querySelectorAll.mockReturnValue([video]);
await initVideoPlayers();
expect(observerCallbacks.length).toBeGreaterThanOrEqual(1);
observerCallbacks[0]!([{ isIntersecting: true, target: video }], { unobserve: vi.fn() });
await new Promise((r) => setTimeout(r, 100));
}
// ── Tests ───────────────────────────────────────────────────────────────────
test("initVideoPlayers: returns early when no [data-video-player] elements", async () => {
mockDocument.querySelectorAll.mockReturnValue([]);
const { initVideoPlayers } = await import("../video-player.ts");
await initVideoPlayers();
expect(observerCallbacks.length).toBe(0);
});
test("upgrade: data-controls='false' configures Plyr with only ['mute']", async () => {
const video = createMockVideo({ controls: "false" });
await triggerUpgrade(video);
expect(mockPlyr).toHaveBeenCalledWith(
video,
expect.objectContaining({
controls: ["mute"],
// warpgogol-m000196: the icon sprite must be same-origin — Plyr's
// cdn.plyr.io default is blocked by the axiom capture network policy
// (console-error → certify gate fail).
iconUrl: "/_astro/plyr.sprite.svg",
}),
);
});
test("upgrade: without data-controls uses full Plyr controls list", async () => {
const video = createMockVideo({});
await triggerUpgrade(video);
expect(mockPlyr).toHaveBeenCalledWith(
video,
expect.objectContaining({
controls: expect.arrayContaining([
"play-large",
"play",
"progress",
"current-time",
"mute",
"volume",
"captions",
"fullscreen",
]),
}),
);
});
test("upgrade: data-autoplay='true' sets up viewport play/pause observer with threshold 0.5", async () => {
const video = createMockVideo({ autoplay: "true" });
await triggerUpgrade(video);
const playPauseOpts = observerOptions.find((o) => o.threshold === 0.5);
expect(
playPauseOpts,
"play/pause observer with threshold 0.5 must be created when data-autoplay is true",
).toBeDefined();
});
test("upgrade: data-autoplay='true' with HLS waits for MEDIA_ATTACHED before play()", async () => {
const video = createMockVideo({ autoplay: "true", hls: "https://example.com/stream.m3u8" });
const hlsInstance = {
loadSource: vi.fn(),
attachMedia: vi.fn(),
destroy: vi.fn(),
on: vi.fn(),
};
const Hls = (await import("hls.js/light")).default as unknown as {
_hlsRef: { current: MockHlsInstance | null };
};
Hls._hlsRef.current = hlsInstance;
await triggerUpgrade(video);
expect(Hls).toHaveBeenCalled();
expect(hlsInstance.attachMedia).toHaveBeenCalledWith(video);
const attachedCall = hlsInstance.on.mock.calls.find((c) => c[0] === "hls.MEDIA_ATTACHED");
expect(attachedCall, "Hls must register a MEDIA_ATTACHED listener before play()").toBeDefined();
attachedCall![1]();
await new Promise((r) => setTimeout(r, 20));
expect(video.play).toHaveBeenCalled();
});
test("upgrade: data-autoplay='true' without HLS calls play() directly", async () => {
const video = createMockVideo({ autoplay: "true" });
await triggerUpgrade(video);
expect(video.play).toHaveBeenCalled();
});
test("upgrade: skip already-upgraded videos (data-video-player-ready='true')", async () => {
const video = createMockVideo({ videoPlayerReady: "true" });
await triggerUpgrade(video);
expect(mockPlyr).not.toHaveBeenCalled();
});
test("upgrade: viewport observer pauses video when scrolled out", async () => {
const video = createMockVideo({ autoplay: "true" });
await triggerUpgrade(video);
const playPauseIdx = observerOptions.findIndex((o) => o.threshold === 0.5);
expect(playPauseIdx).toBeGreaterThan(-1);
observerCallbacks[playPauseIdx]!([{ isIntersecting: false, target: video }], {
unobserve: vi.fn(),
});
expect(video.pause).toHaveBeenCalled();
});
test("upgrade: data-autoplay='true' never plays under prefers-reduced-motion (RFC-1175)", async () => {
const video = createMockVideo({ autoplay: "true" });
mockDocument.querySelectorAll.mockReturnValue([video]);
const { initVideoPlayers } = await import("../video-player.ts");
await initVideoPlayers({ prefersReducedMotion: true });
observerCallbacks[0]!([{ isIntersecting: true, target: video }], { unobserve: vi.fn() });
await new Promise((r) => setTimeout(r, 100));
expect(video.play).not.toHaveBeenCalled();
expect(observerOptions.some((o) => o.threshold === 0.5)).toBe(false);
});