Brave on iPadOS switches element fullscreen off, patches requestFullscreen with a video-only stand-in and fakes fullscreenEnabled, so the Plex fullscreen button and the YouTube docked bar's did nothing. Support is now read off Element.prototype and the buttons are left out without it (owner). A stand-in that returns a boolean no longer throws. A test's bare matchMedia stub no longer outlives its describe.
208 lines
8.0 KiB
TypeScript
208 lines
8.0 KiB
TypeScript
// @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<typeof import("react-i18next")>()),
|
|
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<ReturnType<typeof api.plexSession>>);
|
|
vi.spyOn(api, "plexProgress").mockResolvedValue(undefined as never);
|
|
render(
|
|
<QueryClientProvider
|
|
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
|
>
|
|
<PlexPlayer itemId="1" onClose={() => {}} />
|
|
</QueryClientProvider>,
|
|
);
|
|
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<HTMLElement>(".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<T>(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<HTMLElement>(
|
|
'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<HTMLElement>("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);
|
|
}
|
|
});
|
|
});
|