// @vitest-environment jsdom import { act, createElement, useEffect, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AppTabsProvider, useAppTabs, useOptionalAppTabs, useOptionalAppTabNavigation, useOptionalAppTabSession, } from "./app-tabs-context"; import { createHomeTab } from "./app-tabs"; import type { AppTabsSession } from "./app-tabs-session"; const query = vi.hoisted(() => ({ isPending: false, isError: false, refetch: vi.fn() })); const router = vi.hoisted(() => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() })); const preparations = vi.hoisted(() => [] as { intend: (href: string) => void }[]); const viewport = vi.hoisted(() => ({ mobile: false as boolean | undefined })); const queryCalls = vi.hoisted(() => vi.fn()); const auth = vi.hoisted(() => ({ user: { id: "owner" } as { id: string } | null, loading: false })); vi.mock("./use-mobile-layout", () => ({ useMobileLayout: () => viewport.mobile })); vi.mock("./auth-context", () => ({ useAuth: () => auth })); vi.mock("./use-app-tabs-query", () => ({ appTabsQueryKey: (owner: string) => ["app-tabs", owner], useAppTabsQuery: (...args: unknown[]) => { queryCalls(...args); return query; }, })); vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key })); vi.mock("next/navigation", () => ({ useRouter: () => router })); vi.mock("@/components/app-tab-route-sync", () => ({ AppTabRouteSync: () => null })); vi.mock("./pr-tab-preparation", async (original) => { const actual = await original(); return { ...actual, createPrTabPreparation: (...args: Parameters) => { const preparation = actual.createPrTabPreparation(...args); preparations.push(preparation); return preparation; } }; }); let root: Root; let container: HTMLDivElement; let client: QueryClient; let session: AppTabsSession; let renders: { strip: number; navigation: number; actions: number }; const firstId = "10000000-0000-0000-0000-000000000001"; const secondId = "10000000-0000-0000-0000-000000000002"; function Strip() { session = useAppTabs().session; renders.strip++; return null; } function Page() { const navigation = useOptionalAppTabNavigation(); renders.navigation++; return createElement("span", null, navigation?.activeId); } function Actions() { useOptionalAppTabSession(); renders.actions++; return null; } beforeEach(async () => { vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); viewport.mobile = false; router.replace.mockClear(); sessionStorage.clear(); auth.user = { id: "owner" }; auth.loading = false; renders = { strip: 0, navigation: 0, actions: 0 }; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); client = new QueryClient(); await act(() => root.render(createElement(QueryClientProvider, { client }, createElement(AppTabsProvider, { children: [ createElement(Strip, { key: "strip" }), createElement(Page, { key: "page" }), createElement(Actions, { key: "actions" }), ] }), ))); await act(async () => { session.receive([createHomeTab("owner", firstId), createHomeTab("owner", secondId, 1)]); await session.initialize("/home"); }); }); afterEach(async () => { await act(() => root.unmount()); client.clear(); container.remove(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); describe("application tab subscriptions", () => { it("keeps descendant drafts mounted across unresolved, mobile and desktop layouts", async () => { let mounts = 0, unmounts = 0; let update!: (value: string) => void; let contexts: unknown[] = []; function Draft() { const [value, setValue] = useState(""); update = setValue; contexts = [useOptionalAppTabs(), useOptionalAppTabNavigation(), useOptionalAppTabSession()]; useEffect(() => { mounts++; return () => { unmounts++; }; }, []); return createElement("input", { value, readOnly: true }); } const tree = () => createElement(QueryClientProvider, { client }, createElement(AppTabsProvider, { children: createElement(Draft) })); await act(() => root.render(tree())); await act(() => update("An unfinished ticket description")); const input = container.querySelector("input"); for (const mode of [undefined, true, false, true]) { queryCalls.mockClear(); viewport.mobile = mode; await act(() => root.render(tree())); expect(container.querySelector("input")).toBe(input); expect(input?.value).toBe("An unfinished ticket description"); expect(mounts).toBe(1); expect(unmounts).toBe(0); if (mode !== false) { expect(contexts).toEqual([null, null, null]); expect(queryCalls).not.toHaveBeenCalled(); } } }); describe("across auth identity changes", () => { let mounts: number[]; let next: number; let update!: (value: string) => void; function Draft() { // One identity per mounted descendant instance. const [instance] = useState(() => ++next); const [value, setValue] = useState(""); update = setValue; useEffect(() => { mounts.push(instance); }, [instance]); return createElement("input", { value, readOnly: true }); } const render = (user: { id: string } | null, loading = false) => act(() => { auth.user = user; auth.loading = loading; root.render(createElement(QueryClientProvider, { client }, createElement(AppTabsProvider, { children: createElement(Draft) }))); }); beforeEach(async () => { // The shared setup already resolved auth; start from a fresh provider. await act(() => root.unmount()); root = createRoot(container); mounts = []; next = 0; }); it("keeps descendants and their drafts when auth first resolves to the cookie account", async () => { await render(null, true); await act(() => update("Typed before the session resolved")); await render({ id: "owner" }); await render({ id: "owner" }); expect(mounts).toEqual([1]); expect(container.querySelector("input")?.value).toBe("Typed before the session resolved"); }); it("remounts descendants on every later identity change", async () => { await render(null, true); await render({ id: "owner" }); await render(null); await render({ id: "other" }); await render({ id: "owner" }); expect(mounts).toEqual([1, 2, 3, 4]); }); it("remounts descendants when a signed-out first resolution later finds an account", async () => { await render(null, true); await render(null); await render({ id: "owner" }); expect(mounts).toEqual([1, 2]); }); }); it("does not reset the current page when a desktop reload rotates into mobile", async () => { window.history.replaceState(null, "", "/projects/example"); vi.spyOn(performance, "getEntriesByType").mockReturnValue([{ type: "reload" } as PerformanceNavigationTiming]); const tree = () => createElement(QueryClientProvider, { client }, createElement(AppTabsProvider, { children: createElement(Page) })); viewport.mobile = true; await act(() => root.render(tree())); expect(router.replace).not.toHaveBeenCalled(); expect(window.location.pathname).toBe("/projects/example"); }); it("replaces the session on account switches and clears optional contexts on sign-out", async () => { let values: unknown[] = []; function OptionalConsumers() { values = [useOptionalAppTabs(), useOptionalAppTabNavigation(), useOptionalAppTabSession()]; return null; } const tree = createElement(QueryClientProvider, { client }, createElement(AppTabsProvider, { children: createElement(OptionalConsumers) }), ); await act(() => root.render(tree)); const previousSession = values[2] as AppTabsSession; const dispose = vi.spyOn(previousSession, "dispose"); auth.user = { id: "another-owner" }; await act(() => root.render(createElement(QueryClientProvider, { client }, createElement(AppTabsProvider, { children: createElement(OptionalConsumers) }), ))); expect(values[2]).not.toBe(previousSession); expect((values[2] as AppTabsSession).getSnapshot().activeId).toBeNull(); expect(dispose).toHaveBeenCalledOnce(); auth.user = null; await act(() => root.render(createElement(QueryClientProvider, { client }, createElement(AppTabsProvider, { children: createElement(OptionalConsumers) }), ))); expect(values).toEqual([null, null, null]); }); it("updates tab metadata without rendering page or action consumers", async () => { const previous = { ...renders }; await act(() => session.receive(session.getSnapshot().tabs.map((tab) => ({ ...tab, revision: tab.revision + 1, custom_name: "Renamed remotely", })))); expect(renders.strip).toBeGreaterThan(previous.strip); expect(renders.navigation).toBe(previous.navigation); expect(renders.actions).toBe(previous.actions); }); it("keeps departure status local to the strip and publishes actual activation", async () => { let release!: (allowed: boolean) => void; const unregister = session.registerDeparture(() => new Promise((resolve) => { release = resolve; })); const previous = { ...renders }; let activation!: Promise; await act(async () => { activation = session.activate(secondId); await Promise.resolve(); }); expect(session.getSnapshot().busy).toBe(true); expect(renders.strip).toBeGreaterThan(previous.strip); expect(renders.navigation).toBe(previous.navigation); expect(renders.actions).toBe(previous.actions); await act(async () => { release(true); await activation; }); expect(container.textContent).toBe(secondId); expect(renders.navigation).toBe(previous.navigation + 1); expect(renders.actions).toBe(previous.actions); unregister(); }); }); it("publishes a hovered destination to the live preparation before warming it", () => { vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); const intend = vi.spyOn(preparations.at(-1)!, "intend"); session.prefetch("/projects/hovered"); expect(intend).toHaveBeenCalledWith("/projects/hovered"); }); it("uses local history only for a destination qualified by the mounted retained host", () => { const history = vi.spyOn(window.history, "pushState"); session.isRetainedDestination = (id, href) => id === firstId && href === "/all"; session.navigate("/all"); expect(history).toHaveBeenCalledWith(null, "", "/all"); session.navigate("/projects/cold"); expect(router.push).toHaveBeenCalledWith("/projects/cold", { scroll: false }); history.mockRestore(); }); it("navigates to a stored full-id destination through its short codes", () => { const history = vi.spyOn(window.history, "pushState"); session.isRetainedDestination = () => true; session.navigate("/projects/aur-9e3d0c35/objectives?open=71384d77-1fc9-4227-b045-56ff2c8e29e4"); expect(history).toHaveBeenCalledWith(null, "", "/projects/aur-9e3d0c35/objectives?open=71384d77"); session.isRetainedDestination = () => false; session.navigate("/projects/9e3d0c35-c1d7-4d2a-983d-8190638cc887?objective=71384d77-1fc9-4227-b045-56ff2c8e29e4"); expect(router.push).toHaveBeenCalledWith("/projects/9e3d0c35?objective=71384d77", { scroll: false }); history.mockRestore(); });