// @vitest-environment jsdom import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { queryKeys } from "../lib/queryKeys"; import { SidebarAccountMenu } from "./SidebarAccountMenu"; const mockAuthApi = vi.hoisted(() => ({ getSession: vi.fn(), signInEmail: vi.fn(), signUpEmail: vi.fn(), getProfile: vi.fn(), updateProfile: vi.fn(), signOut: vi.fn(), })); const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn(), })); const mockToggleTheme = vi.hoisted(() => vi.fn()); const mockSetSidebarOpen = vi.hoisted(() => vi.fn()); const mockNavigateTopLevel = vi.hoisted(() => vi.fn()); vi.mock("@/api/auth", () => ({ authApi: mockAuthApi, })); vi.mock("@/lib/browserNavigation", () => ({ navigateTopLevel: mockNavigateTopLevel, })); vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi, })); vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi, })); vi.mock("@/lib/router", () => ({ Link: ({ children, to, ...props }: { children: React.ReactNode; to: string }) => ( {children} ), })); vi.mock("../context/SidebarContext", () => ({ useSidebar: () => ({ isMobile: false, setSidebarOpen: mockSetSidebarOpen, }), })); vi.mock("../context/ThemeContext", () => ({ useTheme: () => ({ theme: "dark", toggleTheme: mockToggleTheme, }), })); // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; async function act(callback: () => void | Promise) { await callback(); await Promise.resolve(); await new Promise((resolve) => window.setTimeout(resolve, 0)); } async function flushReact() { await act(async () => { await Promise.resolve(); await new Promise((resolve) => window.setTimeout(resolve, 0)); }); } describe("SidebarAccountMenu", () => { let container: HTMLDivElement; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); mockAuthApi.getSession.mockResolvedValue({ session: { id: "session-1", userId: "user-1" }, user: { id: "user-1", name: "Jane Example", email: "jane@example.com", image: "https://example.com/jane.png", }, }); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false, }); mockAuthApi.signOut.mockResolvedValue({ success: true, redirectTo: "/cloud/logout" }); }); afterEach(() => { container.remove(); document.body.innerHTML = ""; vi.clearAllMocks(); }); it("shares the nav background without separator borders", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); await act(async () => { root.render( , ); }); await flushReact(); const accountSurface = container.firstElementChild; expect(accountSurface?.className).toContain("bg-border/50"); expect(accountSurface?.className).toContain("dark:bg-muted"); expect(accountSurface?.className).not.toContain("border-t"); expect(accountSurface?.className).not.toContain("border-r"); expect(accountSurface?.className).not.toContain("border-border"); await act(async () => root.unmount()); }); it("keeps authenticated self-hosted sign-out on the local auth flow", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); queryClient.setQueryData(queryKeys.health, { status: "ok", deploymentMode: "authenticated", }); await act(async () => { root.render( , ); }); await flushReact(); await flushReact(); expect(container.textContent).toContain("Jane Example"); expect(container.textContent).not.toContain("jane@example.com"); const trigger = container.querySelector('button[aria-label="Open account menu"]'); expect(trigger).not.toBeNull(); await act(async () => { trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(document.body.textContent).toContain("Edit profile"); expect(document.body.textContent).toContain("Settings"); expect(document.body.textContent).not.toContain("Instance settings"); expect(document.body.textContent).toContain("Documentation"); expect(document.body.textContent).toContain("Feedback"); // Feedback link opens in a new tab pointing at the feedback URL const feedbackAnchor = document.body.querySelector('a[href="https://paperclip.ing/feedback"]') as HTMLAnchorElement | null; expect(feedbackAnchor).not.toBeNull(); expect(feedbackAnchor?.getAttribute("target")).toBe("_blank"); // Feedback appears after Documentation and before the theme toggle const menuText = document.body.querySelector('[data-slot="popover-content"]')?.textContent ?? ""; const docsPos = menuText.indexOf("Documentation"); const feedbackPos = menuText.indexOf("Feedback"); const themePos = menuText.indexOf("Switch to"); expect(docsPos).toBeLessThan(feedbackPos); expect(feedbackPos).toBeLessThan(themePos); expect(document.body.textContent).toContain("Paperclip v1.2.3"); expect(document.body.textContent).toContain("jane@example.com"); expect(document.body.querySelector('[data-slot="popover-content"]')?.className) .toContain("w-(--sz-277px)"); expect(document.body.querySelector('a[href="/company/settings/instance/profile"]')).not.toBeNull(); expect(document.body.querySelector('a[href="/company/settings"]')).not.toBeNull(); const signOutButton = Array.from(document.body.querySelectorAll("button")).find( (button) => button.textContent?.includes("Sign out"), ); await act(async () => { signOutButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(mockAuthApi.signOut).toHaveBeenCalledOnce(); expect(mockNavigateTopLevel).not.toHaveBeenCalled(); expect(queryClient.getQueryState(queryKeys.health)?.isInvalidated).toBe(true); await act(async () => { root.unmount(); }); }); it("navigates cloud-managed sign-out through the harness without calling local auth", async () => { const root = createRoot(container); const onOpenChange = vi.fn(); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); queryClient.setQueryData(queryKeys.health, { status: "ok", deploymentMode: "authenticated", cloud: { managed: true, managedBy: "paperclip-cloud", stackSlug: "acme-labs", cloudBaseUrl: "https://cloud.example.test", }, }); await act(async () => { root.render( , ); }); await flushReact(); const signOutButton = Array.from(document.body.querySelectorAll("button")).find( (button) => button.textContent?.includes("Sign out"), ); await act(async () => { signOutButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); expect(mockAuthApi.signOut).not.toHaveBeenCalled(); expect(mockNavigateTopLevel).toHaveBeenCalledOnce(); expect(mockNavigateTopLevel).toHaveBeenCalledWith("/cloud/logout"); expect(onOpenChange).toHaveBeenCalledWith(false); await act(async () => { root.unmount(); }); }); it("keeps sign-out hidden outside authenticated deployment mode", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); await act(async () => { root.render( , ); }); await flushReact(); expect(document.body.textContent).not.toContain("Sign out"); await act(async () => { root.unmount(); }); }); it("shows the short commit sha instead of a version for source builds", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); await act(async () => { root.render( , ); }); await flushReact(); expect(document.body.textContent).toContain("feature/source-build-labelPaperclip 518fc71"); expect(document.body.textContent).not.toContain("2026.626.0+58.git.518fc71ce"); expect(document.body.querySelector('a[href="https://github.com/paperclipai/paperclip/tree/feature%2Fsource-build-label"]')?.textContent).toBe( "feature/source-build-label", ); expect(document.body.querySelector('a[href="https://github.com/paperclipai/paperclip/commit/518fc71ce1234567890abcdef1234567890abcde"]')?.textContent).toBe( "518fc71", ); await act(async () => { root.unmount(); }); }); });