// @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(); return { ...actual, useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), }; }); vi.mock("../lib/api", async (importOriginal) => { const actual = await importOriginal(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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 `` 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()); }); });