// @vitest-environment jsdom import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiError } from "@/api/client"; import { PluginBridgeContext, usePluginData, type PluginBridgeContextValue } from "./bridge"; const mockPluginsApi = vi.hoisted(() => ({ bridgeGetData: vi.fn() })); vi.mock("@/api/plugins", () => ({ pluginsApi: mockPluginsApi })); // The hook shares the app-wide retry policy; fail fast here so a transient // error reaches the keep-or-clear decision without a 30-second backoff. vi.mock("@/lib/query-client", () => ({ shouldRetryRequest: () => false, retryDelayFor: () => 0 })); // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; const unavailable = () => new ApiError("Paperclip is restarting.", 503, { error: "tenant_app_unavailable" }); const bridge: PluginBridgeContextValue = { pluginId: "files", hostContext: { companyId: "company-1", companyPrefix: null, projectId: null, entityId: null, entityType: null, userId: null, renderEnvironment: null, }, }; type Snapshot = { data: unknown; loading: boolean; error: string | null }; describe("usePluginData", () => { let container: HTMLDivElement; let root: Root; let snapshots: Snapshot[]; function Consumer({ path }: { path: string }) { const { data, loading, error } = usePluginData<{ content: string }>("file-content", { path }); snapshots.push({ data, loading, error: error?.code ?? null }); return null; } function render(path: string) { act(() => { root.render( , ); }); } const last = () => snapshots[snapshots.length - 1]!; const settle = async () => { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0)); }); }; beforeEach(() => { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); snapshots = []; mockPluginsApi.bridgeGetData.mockReset(); }); afterEach(() => { act(() => root.unmount()); container.remove(); }); it("does not show the previous request's data when a new request fails transiently", async () => { mockPluginsApi.bridgeGetData.mockResolvedValueOnce({ data: { content: "alpha" } }); render("a.txt"); await settle(); expect(last().data).toEqual({ content: "alpha" }); mockPluginsApi.bridgeGetData.mockRejectedValueOnce(unavailable()); render("b.txt"); await settle(); expect(mockPluginsApi.bridgeGetData).toHaveBeenLastCalledWith( "files", "file-content", { path: "b.txt" }, "company-1", null, ); expect(last().loading).toBe(false); expect(last().data).toBeNull(); expect(last().error).not.toBeNull(); }); it("keeps data through a transient failure of the same request", async () => { mockPluginsApi.bridgeGetData.mockResolvedValueOnce({ data: { content: "alpha" } }); let refresh!: () => void; function Refreshing() { const result = usePluginData<{ content: string }>("file-content", { path: "a.txt" }); refresh = result.refresh; snapshots.push({ data: result.data, loading: result.loading, error: result.error?.code ?? null }); return null; } act(() => { root.render( , ); }); await settle(); expect(last().data).toEqual({ content: "alpha" }); mockPluginsApi.bridgeGetData.mockRejectedValueOnce(unavailable()); act(() => refresh()); await settle(); expect(mockPluginsApi.bridgeGetData).toHaveBeenCalledTimes(2); expect(last()).toEqual({ data: { content: "alpha" }, loading: false, error: null }); }); });