diff --git a/static/player/browser-flow.test.ts b/static/player/browser-flow.test.ts index f1416b03..8f8b87e6 100644 --- a/static/player/browser-flow.test.ts +++ b/static/player/browser-flow.test.ts @@ -1,13 +1,17 @@ import assert from "node:assert/strict"; import { before, beforeEach, describe, test } from "node:test"; +/* eslint-disable max-classes-per-file */ + class FakeElement extends EventTarget { + readonly dataset: Record = {}; className = ""; classList = fakeClassList(); disabled = false; parentElement: FakeElement | null = null; style: Record = {}; textContent = ""; + tabIndex = 0; private readonly children: FakeElement[] = []; private readonly queries = new Map(); @@ -16,10 +20,26 @@ class FakeElement extends EventTarget { this.children.push(child); } + closest(_selector: string): FakeElement | null { + return null; + } + + focus(options?: FocusOptions): void { + void options; + } + querySelector(selector: string): FakeElement | null { return this.queries.get(selector) ?? null; } + querySelectorAll(_selector: string): FakeElement[] { + return []; + } + + removeAttribute(name: string): void { + void name; + } + replaceChildren(...children: FakeElement[]): void { this.children.length = 0; children.forEach((child) => { @@ -30,6 +50,52 @@ class FakeElement extends EventTarget { setQuery(selector: string, element: FakeElement | null): void { this.queries.set(selector, element); } + + setAttribute(name: string, value: string): void { + void name; + void value; + } + + scrollIntoView(options?: ScrollIntoViewOptions): void { + void options; + } +} + +class FakeAnchorElement extends FakeElement { + href = ""; + target = ""; + + override closest(selector: string): FakeAnchorElement | null { + return selector === "a[data-episode-id]" ? this : null; + } +} + +class FakeMouseEvent { + readonly altKey: boolean; + readonly button: number; + readonly ctrlKey: boolean; + defaultPrevented = false; + readonly metaKey: boolean; + readonly shiftKey: boolean; + readonly target: FakeElement; + + constructor( + target: FakeElement, + options: Partial< + Pick + > = {}, + ) { + this.target = target; + this.altKey = options.altKey ?? false; + this.button = options.button ?? 0; + this.ctrlKey = options.ctrlKey ?? false; + this.metaKey = options.metaKey ?? false; + this.shiftKey = options.shiftKey ?? false; + } + + preventDefault(): void { + this.defaultPrevented = true; + } } type FakeVideoElement = FakeElement & { @@ -48,11 +114,17 @@ type FakeVideoElement = FakeElement & { type PlayerModules = { completeAnime: (episodeNumber: number) => Promise; + handleEpisodeNavigationClick: (event: MouseEvent) => boolean; refreshCurrentModeSource: (signal?: AbortSignal) => Promise; saveProgress: (force?: boolean, progressSeconds?: number) => Promise; setupProgress: () => void; state: typeof import("./state").state; streamUrlForMode: (mode: string, quality?: string) => string; + setupEpisodeNavigation: (signal: AbortSignal) => void; + transitionToEpisode: ( + episode: number, + options?: { fallbackHref?: string; history?: "none" | "push" | "replace" }, + ) => Promise; }; const emptyRanges = (): TimeRanges => ({ length: 0, end: () => 0, start: () => 0 }); @@ -127,6 +199,28 @@ const fakeDocument = (): Document & { }; const documentStub = fakeDocument(); +const storage = (): Storage => { + const values = new Map(); + return { + clear: (): void => { + values.clear(); + }, + getItem: (key: string): string | null => values.get(key) ?? null, + key: (index: number): string | null => [...values.keys()][index] ?? null, + get length(): number { + return values.size; + }, + removeItem: (key: string): void => { + values.delete(key); + }, + setItem: (key: string, value: string): void => { + values.set(key, value); + }, + }; +}; +const historyCalls: string[] = []; +const localStorageStub = storage(); +const sessionStorageStub = storage(); const windowStub = Object.assign(new EventTarget(), { clearTimeout: (_handle: number | undefined): void => { void _handle; @@ -136,8 +230,14 @@ const windowStub = Object.assign(new EventTarget(), { void _delay; return 1; }, + localStorage: localStorageStub, + location: { href: "http://localhost/anime/42/watch?ep=3", origin: "http://localhost" }, + showToast: (options: { message: string }): void => { + void options; + }, }); let modules: PlayerModules; +let beaconCalls: string[]; let fetchCalls: Array<{ body?: string; url: string }>; let timeoutCallbacks: Array<() => void>; const originalConsoleError = console.error; @@ -145,14 +245,39 @@ const originalConsoleError = console.error; const installBrowserGlobals = (): void => { Object.assign(globalThis, { document: documentStub, + Element: FakeElement, + HTMLAnchorElement: FakeAnchorElement, HTMLElement: FakeElement, HTMLVideoElement: FakeElement, HTMLMediaElement: { HAVE_METADATA: 1, HAVE_CURRENT_DATA: 2 }, + MouseEvent: FakeMouseEvent, window: windowStub, }); + Object.assign(globalThis, { + history: { + pushState: (_data: unknown, _unused: string, url?: string | URL | null): void => { + const href = String(url); + historyCalls.push(href); + windowStub.location.href = href; + }, + replaceState: (_data: unknown, _unused: string, url?: string | URL | null): void => { + const href = String(url); + historyCalls.push(href); + windowStub.location.href = href; + }, + }, + sessionStorage: sessionStorageStub, + }); Object.defineProperty(globalThis, "navigator", { configurable: true, - value: { sendBeacon: () => true }, + value: { + sendBeacon: (_url: string, data: Blob): boolean => { + void data.text().then((value) => { + beaconCalls.push(value); + }); + return true; + }, + }, }); }; @@ -170,6 +295,7 @@ const resetPlayerState = (): void => { state.elements.timeDisplay = asHTMLElement(new FakeElement()); state.elements.durationDisplay = asHTMLElement(new FakeElement()); state.episode.current = "3"; + state.episode.total = 10; state.episode.malID = 42; state.episode.transitionEpisode = null; state.episode.completionSent = false; @@ -177,8 +303,13 @@ const resetPlayerState = (): void => { state.episode.endedProgressSaved = false; state.episode.lastSavedProgress = { episode: "3", seconds: -1 }; state.playback.currentMode = "sub"; + state.playback.modeSwitchedFrom = ""; + state.playback.startTimeSeconds = 0; state.playback.streamURL = "/watch/proxy/stream"; state.playback.modeSources = {}; + state.elements.episodeGrid = null; + state.elements.episodeList = null; + state.elements.videoOverlay = null; state.timers.progressSaveTimer = undefined; documentStub.setDropdownTrigger(null); }; @@ -186,21 +317,27 @@ const resetPlayerState = (): void => { const flushPromises = async (): Promise => { await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); }; const loadPlayerModules = async (): Promise => { const completeModule = await import("./episodes/complete"); + const navModule = await import("./episodes/nav"); const progressModule = await import("./progress"); const stateModule = await import("./state"); const sourceModule = await import("./source"); return { completeAnime: completeModule.completeAnime, + handleEpisodeNavigationClick: navModule.handleEpisodeNavigationClick, refreshCurrentModeSource: sourceModule.refreshCurrentModeSource, saveProgress: progressModule.saveProgress, setupProgress: progressModule.setupProgress, state: stateModule.state, streamUrlForMode: sourceModule.streamUrlForMode, + setupEpisodeNavigation: navModule.setupEpisodeNavigation, + transitionToEpisode: navModule.transitionToEpisode, }; }; @@ -212,6 +349,14 @@ before(async () => { beforeEach(() => { console.error = originalConsoleError; fetchCalls = []; + beaconCalls = []; + historyCalls.length = 0; + localStorageStub.clear(); + sessionStorageStub.clear(); + windowStub.location.href = "http://localhost/anime/42/watch?ep=3"; + windowStub.showToast = (options: { message: string }): void => { + void options; + }; timeoutCallbacks = []; globalThis.fetch = ((url: string, init?: RequestInit) => { fetchCalls.push({ url, body: typeof init?.body === "string" ? init.body : undefined }); @@ -225,6 +370,27 @@ beforeEach(() => { resetPlayerState(); }); +const episodePayload = ( + episode: number, + modes?: Record, +): Record => { + const modeSources = modes ?? { + dub: { subtitles: [], token: `dub-${episode}`, type: "mp4" }, + sub: { subtitles: [], token: `sub-${episode}`, type: "mp4" }, + }; + return { + episode_title: `Episode ${episode}`, + initial_mode: "sub", + mode_sources: modeSources, + mode_switched_from: "", + segments: [], + start_time_seconds: 12, + }; +}; + +const episodeResponse = (episode: number): Response => + ({ json: () => Promise.resolve(episodePayload(episode)), ok: true, status: 200 }) as Response; + describe("browser player flow", () => { test("builds initial stream URLs from mode token, HLS type, and preferred quality", () => { modules.state.playback.modeSources = { @@ -322,4 +488,205 @@ describe("browser player flow", () => { assert.equal(trigger.textContent, "Completed "); assert.equal(errors.length, 1); }); + + test("transitions episodes in place and saves progress for the episode being left", async () => { + const loading = new FakeElement(); + (modules.state.elements.container as unknown as FakeElement).setQuery( + "[data-loading]", + loading, + ); + modules.state.elements.video.currentTime = 30; + globalThis.fetch = ((url: string) => { + fetchCalls.push({ url }); + return Promise.resolve(episodeResponse(4)); + }) as typeof fetch; + + const transitioned = await modules.transitionToEpisode(4); + await flushPromises(); + + assert.equal(transitioned, true); + assert.deepEqual( + fetchCalls.map((call) => call.url), + ["/api/watch/episode/42/4?mode=sub"], + ); + assert.equal(modules.state.episode.current, "4"); + assert.equal(modules.state.playback.startTimeSeconds, 12); + assert.equal(modules.state.elements.video.src, "/watch/proxy/stream?mode=sub&token=sub-4"); + assert.equal(loading.style.display, "flex"); + assert.match(historyCalls[0] ?? "", /[?&]ep=4(?:&|$)/); + assert.deepEqual(JSON.parse(beaconCalls[0] ?? "{}"), { + mal_id: 42, + episode: 3, + time_seconds: 30, + }); + + modules.state.elements.video.dispatchEvent(new Event("loadedmetadata")); + assert.equal(modules.state.episode.transitionEpisode, null); + }); + + test("uses the same async path for episode links and leaves modified clicks native", async () => { + const modifiedLink = new FakeAnchorElement(); + modifiedLink.href = "http://localhost/anime/42/watch?ep=2"; + modifiedLink.dataset.episodeId = "2"; + const modifiedClick = new FakeMouseEvent(modifiedLink, { metaKey: true }); + + assert.equal( + modules.handleEpisodeNavigationClick(modifiedClick as unknown as MouseEvent), + false, + ); + assert.equal(modifiedClick.defaultPrevented, false); + assert.equal(fetchCalls.length, 0); + + globalThis.fetch = ((url: string) => { + fetchCalls.push({ url }); + const episode = Number.parseInt( + new URL(url, "http://localhost").pathname.split("/").at(-1) ?? "", + 10, + ); + return Promise.resolve(episodeResponse(episode)); + }) as typeof fetch; + const previousLink = new FakeAnchorElement(); + previousLink.href = "http://localhost/anime/42/watch?ep=2"; + previousLink.dataset.episodeId = "2"; + const previousClick = new FakeMouseEvent(previousLink); + + assert.equal( + modules.handleEpisodeNavigationClick(previousClick as unknown as MouseEvent), + true, + ); + assert.equal(previousClick.defaultPrevented, true); + await flushPromises(); + assert.equal(modules.state.episode.current, "2"); + modules.state.elements.video.dispatchEvent(new Event("loadedmetadata")); + + const nextLink = new FakeAnchorElement(); + nextLink.href = "http://localhost/anime/42/watch?ep=4"; + nextLink.dataset.episodeId = "4"; + assert.equal( + modules.handleEpisodeNavigationClick(new FakeMouseEvent(nextLink) as unknown as MouseEvent), + true, + ); + await flushPromises(); + assert.equal(modules.state.episode.current, "4"); + modules.state.elements.video.dispatchEvent(new Event("loadedmetadata")); + }); + + test("aborts a stale episode request and commits only the latest selection", async () => { + modules.state.episode.current = "1"; + windowStub.location.href = "http://localhost/anime/42/watch?ep=1"; + const pending = new Map< + number, + { reject: (reason: unknown) => void; resolve: (response: Response) => void } + >(); + globalThis.fetch = ((url: string, init?: RequestInit) => { + fetchCalls.push({ url }); + const episode = Number.parseInt( + new URL(url, "http://localhost").pathname.split("/").at(-1) ?? "", + 10, + ); + return new Promise((resolve, reject) => { + pending.set(episode, { reject, resolve }); + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("aborted", "AbortError")); + }); + }); + }) as typeof fetch; + + const episode2 = modules.transitionToEpisode(2); + const episode3 = modules.transitionToEpisode(3); + pending.get(3)?.resolve(episodeResponse(3)); + + assert.equal(await episode3, true); + assert.equal(await episode2, false); + assert.equal(modules.state.episode.current, "3"); + assert.equal(modules.state.elements.video.src, "/watch/proxy/stream?mode=sub&token=sub-3"); + assert.equal(historyCalls.length, 1); + assert.match(historyCalls[0] ?? "", /[?&]ep=3(?:&|$)/); + modules.state.elements.video.dispatchEvent(new Event("loadedmetadata")); + }); + + test("falls back to the original link when the episode payload fails", async () => { + console.error = (): void => { + void 0; + }; + globalThis.fetch = ((url: string) => { + fetchCalls.push({ url }); + return Promise.resolve({ ok: false, status: 503 } as Response); + }) as typeof fetch; + const fallbackHref = "http://localhost/anime/42/watch?ep=5"; + + assert.equal(await modules.transitionToEpisode(5, { fallbackHref }), false); + assert.equal(windowStub.location.href, fallbackHref); + assert.equal(modules.state.episode.current, "3"); + }); + + test("retries one failed media source before falling back to navigation", async () => { + console.error = (): void => { + void 0; + }; + let request = 0; + globalThis.fetch = ((url: string) => { + fetchCalls.push({ url }); + request += 1; + const payload = episodePayload(4); + payload.mode_sources = { + dub: { subtitles: [], token: `dub-${request}`, type: "mp4" }, + sub: { subtitles: [], token: `sub-${request}`, type: "mp4" }, + }; + return Promise.resolve({ + json: () => Promise.resolve(payload), + ok: true, + status: 200, + } as Response); + }) as typeof fetch; + const fallbackHref = "http://localhost/anime/42/watch?ep=4"; + + assert.equal(await modules.transitionToEpisode(4, { fallbackHref }), true); + assert.equal(modules.state.elements.video.src, "/watch/proxy/stream?mode=sub&token=sub-1"); + modules.state.elements.video.dispatchEvent(new Event("error")); + await flushPromises(); + assert.equal(modules.state.elements.video.src, "/watch/proxy/stream?mode=sub&token=sub-2"); + + modules.state.elements.video.dispatchEvent(new Event("error")); + assert.equal(windowStub.location.href, fallbackHref); + }); + + test("uses the returned fallback mode and restores episodes on popstate", async () => { + const notices: string[] = []; + windowStub.showToast = ({ message }: { message: string }): void => { + notices.push(message); + }; + modules.state.playback.currentMode = "dub"; + globalThis.fetch = ((url: string) => { + fetchCalls.push({ url }); + const episode = Number.parseInt( + new URL(url, "http://localhost").pathname.split("/").at(-1) ?? "", + 10, + ); + const payload = episodePayload(episode, { + sub: { subtitles: [], token: `sub-${episode}`, type: "mp4" }, + }); + payload.mode_switched_from = "dub"; + return Promise.resolve({ + json: () => Promise.resolve(payload), + ok: true, + status: 200, + } as Response); + }) as typeof fetch; + + assert.equal(await modules.transitionToEpisode(4), true); + assert.equal(modules.state.playback.currentMode, "sub"); + assert.deepEqual(notices, ["Episode 4 is only available in sub, switched from dub."]); + modules.state.elements.video.dispatchEvent(new Event("loadedmetadata")); + + const controller = new AbortController(); + modules.setupEpisodeNavigation(controller.signal); + windowStub.location.href = "http://localhost/anime/42/watch?ep=2"; + windowStub.dispatchEvent(new Event("popstate")); + await flushPromises(); + assert.equal(modules.state.episode.current, "2"); + assert.equal(historyCalls.length, 1); + modules.state.elements.video.dispatchEvent(new Event("loadedmetadata")); + controller.abort(); + }); });