- The Downloads module, its rail icon and badge are gone. A bookmark, the remembered page and an old
history entry that name it land on Files (urlState.resolvePage); `g d` goes to Files too.
- Users page: a Storage tab — the disk in numbers, every user's use against their own limit with
their file count, every user's downloads; aligned tables, one menu per row (edit / reset quota,
open source, copy, the owner's quota). The admin storage rows carry quota + file count, the admin
job list its owner.
- Settings: a Downloads tab — the download formats in one list (duplicate a built-in, edit or delete
your own) and the site sign-ins, moved from Account.
- "View" on an added download, a clip or a converting upload opens the tray on that row (unfolded,
back if closed; a finished one opens as a file; under a player it opens without taking focus).
- The download dialog opens on the format used last.
- DataTable: `pager={false}` for a short list, `cardRow` to give a lone "…" its own card line.
- Welcome and README speak of Files; a new Files screenshot.
328 lines
14 KiB
TypeScript
328 lines
14 KiB
TypeScript
// @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<typeof import("react-i18next")>();
|
|
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 }) => (
|
|
<QueryClientProvider client={qc}>
|
|
<NavigationProvider>{children}</NavigationProvider>
|
|
</QueryClientProvider>
|
|
);
|
|
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 : <PageHeading>{name}</PageHeading>;
|
|
}
|
|
const view = rtlRender(<View name="" />, { wrapper });
|
|
await settle();
|
|
expect(document.title).toBe("plex.navLabel · Siftlode"); // no title yet: the place alone
|
|
|
|
view.rerender(<View name="Nicolas Cage" />);
|
|
await settle();
|
|
expect(document.title).toBe("Nicolas Cage · plex.navLabel · Siftlode");
|
|
|
|
view.rerender(<View name={null} />);
|
|
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(<PageHeading tabTitle="Voyager · Season 1">Season 1</PageHeading>, { 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" });
|
|
});
|
|
});
|