Files
siftlode/frontend/src/components/OnboardingWizard.dialog.dom.test.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

146 lines
6.6 KiB
TypeScript

// @vitest-environment jsdom
//
// The wizard is a modal overlay in every way that matters to a user, but it renders its own shell
// instead of going through `ui/Modal` — and for a keyboard or screen-reader user it had nothing of
// the dialog contract: no role, no label, no initial focus, no trap, no restore. Tab past its last
// control walked into the app behind the scrim, where the focus ring is invisible.
//
// `focusablesIn` filters on `el.offsetParent !== null` and jsdom reports null for everything, so the
// trap is untestable as written without the same stub `ui/Modal.dom.test.tsx` uses.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup, screen, fireEvent, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
};
});
vi.mock("../lib/onboarding", () => ({ beginGrant: vi.fn(), endOnboarding: vi.fn() }));
import OnboardingWizard from "./OnboardingWizard";
import { api, type Me } from "../lib/api";
import { clearAll, notify } from "../lib/notifications";
const me = { id: 1, email: "a@b", can_read: false, can_write: false } as unknown as Me;
let origDesc: PropertyDescriptor | undefined;
beforeEach(() => {
vi.spyOn(api, "myStatus").mockResolvedValue({ channels_total: 0 } as never);
origDesc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetParent");
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
configurable: true,
get(this: HTMLElement) {
return this.isConnected ? document.body : null;
},
});
});
afterEach(() => {
cleanup();
if (origDesc) Object.defineProperty(HTMLElement.prototype, "offsetParent", origDesc);
else delete (HTMLElement.prototype as unknown as Record<string, unknown>).offsetParent;
vi.restoreAllMocks();
});
const mount = () =>
render(
<QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
>
<OnboardingWizard me={me} onClose={() => {}} />
</QueryClientProvider>,
);
describe("the onboarding wizard as a dialog", () => {
it("announces itself as a labelled modal dialog", () => {
mount();
const panel = screen.getByRole("dialog");
expect(panel.getAttribute("aria-modal")).toBe("true");
// The label is the step's own heading — the wizard has no header row to take a title from.
const heading = document.getElementById(panel.getAttribute("aria-labelledby")!);
expect(heading?.textContent).toBe("onboarding.read.title");
});
it("opens focused inside itself and keeps Tab from leaving", () => {
const outside = document.createElement("button");
outside.textContent = "behind the scrim";
document.body.appendChild(outside);
outside.focus();
const { unmount } = mount();
const panel = screen.getByRole("dialog");
expect(panel.contains(document.activeElement)).toBe(true);
// From the last control, Tab wraps back to the first instead of reaching the page behind.
const stops = [...panel.querySelectorAll<HTMLElement>("button, a[href]")];
stops[stops.length - 1]!.focus();
fireEvent.keyDown(window, { key: "Tab" });
expect(document.activeElement).toBe(stops[0]);
// …and closing hands the focus back to whatever opened it.
unmount();
expect(document.activeElement).toBe(outside);
outside.remove();
});
it("holds a Shift+Tab that starts on the panel itself", () => {
// `Node.contains(self)` is true, so focus sitting ON the tabIndex=-1 panel — where a click on
// its dead space puts it — matched no branch of `trapTab` and the browser's native Shift+Tab
// ran, into the app behind the scrim. That is what the third argument exists for, and this
// caller was written without it on the very commit that extracted the helper.
mount();
const panel = screen.getByRole("dialog");
panel.focus();
expect(document.activeElement).toBe(panel);
const stops = [...panel.querySelectorAll<HTMLElement>("button, a[href]")];
fireEvent.keyDown(window, { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(stops[stops.length - 1]);
});
it("still hands the focus back after it visited the wizard's own toast", () => {
// Ownership has to be measured over the same scope the Tab trap uses. The wizard's toast host
// sits beside the panel and INSIDE the dialog, so tabbing to a toast's ✕ is legal — but read
// against the panel alone it said "the user left", and since removing the toast fires no
// focusin to re-arm, closing afterwards stranded the focus on <body>. Unlike
// `ui/Modal`, whose root carries the aria-modal attribute, nothing else can rescue this one.
const outside = document.createElement("button");
document.body.appendChild(outside);
outside.focus();
const { unmount } = mount();
act(() => void notify({ level: "error", message: "offline" }));
const dismissToast = screen.getByTitle("notifications.dismiss");
dismissToast.focus();
expect(document.activeElement).toBe(dismissToast);
act(() => clearAll()); // the toast goes away under the focus — no focusin follows
unmount();
expect(document.activeElement).toBe(outside);
outside.remove();
});
it("keeps its own toast's dismiss button inside the Tab cycle", () => {
// The wizard renders its own `<Toaster overlay>` beside the panel, and the connection notice it
// can raise never auto-dismisses — so a trap scoped to the panel alone left that ✕ permanently
// unreachable from the keyboard. `ui/Modal` spans the whole dialog for exactly this reason.
mount();
const panel = screen.getByRole("dialog");
act(() => void notify({ level: "error", message: "offline" }));
try {
const dismissToast = screen.getByTitle("notifications.dismiss");
expect(panel.contains(dismissToast)).toBe(false);
const stops = [...panel.querySelectorAll<HTMLElement>("button, a[href]")];
stops[stops.length - 1]!.focus();
fireEvent.keyDown(window, { key: "Tab" });
// No longer the edge: Tab carries on (natively) towards the toast instead of wrapping.
expect(document.activeElement).toBe(stops[stops.length - 1]);
dismissToast.focus();
fireEvent.keyDown(window, { key: "Tab" });
expect(document.activeElement).toBe(stops[0]);
} finally {
act(() => clearAll());
}
});
});