- Every state and Watch-later write goes through features/video/videoWrites, which marks the never-refetched lists (related lists, live searches) once the server has it; a contract test holds it. The Notification Center's undo, the feed's reset and the Watch-later toggles skipped it before. - Confirmed marks are also kept apart (qk.videoMarks) and laid over the related rows: a Show more page landing later rebuilt them from the pages it read when it started, and dropped a mark made meanwhile. - Restart on a related video starts at 0 (the session's stored position won before), and on the one already playing it seeks. - The watched toggle remembers each video's mark: Back showed the opened video's launch-time state after a related one was marked. - The help sheet leaves out the player's keys while a layer above it has them waiting. - After a refusal a retry cannot answer, neither Show more (related) nor Load more (live search) is offered; a failed live-search page keeps the results already read and says why under them. - The related cap message counts requests, as the cap does.
86 lines
3.3 KiB
TypeScript
86 lines
3.3 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 },
|
|
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();
|
|
});
|
|
});
|