// @vitest-environment jsdom // // The YouTube IFrame API script failing to load must END in an error state. `loadYouTubeApi` used to // return a promise that only ever resolved: a blocked or failed script (a content blocker, no // network) left the modal on an empty stage forever, with no error and no way to retry short of a // reload — the promise was memoised, so every later open waited on the same dead one. // // Its own file on purpose: `loadYouTubeApi` memoises the API promise in a module-level singleton, // and these tests need a module instance that has never seen `window.YT`. import { describe, it, expect, vi, afterEach } from "vitest"; import { renderHook } from "@testing-library/react"; import { QueryClient } from "@tanstack/react-query"; import { createRef, type RefObject } from "react"; import { useYtProgress, YT_API_LOAD_FAILED, YT_API_TIMEOUT_MS } from "./playerModalHooks"; import type { YTPlayer } from "./youtube"; const VID = "EC5oFYy9z9g"; const apiScripts = () => [...document.head.querySelectorAll("script")].filter((s) => s.src.includes("iframe_api")); const settle = async () => { for (let i = 0; i < 6; i++) await Promise.resolve(); }; function mountPlayer(setPlayerError: (code: number | null) => void) { const mount = document.createElement("div"); document.body.appendChild(mount); return renderHook(() => useYtProgress({ activeId: VID, resumeAt: 0, exactStart: false, mountRef: { current: mount }, playerRef: createRef() as RefObject, currentIdRef: { current: VID }, navRef: { current: { queue: undefined, index: 0 } }, modeRef: { current: { autoMode: "off", loopMode: "off" } }, setWatched: () => {}, setPlayerError, setLiveData: () => {}, reapplyStored: () => {}, reclaimFocus: () => {}, advanceOnEnd: () => {}, sessionPosRef: { current: new Map() }, qc: new QueryClient(), }), ); } describe("useYtProgress — the IFrame API failing to load", () => { // Fail any load still in flight, so the module's memoised promise is cleared for the next test // (a pending one would carry a timer from before that test's fake clock). afterEach(async () => { for (const s of apiScripts()) s.dispatchEvent(new Event("error")); await settle(); vi.useRealTimers(); }); it("shows the load-failed state when the script errors, and a later open tries again", async () => { const setPlayerError = vi.fn(); const first = mountPlayer(setPlayerError); const [tag] = apiScripts(); expect(tag).toBeDefined(); tag!.dispatchEvent(new Event("error")); await settle(); expect(setPlayerError).toHaveBeenCalledWith(YT_API_LOAD_FAILED); first.unmount(); // Not memoised as a failure: the next open loads the script again rather than waiting on the // dead promise. const again = mountPlayer(vi.fn()); expect(apiScripts()).toHaveLength(1); again.unmount(); }); it("gives up after the timeout when the script never answers", async () => { vi.useFakeTimers(); const setPlayerError = vi.fn(); const { unmount } = mountPlayer(setPlayerError); await vi.advanceTimersByTimeAsync(YT_API_TIMEOUT_MS + 1); expect(setPlayerError).toHaveBeenCalledWith(YT_API_LOAD_FAILED); unmount(); }); });