// @vitest-environment jsdom // // The Plex player paints an opaque, full-viewport layer over the whole app — and for a long // time it was the only overlay in the tree with no Tab trap at all. Tab from its transport bar, // from its menus, or from the debug recorder's pill (which docks in THIS player's toast host) walked // straight out into the page behind it, where the focus ring is invisible and the next Enter fires // something the user cannot see. `ui/Modal`, `PlayerModal`, `OnboardingWizard` and `Welcome` all trap; // an earlier comment that handed the step prompt's trap to "the dialog below" assumed this one did // too. Its own file because `layerStack` is module state and these tests push into it. import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; import { render, cleanup, waitFor, act, screen } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => ({ ...(await importOriginal()), useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), })); import PlexPlayer from "./PlexPlayer"; import { api, type PlexItemDetail } from "../lib/api"; import { pushLayer, removeLayer } from "../lib/layerStack"; beforeAll(() => { // A browser that can fullscreen an element, as every desktop one can: the row's last control is // the fullscreen button there (jsdom has no Fullscreen API, so the button would be left out). Object.defineProperty(Element.prototype, "requestFullscreen", { configurable: true, value: () => Promise.resolve(), }); class Stub { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; for (const [name, value] of Object.entries({ play: () => Promise.resolve(), pause: () => {}, load: () => {}, })) Object.defineProperty(HTMLMediaElement.prototype, name, { configurable: true, value }); Object.defineProperty(HTMLMediaElement.prototype, "seekable", { configurable: true, get: () => ({ length: 0, end: () => 0 }), }); Object.defineProperty(HTMLMediaElement.prototype, "textTracks", { configurable: true, get: () => Object.assign([], { addEventListener() {}, removeEventListener() {} }), }); }); const behind: HTMLButtonElement[] = []; afterEach(() => { cleanup(); while (behind.length) behind.pop()!.remove(); vi.restoreAllMocks(); }); const detail = { id: "1", title: "Title 1", type: "movie", duration_seconds: 3000, status: "new", position_seconds: 0, audio_streams: [], subtitle_streams: [], markers: [], } as unknown as PlexItemDetail; async function mount() { vi.spyOn(api, "plexItem").mockResolvedValue(detail); vi.spyOn(api, "plexSession").mockResolvedValue({ mode: "direct", url: "/media/1", start_s: 0, } as Awaited>); vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never); render( {}} /> , ); await waitFor(() => expect(document.querySelector("video")?.getAttribute("src")).toBe("/media/1"), ); } /** A control on the page the player is painted over — the thing focus used to escape onto. */ function pageBehind() { const b = document.createElement("button"); b.textContent = "on the page behind"; document.body.insertBefore(b, document.body.firstChild); behind.push(b); return b; } const player = () => document.querySelector(".fixed.z-overlay")!; /** jsdom has no layout, so the trap's visibility filter (`offsetParent`) would see nothing tabbable * anywhere. Give every element one for the duration of a check — the house pattern (PlayerModal, * OnboardingWizard, ui/dialogFocus all do this). */ function withLayout(fn: () => T): T { const spy = vi.spyOn(HTMLElement.prototype, "offsetParent", "get").mockReturnValue(document.body); try { return fn(); } finally { spy.mockRestore(); } } const press = (key: string, shiftKey = false) => act(() => { window.dispatchEvent( new KeyboardEvent("keydown", { key, shiftKey, bubbles: true, cancelable: true }), ); }); // A menu's rows are named by their text: the surface is `visibility: hidden` until floating-ui has // measured it (never, under jsdom), and a hidden element's accessible name is empty. const menuItem = (label: string) => waitFor(() => { const row = screen .queryAllByRole("menuitem", { hidden: true }) .find((b) => b.textContent === label); expect(row).toBeDefined(); return row!; }); describe("PlexPlayer · Tab stays inside the player", () => { it("pulls the focus back in when it is on the page behind", async () => { await mount(); const outside = pageBehind(); outside.focus(); expect(document.activeElement).toBe(outside); withLayout(() => press("Tab")); // Not left on a control under an opaque overlay: the trap takes it into the player. expect(document.activeElement).not.toBe(outside); expect(player().contains(document.activeElement)).toBe(true); }); it("wraps from the last of its own controls back to the first", async () => { await mount(); const items = [ ...player().querySelectorAll( 'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])', ), ]; expect(items.length).toBeGreaterThan(1); const first = items[0]!; const last = items[items.length - 1]!; last.focus(); withLayout(() => press("Tab")); expect(document.activeElement).toBe(first); // ...and backwards, which is the direction that used to walk straight out behind the player. first.focus(); withLayout(() => press("Tab", true)); expect(document.activeElement).toBe(last); }); it("keeps trapping while one of its OWN menus is open", async () => { await mount(); // The settings and track menus are `useDismiss` layers. That hook owns Escape and the // outside-press and installs no Tab trap at all — but it used to register as a layer that // DOES trap, so the player stood its own trap down for it and Tab walked out past the last // control into the nav rail. Measured in a real browser: 30 Tabs, 9 escapes. The menus render // inside the player, so the player's trap is the one that must stay on. const buttons = () => [...player().querySelectorAll("button:not([disabled])")]; const before = buttons().length; // The tuning panel opens from the ⋯ (C5): ⋯, then its Settings row. const more = buttons().find((b) => b.getAttribute("aria-label") === "plex.player.more"); expect(more).toBeDefined(); act(() => more!.click()); // The ⋯ is a menu on the popover engine: it portals out of the player and traps for itself. const settings = await menuItem("plex.player.settings"); act(() => settings.click()); await screen.findByRole("dialog", { name: "plex.player.settings" }); const items = buttons(); // The panel really opened — the trap now has to span its controls too. expect(items.length).toBeGreaterThan(before + 3); const last = items[items.length - 1]!; last.focus(); withLayout(() => press("Tab")); expect(player().contains(document.activeElement)).toBe(true); expect(document.activeElement).toBe(items[0]); }); it("leaves the Tab to a dialog opened above it, which traps for itself", async () => { await mount(); const outside = pageBehind(); outside.focus(); const above = pushLayer(); // a Modal over the player — the download dialog, say try { withLayout(() => press("Tab")); // The player does not reach past it: the dialog above owns the keyboard and its own trap. expect(document.activeElement).toBe(outside); } finally { removeLayer(above); } }); });