Files
siftlode/frontend/src/lib/playerModalHooks.apiload.dom.test.tsx
T
peter 15a3603513 fix(player): a step onto an unplayable video carries on the way it went
Previous onto an embed-blocked or removed video skipped forward again (the
auto-advance direction), straight back to the item just left. The player now
remembers the direction of the step that reached the item and the videos
YouTube refused this session: an error carries on that way, Previous / Next
pass over known-dead items without loading them, and go dark when only those
are left. A step of your own skips with auto-advance off too.
2026-11-01 01:14:50 +01:00

85 lines
3.2 KiB
TypeScript

// @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<YTPlayer>() as RefObject<YTPlayer | null>,
currentIdRef: { current: VID },
setWatched: () => {},
setPlayerError,
setLiveData: () => {},
reapplyStored: () => {},
reclaimFocus: () => {},
advanceOnEnd: () => {},
skipUnplayable: () => {},
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();
});
});