// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { ApiError } from "@/api/client";
import type { PluginUiContribution } from "@/api/plugins";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
import { PluginLauncherOutlet, PluginLauncherProvider, usePluginLaunchers } from "./launchers";
import { _resetPluginModuleLoader, PluginSlotOutlet, registerPluginReactComponent, usePluginSlots } from "./slots";
vi.mock("@/context/CompanyContext", () => ({ useCompany: () => ({ selectedCompany: null }) }));
const contribution: PluginUiContribution = {
pluginId: "fixture", pluginKey: "fixture.sidebar", displayName: "Fixture", version: "1",
uiEntryFile: "index.js",
slots: [
{ type: "sidebar", id: "nav", displayName: "Nav", exportName: "SidebarItem" },
{ type: "sidebarPanel", id: "panel", displayName: "Panel", exportName: "SidebarPanel" },
],
launchers: [{
id: "open", displayName: "Fixture launcher", placementZone: "sidebar",
action: { type: "navigate", target: "/fixture" },
}],
};
const context = { companyId: "company-1", companyPrefix: "PAP" };
let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
function HookProbe() {
const slots = usePluginSlots({ slotTypes: ["sidebar"], companyId: context.companyId });
const launchers = usePluginLaunchers({ placementZones: ["sidebar"], companyId: context.companyId });
return (
{`slots:${slots.isLoading ? "loading" : "settled"}:${slots.errorMessage ?? "-"}|launchers:${launchers.isLoading ? "loading" : "settled"}:${launchers.errorMessage ?? "-"}`}
);
}
async function render() {
await act(async () => root.render(
,
));
}
const probeText = () => container.querySelector('[data-testid="probe"]')?.textContent ?? "";
beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
// Module and session fetches stay pending; components are registered directly.
vi.stubGlobal("__paperclipPluginBridge__", {});
vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {})));
client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
container = document.createElement("div"); document.body.append(container); root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount()); container.remove(); client.clear(); _resetPluginModuleLoader();
vi.unstubAllGlobals(); vi.restoreAllMocks();
});
it("collapses silently and stays loading when contributions cannot be fetched during an outage", async () => {
vi.spyOn(pluginsApi, "listUiContributions").mockRejectedValue(new TypeError("Failed to fetch"));
await render();
await vi.waitFor(() => expect(client.getQueryState(queryKeys.plugins.uiContributions)?.status).toBe("error"));
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
expect(container.textContent).not.toContain("unavailable");
expect(container.textContent).not.toContain("Failed to fetch");
expect(container.querySelector(".text-destructive")).toBeNull();
// Nothing has loaded yet: the hooks report loading, not an error, so pages show a placeholder.
expect(probeText()).toBe("slots:loading:-|launchers:loading:-");
});
it("reports a readable message, never a raw code, for a real failure", async () => {
vi.spyOn(pluginsApi, "listUiContributions").mockRejectedValue(new ApiError("access_denied", 403, { error: "access_denied" }));
await render();
await vi.waitFor(() => expect(client.getQueryState(queryKeys.plugins.uiContributions)?.status).toBe("error"));
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
expect(container.textContent).not.toContain("access_denied");
expect(container.querySelector(".text-destructive")).toBeNull();
expect(probeText()).toContain("slots:settled:You don’t have permission to do that.");
expect(probeText()).toContain("launchers:settled:You don’t have permission to do that.");
});
it("keeps the last loaded slots and launchers when a refetch fails", async () => {
vi.spyOn(pluginsApi, "listUiContributions").mockRejectedValue(new ApiError("tenant_app_unavailable", 503, { error: "tenant_app_unavailable" }));
registerPluginReactComponent("fixture.sidebar", "SidebarItem", () => Fixture sidebar item);
registerPluginReactComponent("fixture.sidebar", "SidebarPanel", () => Fixture sidebar panel);
client.setQueryData(queryKeys.plugins.uiContributions, [contribution]);
await render();
expect(container.textContent).toContain("Fixture sidebar item");
expect(container.textContent).toContain("Fixture sidebar panel");
await act(async () => {
await client.refetchQueries({ queryKey: queryKeys.plugins.uiContributions });
// React Query notifies observers on a timer; let the error render land.
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(client.getQueryState(queryKeys.plugins.uiContributions)?.status).toBe("error");
expect(container.textContent).not.toContain("unavailable");
expect(container.textContent).toContain("Fixture sidebar item");
expect(container.textContent).toContain("Fixture sidebar panel");
expect(container.textContent).toContain("Fixture launcher");
// The slots probe stays "loading" only because the fixture module fetch never settles.
expect(probeText()).toMatch(/^slots:(?:loading|settled):-\|launchers:settled:-$/);
});