// @vitest-environment jsdom // // The navigation actions against the overlay history stack: `setPage` and `openChannel` never push // under an open overlay — they close it and push once its Back-to-close entry is reclaimed — and // `setPage`'s same-page guard, which decides whether anything is pushed at all. // // The overlay half is what used to live at each call site (Shift+W in App, a toast's "View", the // palette, the player's channel link) and could be forgotten at any of them; it now lives in the two // actions, so this file is where it is pinned. The end-to-end gesture — Shift+W over the player, // then browser Back — is still checked in a real browser: jsdom reproduces history ordering only as // bookkeeping. import { describe, it, expect, afterEach, beforeEach, vi } from "vitest"; import { renderHook, render as rtlRender, act, cleanup } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; // Partial: the provider reads `t` AND `i18n.language` (both feed the document-title effect), and the // import graph pulls in `src/i18n`, which initialises against the real `initReactI18next` — so a // wholesale mock throws before any test runs. vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }), }; }); import { NavigationProvider } from "./NavigationProvider"; import { useNavigation, useNavigationActions } from "./navigationContext"; import { useBackToClose } from "../lib/history"; import { PageHeading } from "./ui/Headings"; import { qk } from "../lib/queryKeys"; /** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs * to this document, nearest first. jsdom has no Navigation API; Chrome's says this. */ function stubNavigation(below: boolean[]) { const entries = [...below].reverse().map((sameDocument) => ({ sameDocument })); entries.push({ sameDocument: true }); Object.defineProperty(window, "navigation", { configurable: true, value: { currentEntry: { index: entries.length - 1 }, entries: () => entries }, }); } afterEach(() => { Reflect.deleteProperty(window, "navigation"); }); // The provider reads the signed-in account (the tab title) from the `me` cache. let qc = new QueryClient(); const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); const render = () => renderHook(() => ({ ...useNavigation(), ...useNavigationActions() }), { wrapper }); const settle = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); }); /** An open overlay (the player) whose close really unmounts it; its reclaiming back() is stubbed to * land its popstate at once. */ async function openOverlay() { const back = vi.spyOn(window.history, "back").mockImplementation(() => { window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state })); }); const holder: { unmount?: () => void } = {}; const onClose = vi.fn(() => holder.unmount?.()); const layer = renderHook(() => useBackToClose(onClose)); holder.unmount = layer.unmount; await settle(); return { back, onClose }; } beforeEach(() => { qc = new QueryClient(); qc.setQueryData(qk.me(), { id: 1 }); localStorage.clear(); sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory window.history.replaceState({ sfPage: "feed" }, "", "/"); }); // Not automatic: vitest runs without `globals`, so RTL never registers its own afterEach — leaving a // provider (and its popstate listener) mounted for the next case. Same reason Home.dom.test.tsx does. afterEach(() => { cleanup(); vi.restoreAllMocks(); }); describe("NavigationProvider · the tab title", () => { // Prod UAT 2026-09-19: signed out, the tab read the Home page's question over the sign-in form. it("is the brand alone while nobody is signed in, the page's name once someone is", async () => { window.history.replaceState({ sfPage: "home" }, "", "/?page=home"); qc.setQueryData(qk.me(), null); render(); await settle(); expect(document.title).toBe("Siftlode"); act(() => { qc.setQueryData(qk.me(), { id: 1 }); }); await settle(); expect(document.title).not.toBe("Siftlode"); expect(document.title.endsWith(" · Siftlode")).toBe(true); }); // The Feed's tab read a bare "Siftlode" — the one page that named no place. it("names the Feed like every other page", async () => { window.history.replaceState({ sfPage: "feed" }, "", "/?page=feed"); render(); await settle(); expect(document.title).toBe("header.account.feed · Siftlode"); }); // A channel opened from a link was "Channels · Siftlode", the same channel opened in the // app was named; a Plex title, person or collection only "Plex". The object is the view's heading. it("puts the open view's own heading before the place, and drops it when the view closes", async () => { window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex"); function View({ name }: { name: string | null }) { return name === null ? null : {name}; } const view = rtlRender(, { wrapper }); await settle(); expect(document.title).toBe("plex.navLabel · Siftlode"); // no title yet: the place alone view.rerender(); await settle(); expect(document.title).toBe("Nicolas Cage · plex.navLabel · Siftlode"); view.rerender(); await settle(); expect(document.title).toBe("plex.navLabel · Siftlode"); }); it("takes a heading's tabTitle over its text", async () => { window.history.replaceState({ sfPage: "plex" }, "", "/?page=plex"); rtlRender(Season 1, { wrapper }); await settle(); expect(document.title).toBe("Voyager · Season 1 · plex.navLabel · Siftlode"); }); }); describe("NavigationProvider · setPage's same-page guard", () => { it("pushes an entry for a real page change", () => { const { result } = render(); const push = vi.spyOn(window.history, "pushState"); act(() => result.current.setPage("plex")); expect(push).toHaveBeenCalledTimes(1); expect(result.current.page).toBe("plex"); }); it("leaves every page's own URL keys behind, the log console's included", () => { window.history.replaceState( { sfPage: "logs" }, "", "/?page=logs&lgrp=error&lreq=req-abcdef12&pscope=movie&ctab=hidden&keep=1", ); const { result } = render(); act(() => result.current.setPage("plex")); expect(window.location.search).toBe("?page=plex&keep=1"); }); it("pushes nothing when it is already that page", () => { const { result } = render(); act(() => result.current.setPage("plex")); const push = vi.spyOn(window.history, "pushState"); act(() => result.current.setPage("plex")); expect(push).not.toHaveBeenCalled(); }); it("still navigates to the current page when a channel page is over it", () => { // The nav rail's own module while a channel page is open over it is a real navigation: it closes // the channel page. The `&& !channelViewRef.current` half of the guard is what makes it one. const { result } = render(); // The page under test must be the one that is ALREADY current, or the guard's first conjunct is // false and this never reaches the clause it is named for — the mistake a review caught here: a // bare URL with cleared storage lands on "home" (navigationInit's default), not "feed", so // asking for "feed" was just an ordinary page change and deleting the clause left it green. const current = result.current.page; expect(current).toBe("home"); act(() => result.current.openChannel("UC1", "A channel")); expect(result.current.channelView).not.toBeNull(); const push = vi.spyOn(window.history, "pushState"); act(() => result.current.setPage(current)); expect(push).toHaveBeenCalledTimes(1); expect(result.current.channelView).toBeNull(); }); }); describe("NavigationProvider · Back", () => { // A tab that had a since-removed module open, reloaded onto the new build: its older history // entries still name that page, and Back lands on one of them. it("lands on the feed when the entry names a page this build does not have", () => { const { result } = render(); act(() => result.current.setPage("plex")); act(() => { window.history.replaceState({ sfPage: "messages" }, "", "/?page=messages&keep=1"); window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "messages" } })); }); expect(result.current.page).toBe("feed"); // …and the entry says so too, so the address bar and a later Feed click agree with the screen. expect(window.history.state).toMatchObject({ sfPage: "feed" }); expect(window.location.search).toBe("?keep=1"); }); it("lands where a retired page's content went, and the entry names that page", () => { const { result } = render(); act(() => result.current.setPage("plex")); act(() => { window.history.replaceState({ sfPage: "downloads" }, "", "/?page=downloads&keep=1"); window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "downloads" } })); }); expect(result.current.page).toBe("files"); expect(window.history.state).toMatchObject({ sfPage: "files" }); expect(window.location.search).toBe("?page=files&keep=1"); }); // lib/history's own listener takes a spent focus stamp off the entry it lands on; the rewrite must // not put it back, or the next landing there (an overlay closing) would refocus a stale item. it("does not put back the focus stamp the landing spent", () => { render(); const st = { sfPage: "messages", sfNav: { list: 0, lists: 1 } }; act(() => { window.history.replaceState(st, "", "/?page=messages"); window.dispatchEvent(new PopStateEvent("popstate", { state: st })); }); expect(window.history.state).toMatchObject({ sfPage: "feed" }); expect(window.history.state).not.toHaveProperty("sfNav"); }); }); describe("NavigationProvider · leaving a sub-view whose entry is the oldest of this document", () => { // A full tab can have dropped the entry under the one the channel page or the search pushed (see // lib/history's backStaysInDocument): leaving clears it in place instead of stepping out. it("closes the channel page in place", () => { const { result } = render(); act(() => result.current.openChannel("UC1", "One")); stubNavigation([false]); const back = vi.spyOn(window.history, "back").mockImplementation(() => {}); act(() => result.current.closeChannel()); expect(back).not.toHaveBeenCalled(); expect(result.current.channelView).toBeNull(); expect(window.location.search).not.toContain("chan="); }); it("leaves the YouTube search in place", () => { const { result } = render(); act(() => result.current.enterYtSearch("cats")); stubNavigation([false]); const back = vi.spyOn(window.history, "back").mockImplementation(() => {}); act(() => result.current.exitYtSearch()); expect(back).not.toHaveBeenCalled(); expect(result.current.ytSearch).toBeNull(); }); }); describe("NavigationProvider · never under an open overlay", () => { it("setPage closes the overlay first and pushes only once its entry is reclaimed", async () => { const { result } = render(); const { back, onClose } = await openOverlay(); const push = vi.spyOn(window.history, "pushState"); act(() => result.current.setPage("plex")); expect(onClose).toHaveBeenCalledTimes(1); expect(push).not.toHaveBeenCalled(); // not while the player's entry is still current await settle(); await settle(); expect(back).toHaveBeenCalledTimes(1); expect(push).toHaveBeenCalledTimes(1); expect(result.current.page).toBe("plex"); expect(window.history.state).toMatchObject({ sfPage: "plex" }); }); // `then` belongs to the NEW entry (the palette's "Meglepetés" token names it — lib/homeIntent), so // over an open player it must wait for the push, and with nothing to push it still runs. it("runs setPage's `then` after the push, on the entry the push made", async () => { const { result } = render(); await openOverlay(); const seen: unknown[] = []; expect(result.current.page).not.toBe("plex"); act(() => result.current.setPage("plex", () => seen.push(window.history.state?.sfPage))); expect(seen).toEqual([]); await settle(); await settle(); expect(seen).toEqual(["plex"]); act(() => result.current.setPage("plex", () => seen.push("again"))); expect(seen).toEqual(["plex", "again"]); }); // Shift+W on Home with the player open (owner decision 2026-09-09): nothing to push, but the player // closes, and its entry is reclaimed rather than orphaned under a push that never happened. it("setPage to the current page closes the overlay and pushes nothing", async () => { const { result } = render(); const { back, onClose } = await openOverlay(); const push = vi.spyOn(window.history, "pushState"); act(() => result.current.setPage(result.current.page)); await settle(); await settle(); expect(onClose).toHaveBeenCalledTimes(1); expect(back).toHaveBeenCalledTimes(1); expect(push).not.toHaveBeenCalled(); }); // The player's channel link and the palette's channel rows. it("openChannel closes the overlay first and pushes the channel once its entry is reclaimed", async () => { const { result } = render(); const { back, onClose } = await openOverlay(); const push = vi.spyOn(window.history, "pushState"); act(() => result.current.openChannel("UC1", "A channel")); expect(onClose).toHaveBeenCalledTimes(1); expect(push).not.toHaveBeenCalled(); await settle(); await settle(); expect(back).toHaveBeenCalledTimes(1); expect(push).toHaveBeenCalledTimes(1); expect(result.current.channelView).toEqual({ id: "UC1", name: "A channel" }); expect(window.history.state).toMatchObject({ _chan: "UC1" }); }); });