Measured in Chrome before each change: - A dialog opened from a menu lost its focus: the menu row ran its action and closed in one batch, and floating-ui's focus return (a microtask) moved the focus to the row BEHIND the confirm dialog's scrim 7 ms after it opened, where Enter activated the row. MenuItemList now closes first and runs the action a task later; the dialog also captures the row, not a dying menu item, as its restore target. - A side panel opened as a flyout collapsed the moment a dialog opened from inside it, unmounting the row the dialog hands the focus back to. useHoverFocusWithin holds while the focus is inside an aria-modal dialog. - ui/Button's loading state set native disabled; Chrome moved the focus of the pressed button to <body> and never gave it back. Loading is now aria-disabled with a click guard that also cancels implicit submission. - Escape/Enter in the playlist rail's rename field dropped the focus on <body>. The row refocuses from its ref callback when it re-attaches (a post-commit focus sticks; the earlier attempt ran before the button existed). - PlayerModal was the one aria-modal shell without the dialog contract: it is now named by its title, traps Tab, restores the focus to its opener and shares one counted body scroll lock with every dialog.
353 lines
14 KiB
TypeScript
353 lines
14 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The YouTube player modal, rendered for real. Its pure helpers (afterRemoval, the progress cache)
|
|
// had tests; what the modal DOES with them had none — a deep-review of the 2026-09 bug hunt found
|
|
// that deleting the lines that apply them left every lane green.
|
|
//
|
|
// `window.YT` is a fake installed before the module's API loader first runs (that loader memoises
|
|
// its promise in a module-level singleton, so this file must not share a module instance with a
|
|
// test that leaves `window.YT` unset). Each item the modal plays constructs a new YT.Player, and the
|
|
// fake records what it was asked for — that is how these tests see where playback starts.
|
|
import { describe, it, expect, vi, afterEach, beforeAll, beforeEach } from "vitest";
|
|
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { ReactNode } from "react";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({
|
|
t: (key: string, o?: Record<string, unknown>) => (o?.title ? `${key}:${o.title}` : key),
|
|
i18n: { language: "en" },
|
|
}),
|
|
};
|
|
});
|
|
vi.mock("./NavigationProvider", () => ({ useNavigationActions: () => ({ openChannel: vi.fn() }) }));
|
|
// Their own queries and popovers are not what is under test here.
|
|
vi.mock("./AddToPlaylist", () => ({ default: () => null }));
|
|
vi.mock("./DownloadButton", () => ({ default: () => null }));
|
|
|
|
import PlayerModal from "./PlayerModal";
|
|
import { ConfirmProvider, useConfirm } from "./ConfirmProvider";
|
|
import { api, type Video } from "../lib/api";
|
|
import { clearAll, notify } from "../lib/notifications";
|
|
|
|
beforeAll(() => {
|
|
class Stub {
|
|
observe() {}
|
|
unobserve() {}
|
|
disconnect() {}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
}
|
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
|
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
|
});
|
|
|
|
// --- the fake IFrame API ------------------------------------------------------------------------
|
|
const clock = { current: 0, duration: 3000 };
|
|
const started: { videoId: string; start: number | undefined }[] = [];
|
|
const base = {
|
|
getCurrentTime: () => clock.current,
|
|
getDuration: () => clock.duration,
|
|
getVideoData: () => null,
|
|
};
|
|
// Anything else the modal calls on the player (volume, rate, destroy…) is a no-op. Not `then`:
|
|
// a thenable would be swallowed by the first `await` that touched it.
|
|
const fakePlayer = new Proxy(base, {
|
|
get: (t, k) => (k in t ? t[k as keyof typeof t] : k === "then" ? undefined : () => undefined),
|
|
});
|
|
window.YT = {
|
|
Player: function (_el: unknown, opts: { videoId: string; playerVars: { start?: number } }) {
|
|
started.push({ videoId: opts.videoId, start: opts.playerVars.start });
|
|
return fakePlayer;
|
|
} as never,
|
|
};
|
|
|
|
const vid = (id: string, position_seconds: number): Video =>
|
|
({
|
|
id,
|
|
title: `Video ${id}`,
|
|
channel_id: "UCx",
|
|
channel_title: "Channel",
|
|
status: "new",
|
|
position_seconds,
|
|
duration_seconds: 3000,
|
|
watch_url: `https://www.youtube.com/watch?v=${id}`,
|
|
}) as unknown as Video;
|
|
|
|
function renderModal(ui: ReactNode) {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
|
}
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
started.length = 0;
|
|
});
|
|
|
|
const lastStart = () => started[started.length - 1];
|
|
|
|
describe("PlayerModal — where each item starts", () => {
|
|
it("resumes an item played earlier in the SAME session where this session left it", async () => {
|
|
// The launch queue is frozen at mount, so its rows keep the position they were snapshotted
|
|
// with (748). Stepping next→prev back to an item already watched to 830 used to start it at
|
|
// 748 — and the next checkpoint then wrote 748 over the 830 the server held.
|
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
|
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
|
|
video_id: id,
|
|
position_seconds: id === "A" ? 830 : 0,
|
|
}));
|
|
const A = vid("A", 748);
|
|
const B = vid("B", 0);
|
|
clock.current = 830;
|
|
renderModal(
|
|
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
|
);
|
|
await waitFor(() => expect(lastStart()).toEqual({ videoId: "A", start: 748 }));
|
|
fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
|
|
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
|
fireEvent.click(screen.getAllByRole("button", { name: "player.previous" })[0]!);
|
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
|
expect(lastStart()?.start).toBe(830);
|
|
});
|
|
});
|
|
|
|
// --- "remove from this playlist", from inside the player ----------------------------------------
|
|
const trash = (title: string) =>
|
|
screen.getByRole("button", { name: `playlists.removeItemOf:Video ${title}` });
|
|
const next = () => fireEvent.click(screen.getAllByRole("button", { name: "player.next" })[0]!);
|
|
const prev = () => fireEvent.click(screen.getAllByRole("button", { name: "player.previous" })[0]!);
|
|
const settle = () => act(async () => {});
|
|
|
|
describe("PlayerModal — removing the playing video from its playlist", () => {
|
|
const [A, B, C, D] = ["A", "B", "C", "D"].map((id) => vid(id, 0));
|
|
beforeEach(() => {
|
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
|
vi.spyOn(api, "saveProgress").mockImplementation(async (id: string) => ({
|
|
video_id: id,
|
|
position_seconds: 0,
|
|
}));
|
|
clock.current = 0;
|
|
});
|
|
|
|
it("steps to the item that followed, and the removed one is gone from the queue", async () => {
|
|
renderModal(
|
|
<PlayerModal
|
|
video={B!}
|
|
queue={[A!, B!, C!]}
|
|
startIndex={1}
|
|
onClose={() => {}}
|
|
onState={() => {}}
|
|
onRemoveFromList={async () => true}
|
|
/>,
|
|
);
|
|
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
|
fireEvent.click(trash("B"));
|
|
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
|
|
prev(); // …straight back to A: B is no longer there to step onto
|
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
|
});
|
|
|
|
it("closes the player when the last item goes", async () => {
|
|
const onClose = vi.fn();
|
|
renderModal(
|
|
<PlayerModal
|
|
video={A!}
|
|
queue={[A!]}
|
|
startIndex={0}
|
|
onClose={onClose}
|
|
onState={() => {}}
|
|
onRemoveFromList={async () => true}
|
|
/>,
|
|
);
|
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
|
fireEvent.click(trash("A"));
|
|
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
|
});
|
|
|
|
it("leaves playback and the queue alone when the removal did not happen", async () => {
|
|
const onClose = vi.fn();
|
|
renderModal(
|
|
<PlayerModal
|
|
video={A!}
|
|
queue={[A!, B!]}
|
|
startIndex={0}
|
|
onClose={onClose}
|
|
onState={() => {}}
|
|
onRemoveFromList={async () => false}
|
|
/>,
|
|
);
|
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
|
fireEvent.click(trash("A"));
|
|
await settle();
|
|
expect(onClose).not.toHaveBeenCalled();
|
|
expect(started.map((s) => s.videoId)).toEqual(["A"]);
|
|
next();
|
|
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
|
prev(); // A is still in the queue
|
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
|
});
|
|
|
|
it("keeps playback where the viewer went while the confirm was open", async () => {
|
|
// prev/next keep working under the confirm dialog; deciding from the cursor as it was when the
|
|
// button was pressed threw the viewer back onto a video they had already moved past.
|
|
let answer!: (ok: boolean) => void;
|
|
renderModal(
|
|
<PlayerModal
|
|
video={A!}
|
|
queue={[A!, B!, C!, D!]}
|
|
startIndex={0}
|
|
onClose={() => {}}
|
|
onState={() => {}}
|
|
onRemoveFromList={() => new Promise<boolean>((r) => (answer = r))}
|
|
/>,
|
|
);
|
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
|
fireEvent.click(trash("A"));
|
|
next();
|
|
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
|
next();
|
|
await waitFor(() => expect(lastStart()?.videoId).toBe("C"));
|
|
await act(async () => answer(true));
|
|
expect(lastStart()?.videoId).toBe("C");
|
|
prev();
|
|
await waitFor(() => expect(lastStart()?.videoId).toBe("B"));
|
|
// …and A is gone from the queue, not just skipped over: B is its head now, so stepping back
|
|
// again goes nowhere. (One step back lands on B either way — it took the second to tell.)
|
|
prev();
|
|
await settle();
|
|
expect(lastStart()?.videoId).toBe("B");
|
|
expect(started.filter((s) => s.videoId === "A")).toHaveLength(1); // never played again
|
|
});
|
|
|
|
it("gives focus back to the Remove button when the confirm is cancelled", async () => {
|
|
// The button used to be disabled for the confirm's whole lifetime, so the dialog's focus
|
|
// restore landed on a disabled element — a no-op — and a keyboard user was left on <body>,
|
|
// outside the aria-modal player.
|
|
function WithConfirm() {
|
|
const confirm = useConfirm();
|
|
return (
|
|
<PlayerModal
|
|
video={A!}
|
|
queue={[A!, B!]}
|
|
startIndex={0}
|
|
onClose={() => {}}
|
|
onState={() => {}}
|
|
onRemoveFromList={(v) =>
|
|
confirm({ title: "t", message: v.title, confirmLabel: "remove", danger: true })
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
renderModal(
|
|
<ConfirmProvider>
|
|
<WithConfirm />
|
|
</ConfirmProvider>,
|
|
);
|
|
await waitFor(() => expect(lastStart()?.videoId).toBe("A"));
|
|
trash("A").focus();
|
|
fireEvent.click(trash("A"));
|
|
fireEvent.click(await screen.findByRole("button", { name: "common.cancel" }));
|
|
await waitFor(() => expect(document.activeElement).toBe(trash("A")));
|
|
});
|
|
});
|
|
|
|
describe("PlayerModal — toasts raised while it is open", () => {
|
|
afterEach(() => act(() => clearAll()));
|
|
|
|
it("shows them inside the player, not under its scrim", async () => {
|
|
// The app's Toaster is in a stacking context below this body-level portal: a failed Remove
|
|
// reported by a toast was drawn under the 80% black scrim, i.e. still silent.
|
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
|
const A = vid("A", 0);
|
|
renderModal(
|
|
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
|
);
|
|
act(() => void notify({ level: "error", message: "Couldn't remove the video." }));
|
|
expect(screen.getByTestId("player-modal").textContent).toContain("Couldn't remove the video.");
|
|
});
|
|
|
|
it("shows them inside the stage in true fullscreen — the element holding the lock", async () => {
|
|
// Anything outside the fullscreen element is painted under it. The player's host sat beside the
|
|
// card, so in fullscreen a lost connection was reported to nobody.
|
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
|
const A = vid("A", 0);
|
|
renderModal(
|
|
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
|
);
|
|
const stage = document.querySelector(".player-stage")!;
|
|
Object.defineProperty(document, "fullscreenElement", { configurable: true, get: () => stage });
|
|
try {
|
|
act(() => void document.dispatchEvent(new Event("fullscreenchange")));
|
|
act(() => void notify({ level: "error", message: "Connection lost" }));
|
|
await waitFor(() => expect(stage.textContent).toContain("Connection lost"));
|
|
} finally {
|
|
delete (document as { fullscreenElement?: unknown }).fullscreenElement;
|
|
}
|
|
});
|
|
});
|
|
|
|
// --- the dialog contract it now shares (ui/dialogFocus) -----------------------------------------
|
|
// Until E11 this was the one `aria-modal` shell with no accessible name, no Tab trap and no focus
|
|
// restore: Tab walked out of an open player into the page behind the scrim, and closing it with
|
|
// Escape left a keyboard user on <body>, their place in a long feed lost (deep-review round 7).
|
|
describe("PlayerModal — the dialog contract", () => {
|
|
const open = (onClose = () => {}) => {
|
|
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
|
|
const A = vid("A", 0);
|
|
return renderModal(
|
|
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
|
|
);
|
|
};
|
|
|
|
it("is named by the video's title", () => {
|
|
open();
|
|
expect(screen.getByRole("dialog", { name: "Video A" })).toBeTruthy();
|
|
});
|
|
|
|
it("opens focused on its card, not on a control, so its shortcuts keep working", () => {
|
|
open();
|
|
const active = document.activeElement as HTMLElement;
|
|
expect(active.tagName).not.toBe("BUTTON");
|
|
expect(screen.getByTestId("player-modal").contains(active)).toBe(true);
|
|
});
|
|
|
|
it("gives the focus back to what opened it when it closes", () => {
|
|
const opener = document.createElement("button");
|
|
document.body.appendChild(opener);
|
|
opener.focus();
|
|
try {
|
|
const view = open();
|
|
expect(document.activeElement).not.toBe(opener);
|
|
view.unmount();
|
|
expect(document.activeElement).toBe(opener);
|
|
} finally {
|
|
opener.remove();
|
|
}
|
|
});
|
|
|
|
it("keeps Tab inside the player", () => {
|
|
open();
|
|
const dialog = screen.getByTestId("player-modal");
|
|
const tabbables = [...dialog.querySelectorAll<HTMLElement>("button:not([disabled]), a[href]")];
|
|
const last = tabbables[tabbables.length - 1]!;
|
|
last.focus();
|
|
// jsdom has no layout, so the trap's visibility filter would see nothing tabbable; give every
|
|
// element an offsetParent for the duration of this check.
|
|
const spy = vi
|
|
.spyOn(HTMLElement.prototype, "offsetParent", "get")
|
|
.mockReturnValue(document.body);
|
|
try {
|
|
fireEvent.keyDown(window, { key: "Tab" });
|
|
expect(dialog.contains(document.activeElement)).toBe(true);
|
|
expect(document.activeElement).not.toBe(last);
|
|
} finally {
|
|
spy.mockRestore();
|
|
}
|
|
});
|
|
});
|