eslint now also enforces: - the React Compiler diagnostics that `recommended` leaves out (todo, memo-dependencies and ten zero-hit tripwires); - a description on every suppression, and no blanket disables; - no focused or skipped tests (vitest and playwright); - the typed pair no-floating-promises and no-deprecated. Lint also covers e2e/ and the TypeScript build configs, which `eslint .` had been skipping in silence. Every hit is fixed or suppressed inline with a named reason: - The 31 try statements the compiler cannot lower yet carry a suppression until S3 rewrites them. Five other compiler skips were plain fixes: a module counter bumped inside a hook, a ref read in a default parameter, and a local named like the <section> it renders. - The real defects behind floating promises are fixed. ChatDock threw at nobody when the device key store could not be opened. VideoEditor's play() rejection went unhandled. loadYouTubeApi never rejected, so a blocked IFrame API left the player on an empty stage forever; it now fails on a script error or after 15 s, shows its own error with the way out, and lets the next open try again. - MutableRefObject, FormEvent and lucide's deprecated brand icon are replaced. The YouTube glyph now lives in lib/brandIcons. - The lint-probe harness lets its virtual file names into TypeScript's default project, so the contract tests still run the real config.
257 lines
11 KiB
TypeScript
257 lines
11 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// BootGate settles the tab's identity BEFORE the app tree mounts. The bug it replaced: a new tab (no
|
|
// pinned account in sessionStorage) built the app for "no account" first — so with Settings →
|
|
// Appearance → Startup turned off, NavigationProvider's initializer could not read the opt-out and
|
|
// landed on Home, mirrored ?page=home into the address bar, and then re-read its own mirror when the
|
|
// account was pinned, staying on Home. Only a same-tab reload (account already pinned) was right.
|
|
//
|
|
// Real NavigationProvider, real storage and i18n; only the two boot probes are stubbed. The probe
|
|
// records EVERY render, so "never showed the wrong page, not even for one render" is what is asserted.
|
|
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest";
|
|
import { render, screen, cleanup, act, fireEvent, waitFor } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { useEffect } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import BootGate from "./BootGate";
|
|
import { NavigationProvider } from "./NavigationProvider";
|
|
import { useNavigation } from "./navigationContext";
|
|
import { api, type Me } from "../lib/api";
|
|
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
|
import { qk } from "../lib/queryKeys";
|
|
import i18n from "../i18n";
|
|
|
|
const renders: { page: string; lang: string }[] = [];
|
|
let mounts = 0;
|
|
const countMount = () => {
|
|
mounts++; // outside the component: the compiler cannot lower `++` on a module variable in one
|
|
};
|
|
function Probe() {
|
|
const { page } = useNavigation();
|
|
const { i18n: inst } = useTranslation();
|
|
renders.push({ page, lang: inst.language });
|
|
useEffect(countMount, []);
|
|
return <div data-testid="app">{page}</div>;
|
|
}
|
|
|
|
function mount() {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
render(
|
|
<QueryClientProvider client={qc}>
|
|
<BootGate>
|
|
<NavigationProvider>
|
|
<Probe />
|
|
</NavigationProvider>
|
|
</BootGate>
|
|
</QueryClientProvider>,
|
|
);
|
|
return qc;
|
|
}
|
|
const account = (over: Partial<Me> = {}) => ({ id: 5, preferences: {}, ...over }) as Me;
|
|
let savePrefs: MockInstance<typeof api.savePrefs>;
|
|
const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") });
|
|
|
|
beforeEach(async () => {
|
|
localStorage.clear();
|
|
sessionStorage.clear(); // a NEW tab: nothing pinned yet
|
|
window.history.replaceState(null, "", "/");
|
|
renders.length = 0;
|
|
mounts = 0;
|
|
await i18n.changeLanguage("en");
|
|
vi.spyOn(api, "setupStatus").mockResolvedValue({ configured: true });
|
|
savePrefs = vi.spyOn(api, "savePrefs").mockResolvedValue({ ok: true } as never);
|
|
});
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("BootGate", () => {
|
|
it("a new tab with Startup off opens on the page the account left — never on Home first", async () => {
|
|
localStorage.setItem("siftlode.startOnHome.5", "0");
|
|
localStorage.setItem("siftlode.page.5", "downloads");
|
|
vi.spyOn(api, "me").mockResolvedValue(account());
|
|
mount();
|
|
|
|
expect((await screen.findByTestId("app")).textContent).toBe("downloads");
|
|
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads"));
|
|
expect(new URLSearchParams(window.location.search).get("page")).toBe("downloads");
|
|
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // the tab is pinned
|
|
});
|
|
|
|
it("renders the app in the account's language from its first render", async () => {
|
|
vi.spyOn(api, "me").mockResolvedValue(account({ preferences: { language: "hu" } }));
|
|
mount();
|
|
|
|
await screen.findByTestId("app");
|
|
expect(renders.map((r) => r.lang)).toEqual(renders.map(() => "hu"));
|
|
});
|
|
|
|
it("an account with no language stored stores the one it is shown in, once", async () => {
|
|
await i18n.changeLanguage("hu"); // the browser's language, never saved
|
|
vi.spyOn(api, "me").mockResolvedValue(account());
|
|
const qc = mount();
|
|
|
|
await screen.findByTestId("app");
|
|
await waitFor(() => expect(savePrefs).toHaveBeenCalledWith({ language: "hu" }));
|
|
expect(savePrefs).toHaveBeenCalledTimes(1);
|
|
// The server now sorts in Hungarian, and so does what reads the stored language.
|
|
await waitFor(() => expect(qc.getQueryData<Me>(qk.me())?.preferences.language).toBe("hu"));
|
|
});
|
|
|
|
it("an account with its language stored stores nothing", async () => {
|
|
vi.spyOn(api, "me").mockResolvedValue(account({ preferences: { language: "hu" } }));
|
|
mount();
|
|
|
|
await screen.findByTestId("app");
|
|
expect(savePrefs).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("mounts nothing of the app while the identity is still unknown", async () => {
|
|
vi.spyOn(api, "me").mockReturnValue(new Promise<Me | null>(() => {}));
|
|
mount();
|
|
await act(async () => {}); // the setup probe resolves; `me` never does
|
|
|
|
expect(screen.queryByTestId("app")).toBeNull();
|
|
expect(renders).toHaveLength(0);
|
|
});
|
|
|
|
it("signed out: mounts the app (App shows the landing) without pinning an account", async () => {
|
|
vi.spyOn(api, "me").mockResolvedValue(null);
|
|
mount();
|
|
|
|
await screen.findByTestId("app");
|
|
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull();
|
|
});
|
|
|
|
it("setup mode: mounts the app (App shows the wizard) without asking for the account", async () => {
|
|
vi.mocked(api.setupStatus).mockResolvedValue({ configured: false });
|
|
const me = vi.spyOn(api, "me").mockResolvedValue(account());
|
|
mount();
|
|
|
|
await screen.findByTestId("app");
|
|
expect(me).not.toHaveBeenCalled();
|
|
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBeNull();
|
|
});
|
|
|
|
it("an unanswered setup probe is an error with a retry — never the signed-out landing", async () => {
|
|
vi.mocked(api.setupStatus)
|
|
.mockRejectedValueOnce(new Error("502"))
|
|
.mockResolvedValue({ configured: true });
|
|
vi.spyOn(api, "me").mockResolvedValue(account());
|
|
mount();
|
|
|
|
fireEvent.click(await retryButton());
|
|
await screen.findByTestId("app");
|
|
expect(renders.length).toBeGreaterThan(0);
|
|
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5"); // it mounted for the account
|
|
});
|
|
|
|
it("an unanswered first account probe is an error with a retry", async () => {
|
|
vi.spyOn(api, "me").mockRejectedValueOnce(new Error("502")).mockResolvedValue(account());
|
|
mount();
|
|
|
|
fireEvent.click(await retryButton());
|
|
await screen.findByTestId("app");
|
|
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
|
|
});
|
|
|
|
it("a background refetch that fails keeps the app mounted, as it was", async () => {
|
|
const me = vi.spyOn(api, "me").mockResolvedValue(account());
|
|
const qc = mount();
|
|
await screen.findByTestId("app");
|
|
|
|
me.mockRejectedValue(new Error("502"));
|
|
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
|
|
// react-query hands the error to its observers on a timer: let that render land before asserting.
|
|
await act(() => new Promise((r) => setTimeout(r, 20)));
|
|
|
|
expect(qc.getQueryState(qk.me())?.status).toBe("error");
|
|
expect(screen.getByTestId("app")).toBeTruthy();
|
|
expect(mounts).toBe(1);
|
|
});
|
|
|
|
it("a pin naming another account than the server answered as is corrected before the app reads it", async () => {
|
|
// Pinned to 9, but 9 was signed out in another tab: the server answers as the browser's default, 5.
|
|
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, "9");
|
|
localStorage.setItem("siftlode.startOnHome.5", "0");
|
|
localStorage.setItem("siftlode.page.5", "downloads");
|
|
vi.spyOn(api, "me").mockResolvedValue(account());
|
|
mount();
|
|
|
|
expect((await screen.findByTestId("app")).textContent).toBe("downloads");
|
|
expect(renders.map((r) => r.page)).toEqual(renders.map(() => "downloads"));
|
|
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
|
|
});
|
|
|
|
it("a mounted tab whose identity changes reloads rather than keep the previous account's tree", async () => {
|
|
const me = vi.spyOn(api, "me").mockResolvedValue(account());
|
|
const qc = mount();
|
|
await screen.findByTestId("app");
|
|
const reload = vi.fn();
|
|
vi.stubGlobal("location", { ...window.location, reload });
|
|
|
|
me.mockResolvedValue(null); // the session ended
|
|
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
|
|
|
|
await waitFor(() => expect(reload).toHaveBeenCalled());
|
|
});
|
|
|
|
it("an identity change reloads without moving the pin first", async () => {
|
|
// The tree being torn down still runs its unmount saves before the page goes; with the pin already
|
|
// moved they would land under the NEW account's local keys (a player volume still pending, say).
|
|
const me = vi.spyOn(api, "me").mockResolvedValue(account());
|
|
const qc = mount();
|
|
await screen.findByTestId("app");
|
|
const reload = vi.fn();
|
|
vi.stubGlobal("location", { ...window.location, reload });
|
|
|
|
me.mockResolvedValue(account({ id: 6 })); // 5 was signed out elsewhere: the server answers as 6
|
|
await act(() => qc.refetchQueries({ queryKey: qk.me() }));
|
|
|
|
await waitFor(() => expect(reload).toHaveBeenCalled());
|
|
expect(sessionStorage.getItem(ACTIVE_ACCOUNT_KEY)).toBe("5");
|
|
});
|
|
|
|
// The adoption effect is idempotent for the identity it already adopted, and that is load-bearing
|
|
// rather than tidy: a re-run reloads the page, so an effect that cannot tell "already adopted this
|
|
// account" from "adopted a different one" turns an ordinary boot into a reload loop. The spy is
|
|
// installed BEFORE mounting on purpose — the other reload tests stub it after the app is up, which
|
|
// is exactly late enough to miss the re-run that setting `readyFor` triggers.
|
|
it("a plain boot never reloads, however many times the adoption effect re-runs", async () => {
|
|
const reload = vi.fn();
|
|
vi.stubGlobal("location", { ...window.location, reload });
|
|
vi.spyOn(api, "me").mockResolvedValue(account());
|
|
|
|
const qc = mount();
|
|
await screen.findByTestId("app");
|
|
// Anything that re-renders the gate must leave it alone: a pref save, a background refetch.
|
|
act(() => {
|
|
qc.setQueryData(qk.me(), account({ preferences: { startup: "last" } }));
|
|
});
|
|
await act(() => new Promise((r) => setTimeout(r, 20)));
|
|
|
|
expect(reload).not.toHaveBeenCalled();
|
|
expect(mounts).toBe(1);
|
|
});
|
|
|
|
it("a change to the account's data, not its identity, leaves the app as it is", async () => {
|
|
vi.spyOn(api, "me").mockResolvedValue(account());
|
|
const qc = mount();
|
|
await screen.findByTestId("app");
|
|
const reload = vi.fn();
|
|
vi.stubGlobal("location", { ...window.location, reload });
|
|
|
|
// What every pref save does to the cache (usePref's mergePrefsCache): a new `me` for the same account.
|
|
act(() => {
|
|
qc.setQueryData(qk.me(), account({ preferences: { language: "hu" } }));
|
|
});
|
|
await act(() => new Promise((r) => setTimeout(r, 20)));
|
|
|
|
expect(reload).not.toHaveBeenCalled();
|
|
expect(mounts).toBe(1);
|
|
expect(i18n.language).toBe("en"); // the language is adopted once, never over the user's own choice
|
|
});
|
|
});
|