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.
85 lines
3.2 KiB
TypeScript
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();
|
|
});
|
|
});
|