Round 6 widened dialog focus ownership to count focus inside ANY `aria-modal` subtree as ours, so a dialog and the dialog stacked on it closing together would still hand the focus back. That clause has no way to tell a layer closing WITH us from one that stays open, so a dialog closing on its own — an in-flight mutation's `onSuccess`, not a click, since a scrim covers it — under a modal raised above it pulled the focus onto its own pre-open trigger, behind that scrim, where Enter still activated it. Three verifiers confirmed the mechanism and two corrected the severity down: it self-heals on the next Tab, which is why this is a containment violation rather than a broken end state. `lib/layerStack` cannot refine it. Measured against the real hook: at the instant the lower dialog's focus cleanup runs, the both-closing case and the sibling-stays-open case give identical readings — `anyLayerAbove` true, the upper layer topmost — because passive cleanups run parent-first and `useDialogChrome` is declared before the effect that calls `removeLayer`. Gating on the stack turns round 6's own test red. What separates them is the DOM: React detaches a deleted subtree before passive cleanups, so a layer closing with us is already disconnected while one that stays open is not. Both tests are green, and each was mutation-checked against the other. Verified in Chrome on localdev: opening the cheat sheet from a sidebar button and closing it restores that button; with the command palette stacked over the cheat sheet, closing the top one leaves the focus neither on the background trigger nor on <body>, and closing both returns it to the trigger. Welcome's Lightbox got the trap and the restore in round 6 but not the layer membership the other shells have, so its Escape and Tab fired from a raw window listener whatever was on top — the shape the wizard's comment records being bitten by, unreachable today only by a mount ordering nothing enforces. It joins the stack. Its comment also read as though the four-shell sweep were finished: `PlayerModal` is still an `aria-modal` surface with no name, no trap and no restore, which is on ROADMAP.md now rather than implied to be done.
207 lines
9.3 KiB
TypeScript
207 lines
9.3 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// The sign-in card's sub-views as the user meets them: clicking a link must leave a history entry,
|
|
// so Back — including the mouse's back button — returns to the previous view. They used to be plain
|
|
// React state, and Back moved only the address bar while the form on screen stayed put. The
|
|
// encode/decode table lives in welcomeMode.dom.test.ts; this file renders the real card, so a revert
|
|
// to useState fails here.
|
|
//
|
|
// i18n is mocked to a key-passthrough so queries read stable keys, and the API module is stubbed —
|
|
// the card probes /auth/config on mount and must not reach the network.
|
|
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
|
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
|
|
|
vi.mock("react-i18next", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
|
return {
|
|
...actual,
|
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
|
};
|
|
});
|
|
|
|
vi.mock("../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../lib/api")>();
|
|
return {
|
|
...actual,
|
|
api: { ...actual.api, authConfig: () => Promise.resolve({ google_enabled: false }) },
|
|
};
|
|
});
|
|
|
|
import Welcome from "./Welcome";
|
|
import { pushLayer, removeLayer } from "../lib/layerStack";
|
|
import type { VerifyEntry } from "../lib/useVerifyEntry";
|
|
|
|
// App confirms the emailed token and hands the outcome down; these tests are about the card's
|
|
// sub-views, so there is nothing to report.
|
|
const noVerify: VerifyEntry = {
|
|
outcome: null,
|
|
email: null,
|
|
resetPending: false,
|
|
dismiss: () => {},
|
|
};
|
|
|
|
const back = () =>
|
|
act(() => {
|
|
// jsdom's history.back() is async and does not rewind location, so land on the previous entry
|
|
// the way the browser would and fire popstate. The push itself is asserted via history.length.
|
|
window.history.replaceState(null, "", "/");
|
|
window.dispatchEvent(new PopStateEvent("popstate", { state: null }));
|
|
});
|
|
|
|
beforeEach(() => window.history.replaceState(null, "", "/"));
|
|
afterEach(() => {
|
|
cleanup();
|
|
window.history.replaceState(null, "", "/");
|
|
});
|
|
|
|
describe("sign-in card sub-views and the Back button", () => {
|
|
it("opens Forgot password as a history entry and comes back to sign in", async () => {
|
|
render(<Welcome verify={noVerify} />);
|
|
expect(await screen.findByText("welcome.auth.signinTitle")).toBeTruthy();
|
|
const entries = window.history.length;
|
|
|
|
fireEvent.click(screen.getByText("welcome.auth.forgotLink"));
|
|
|
|
expect(screen.getByText("welcome.auth.forgotTitle")).toBeTruthy();
|
|
expect(window.history.length).toBe(entries + 1); // a Back now has somewhere to go
|
|
expect(new URLSearchParams(window.location.search).get("m")).toBe("forgot");
|
|
|
|
back();
|
|
|
|
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
|
|
expect(screen.queryByText("welcome.auth.forgotTitle")).toBeNull();
|
|
});
|
|
|
|
it("does the same for Create an account and the verification-email link", async () => {
|
|
render(<Welcome verify={noVerify} />);
|
|
await screen.findByText("welcome.auth.signinTitle");
|
|
|
|
fireEvent.click(screen.getByText("welcome.auth.createLink"));
|
|
expect(screen.getByText("welcome.auth.createTitle")).toBeTruthy();
|
|
back();
|
|
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
|
|
|
|
fireEvent.click(screen.getByText("welcome.auth.resendLink"));
|
|
expect(screen.getByText("welcome.auth.resendTitle")).toBeTruthy();
|
|
back();
|
|
expect(screen.getByText("welcome.auth.signinTitle")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// The signed-OUT half of the verification outcome: App confirms the emailed token above its
|
|
// signed-in branch and hands the result down, so the card no longer posts anything itself. These pin
|
|
// that each outcome still reaches the right banner — and that the banner is announced (role=status),
|
|
// which the 2026-09-02 review asked for and which is the whole point when the alternative was a
|
|
// screen that said nothing at all.
|
|
describe("the verification outcome handed down from App", () => {
|
|
const withOutcome = (outcome: VerifyEntry["outcome"]): VerifyEntry => ({
|
|
outcome,
|
|
email: "someone@example.com",
|
|
resetPending: false,
|
|
dismiss: () => {},
|
|
});
|
|
|
|
it("announces a confirmed address", async () => {
|
|
render(<Welcome verify={withOutcome("ok")} />);
|
|
const banner = await screen.findByText("welcome.auth.verifyOk");
|
|
expect(banner.closest('[role="status"]')).toBeTruthy();
|
|
});
|
|
|
|
it("offers the reset flow when the account's password had been revoked", async () => {
|
|
render(<Welcome verify={withOutcome("ok_reset")} />);
|
|
expect(await screen.findByText("welcome.auth.verifyOkReset")).toBeTruthy();
|
|
expect(screen.getByText("welcome.auth.verifyOkResetCta")).toBeTruthy();
|
|
});
|
|
|
|
it("offers a new link when the token is spent", async () => {
|
|
render(<Welcome verify={withOutcome("invalid")} />);
|
|
expect(await screen.findByText("welcome.auth.verifyInvalid")).toBeTruthy();
|
|
expect(screen.getByText("welcome.auth.resendCta")).toBeTruthy();
|
|
});
|
|
|
|
// DX-16: a transport failure is not a verdict on the token, so it must NOT offer a fresh link —
|
|
// that would act on a statement we know to be false. Retrying the same link is the way out.
|
|
it("does not offer a new link when the server was merely unreachable", async () => {
|
|
render(<Welcome verify={withOutcome("unreachable")} />);
|
|
expect(await screen.findByText("welcome.auth.verifyUnreachable")).toBeTruthy();
|
|
expect(screen.queryByText("welcome.auth.resendCta")).toBeNull();
|
|
expect(screen.queryByText("welcome.auth.verifyInvalid")).toBeNull();
|
|
});
|
|
|
|
it("shows no banner when there was no token to confirm", async () => {
|
|
render(<Welcome verify={noVerify} />);
|
|
await screen.findByText("welcome.auth.signinTitle");
|
|
expect(screen.queryByText("welcome.auth.verifyOk")).toBeNull();
|
|
expect(screen.queryByText("welcome.auth.verifyInvalid")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("the screenshot lightbox", () => {
|
|
// The fourth `aria-modal` surface in the app, and the one the round-5 focus extraction did not
|
|
// sweep in: it declared itself a modal dialog while leaving focus on the thumbnail behind an
|
|
// 80%-black scrim and letting Tab walk the landing page underneath (round 6).
|
|
let origDesc: PropertyDescriptor | undefined;
|
|
beforeEach(() => {
|
|
origDesc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetParent");
|
|
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
|
|
configurable: true,
|
|
get(this: HTMLElement) {
|
|
return this.isConnected ? document.body : null;
|
|
},
|
|
});
|
|
});
|
|
afterEach(() => {
|
|
if (origDesc) Object.defineProperty(HTMLElement.prototype, "offsetParent", origDesc);
|
|
});
|
|
|
|
const openIt = async () => {
|
|
render(<Welcome verify={noVerify} />);
|
|
const shot = (await screen.findAllByLabelText("welcome.preview.feed"))[0]!;
|
|
shot.focus();
|
|
fireEvent.click(shot);
|
|
return { shot, panel: await screen.findByRole("dialog") };
|
|
};
|
|
|
|
it("opens focused inside itself and holds the Tab instead of letting it walk the page", async () => {
|
|
const { panel } = await openIt();
|
|
expect(panel.getAttribute("aria-modal")).toBe("true");
|
|
expect(panel.contains(document.activeElement)).toBe(true);
|
|
// Assert the trap ACTED (the keydown was cancelled), not where focus ended up: jsdom never
|
|
// performs the browser's default Tab traversal, so "focus is still inside" is true with no trap
|
|
// at all — a green assertion about nothing (the mutation check caught exactly that).
|
|
screen.getByLabelText("common.close").focus();
|
|
const notCancelled = fireEvent.keyDown(window, { key: "Tab", cancelable: true });
|
|
expect(notCancelled).toBe(false);
|
|
});
|
|
|
|
it("hands the focus back to the thumbnail that opened it", async () => {
|
|
const { shot } = await openIt();
|
|
fireEvent.keyDown(window, { key: "Escape" });
|
|
await waitFor(() => expect(document.activeElement).toBe(shot));
|
|
});
|
|
|
|
it("defers its Escape and its Tab to a layer opened above it", async () => {
|
|
// Round 6 gave this shell the trap and the restore but not the LAYER MEMBERSHIP the other
|
|
// three have, so both fired from a raw window listener no matter what was on top. That is the
|
|
// shape the wizard's own comment records being bitten by ("one Esc closed the error AND ended
|
|
// onboarding"), and it was unreachable here only because `App` renders `<Welcome/>` before it
|
|
// mounts `ErrorDialog` and the Toaster — an ordering nothing enforces (round 7).
|
|
const { panel } = await openIt();
|
|
const above = pushLayer(); // whatever the landing page grows next
|
|
try {
|
|
fireEvent.keyDown(window, { key: "Escape" });
|
|
// Still open: the layer above owns that Escape.
|
|
expect(screen.queryByRole("dialog")).toBe(panel);
|
|
// And it must not fight the layer above for the Tab either — same reasoning as the test
|
|
// above, so assert the keydown was NOT cancelled rather than where the focus went.
|
|
screen.getByLabelText("common.close").focus();
|
|
expect(fireEvent.keyDown(window, { key: "Tab", cancelable: true })).toBe(true);
|
|
} finally {
|
|
removeLayer(above);
|
|
}
|
|
// With the layer above gone it is topmost again and closes normally.
|
|
fireEvent.keyDown(window, { key: "Escape" });
|
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
|
});
|
|
});
|