import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { createElement } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { IntegrationsMenu, IntegrationsTopbarLinks, MobileIntegrationsSection, } from "./integrations-menu"; import { pluginRegistry } from "@/lib/plugins/registry"; import type { NavItem } from "@/lib/plugins/types"; import type { GitHubStatus } from "@/lib/types/github"; import { TooltipProvider } from "@kandev/ui/tooltip"; const useGitHubStatusMock = vi.hoisted(() => vi.fn()); const useGitLabAvailableMock = vi.hoisted(() => vi.fn()); const useJiraAuthedMock = vi.hoisted(() => vi.fn()); const useLinearAuthedMock = vi.hoisted(() => vi.fn()); const activeWorkspaceRef = vi.hoisted(() => ({ id: null as string | null, items: [] as Array<{ id: string }>, })); vi.mock("@/hooks/domains/github/use-github-status", () => ({ useGitHubStatus: useGitHubStatusMock, })); vi.mock("@/hooks/domains/gitlab/use-task-mr", () => ({ useGitLabAvailable: useGitLabAvailableMock, })); vi.mock("@/hooks/domains/jira/use-jira-availability", () => ({ useJiraAuthed: useJiraAuthedMock, })); vi.mock("@/hooks/domains/linear/use-linear-availability", () => ({ useLinearAuthed: useLinearAuthedMock, })); // useNavAvailability now also folds in each integration's per-workspace // enabled toggle plus the "hide disabled" nav setting. This suite never // exercises that decoupling (see hooks/use-nav-availability.test.ts for // that) — every enabled hook here defaults to `true` and the hide-disabled // setting to `false`, reproducing the pre-existing "enabled && authed" // visibility these tests already assert on. function enabledStub() { return { enabled: true, setEnabled: () => {}, loaded: true }; } vi.mock("@/hooks/domains/azure-devops/use-azure-devops-enabled", () => ({ useAzureDevOpsEnabled: enabledStub, })); vi.mock("@/hooks/domains/github/use-github-enabled", () => ({ useGitHubEnabled: enabledStub, })); vi.mock("@/hooks/domains/gitlab/use-gitlab-enabled", () => ({ useGitLabEnabled: enabledStub, })); vi.mock("@/hooks/domains/jira/use-jira-enabled", () => ({ useJiraEnabled: enabledStub, })); vi.mock("@/hooks/domains/linear/use-linear-enabled", () => ({ useLinearEnabled: enabledStub, })); vi.mock("@/hooks/domains/integrations/use-hide-disabled-integrations-in-nav", () => ({ useHideDisabledIntegrationsInNav: () => ({ hideDisabled: false, setHideDisabled: () => {} }), })); vi.mock("@/components/state-provider", () => ({ useAppStore: ( selector: (state: { workspaces: { activeId: string | null; items: Array<{ id: string }> }; // The navigation manifest resolves hrefs against the active mode, so // `useInOffice` (and through it `useFeature`) now runs in this tree. features: Record; }) => unknown, ) => selector({ workspaces: { activeId: activeWorkspaceRef.id, items: activeWorkspaceRef.items }, features: { office: false }, }), })); function status(overrides: Partial): GitHubStatus { return { authenticated: false, username: "", auth_method: "none", token_configured: false, required_scopes: [], ...overrides, }; } function mockAvailability({ githubReady, gitlabReady = false, jiraConfigured, linearConfigured, }: { githubReady: boolean; gitlabReady?: boolean; jiraConfigured: boolean; linearConfigured: boolean; }) { useGitHubStatusMock.mockReturnValue({ status: githubReady ? status({ token_configured: true }) : status({}), loading: false, }); useGitLabAvailableMock.mockReturnValue(gitlabReady); useJiraAuthedMock.mockReturnValue(jiraConfigured); useLinearAuthedMock.mockReturnValue(linearConfigured); } afterEach(() => { cleanup(); vi.clearAllMocks(); activeWorkspaceRef.id = null; activeWorkspaceRef.items = []; }); // `getGitHubIntegrationStatus` now lives in `hooks/use-nav-availability` with the // rest of the manifest's availability gating, and the link table moved into // `lib/navigation/`. Their unit coverage moved with them — see // `hooks/use-nav-availability.test.ts` and `lib/navigation/*.test.ts`. describe("IntegrationsMenu", () => { it("opens configured integration links on hover", async () => { mockAvailability({ githubReady: true, jiraConfigured: true, linearConfigured: false }); render(createElement(IntegrationsMenu, {})); const trigger = screen.getByRole("button", { name: "Integrations" }); expect(screen.queryByText("GitHub")).toBeNull(); fireEvent.pointerEnter(trigger); expect(await screen.findByText("GitHub")).toBeTruthy(); expect(screen.getByText("Jira")).toBeTruthy(); expect(screen.queryByText("Linear")).toBeNull(); }); it("does not render when no integrations are configured", () => { mockAvailability({ githubReady: false, jiraConfigured: false, linearConfigured: false }); render(createElement(IntegrationsMenu, {})); expect(screen.queryByRole("button", { name: "Integrations" })).toBeNull(); }); it("passes the active workspace id to the per-workspace availability hooks", () => { activeWorkspaceRef.id = "ws-active"; activeWorkspaceRef.items = [{ id: "ws-active" }]; mockAvailability({ githubReady: true, jiraConfigured: true, linearConfigured: true }); render(createElement(IntegrationsMenu, {})); // Jira and Linear are per-workspace: they must be scoped to the active // workspace so the sidebar reflects the workspace the user is viewing. expect(useJiraAuthedMock).toHaveBeenCalledWith("ws-active"); expect(useLinearAuthedMock).toHaveBeenCalledWith("ws-active"); }); it("falls back to null scope when the active workspace id is stale", () => { // The active workspace was removed but activeId was not reconciled. Scoping // to the deleted id would hide the links; fall back to null instead so the // backend's default-workspace resolution applies. activeWorkspaceRef.id = "ws-deleted"; activeWorkspaceRef.items = [{ id: "ws-remaining" }]; mockAvailability({ githubReady: false, jiraConfigured: true, linearConfigured: true }); render(createElement(IntegrationsMenu, {})); expect(useJiraAuthedMock).toHaveBeenCalledWith(null); expect(useLinearAuthedMock).toHaveBeenCalledWith(null); }); }); function renderWithTooltip(component: Parameters[0]) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- TooltipProvider requires children in its type but createElement passes them as 3rd arg return render(createElement(TooltipProvider, {} as any, component)); } describe("IntegrationsTopbarLinks", () => { it("renders an icon link for each configured integration", () => { mockAvailability({ githubReady: true, jiraConfigured: false, linearConfigured: true }); renderWithTooltip(createElement(IntegrationsTopbarLinks, {})); const githubLink = screen.getByRole("link", { name: "GitHub" }); const linearLink = screen.getByRole("link", { name: "Linear" }); expect(githubLink.getAttribute("href")).toBe("/github"); expect(linearLink.getAttribute("href")).toBe("/linear"); expect(screen.queryByRole("link", { name: "Jira" })).toBeNull(); }); it("renders nothing when no integrations are configured", () => { mockAvailability({ githubReady: false, jiraConfigured: false, linearConfigured: false }); const { container } = renderWithTooltip(createElement(IntegrationsTopbarLinks, {})); expect(container.firstChild).toBeNull(); }); }); describe("MobileIntegrationsSection", () => { const HELLO_LABEL = "Hello Integration"; const HELLO_PATH = "/plugins/hello"; // Track every plugin registered through this suite so the singleton // pluginRegistry is fully cleared after each test — a new test adding a new // plugin id can't leak nav items into the ones that follow. const registeredPluginIds: string[] = []; function registerNavItem(pluginId: string, item: NavItem) { registeredPluginIds.push(pluginId); pluginRegistry.forPlugin(pluginId).registerNavItem(item); } function registerHelloIntegrationItem() { registerNavItem("plugin-a", { id: "hello", label: HELLO_LABEL, path: HELLO_PATH, section: "integrations", }); } afterEach(() => { for (const id of registeredPluginIds) pluginRegistry.unregisterPlugin(id); registeredPluginIds.length = 0; }); function renderMobileSection(onNavigate = vi.fn()) { return { onNavigate, ...render(createElement(MobileIntegrationsSection, { onNavigate })), }; } it("renders a touch row for each configured first-party link and closes the sheet on click", () => { mockAvailability({ githubReady: true, jiraConfigured: false, linearConfigured: true }); const { onNavigate } = renderMobileSection(); const githubLink = screen.getByRole("link", { name: "GitHub" }); expect(githubLink.getAttribute("href")).toBe("/github"); expect(screen.getByRole("link", { name: "Linear" }).getAttribute("href")).toBe("/linear"); expect(screen.queryByRole("link", { name: "Jira" })).toBeNull(); fireEvent.click(githubLink); expect(onNavigate).toHaveBeenCalledTimes(1); }); it("renders plugin nav items targeting the integrations section, gated on the plugins flag", () => { mockAvailability({ githubReady: false, jiraConfigured: false, linearConfigured: false }); registerHelloIntegrationItem(); registerNavItem("plugin-b", { id: "main-item", label: "Main Item", path: "/plugins/main", section: "main", }); renderMobileSection(); const pluginLink = screen.getByTestId("plugin-nav-item-hello"); expect(pluginLink.getAttribute("href")).toBe(HELLO_PATH); expect(screen.getByText(HELLO_LABEL)).toBeTruthy(); // A "main" section plugin item belongs to the top-level nav, not here. expect(screen.queryByTestId("plugin-nav-item-main-item")).toBeNull(); }); it("renders when only plugin items exist and no first-party links are configured", () => { mockAvailability({ githubReady: false, jiraConfigured: false, linearConfigured: false }); registerHelloIntegrationItem(); renderMobileSection(); expect(screen.getByText("Integrations")).toBeTruthy(); expect(screen.getByTestId("plugin-nav-item-hello")).toBeTruthy(); }); it("renders nothing when there are no links and no plugin items", () => { mockAvailability({ githubReady: false, jiraConfigured: false, linearConfigured: false }); const { container } = renderMobileSection(); expect(container.firstChild).toBeNull(); }); });