lib/app-tabs-context.test.ts
Rama main · réplica de lectura del repositorio original.
// @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<typeof import("./pr-tab-preparation")>();
return { ...actual, createPrTabPreparation: (...args: Parameters<typeof actual.createPrTabPreparation>) => {
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<void>;
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<Response>(() => {})));
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();
});