import { afterEach, describe, it, expect, vi, beforeEach } from "vitest"; import { act, cleanup, renderHook, waitFor } from "@testing-library/react"; const mockListWorkflows = vi.fn(); const mockSetWorkflows = vi.fn(); const mockSetWorkspaceContextRead = vi.fn(); const mockRequestWorkspaceContextRefresh = vi.fn(); type WorkspaceContextReadResult = | "pending" | "success" | "transient" | "access_denied" | "not_found" | "invalid" | "cancelled" | "unknown"; type MockWorkspaceContextRead = { workspaceId: string | null; generation: number; pending: { workflows: boolean; repositories: boolean; steps: boolean }; errors: Record<"workflows" | "repositories" | "steps", WorkspaceContextReadResult | null>; retryAfterMs: Record<"workflows" | "repositories" | "steps", number | null>; retryVersion: number; retryCycle: number; requestIds?: { workflows: string | null; repositories: string | null; steps: string | null }; snapshotPending?: boolean; snapshotError?: WorkspaceContextReadResult | null; snapshotRetryAfterMs?: number | null; snapshotRequestId?: string | null; }; type WorkspaceContextReadArgs = [ collection: "workflows" | "repositories" | "steps", workspaceId: string, generation: number, result: WorkspaceContextReadResult, retryAfterMs?: number, requestId?: string, ]; type MockState = { workflows: { items: Array<{ id: string; workspaceId: string; name: string }> }; workspaces: { activeId: string | null }; workspaceContextGeneration: number; setWorkflows: typeof mockSetWorkflows; workspaceContextRead?: MockWorkspaceContextRead; setWorkspaceContextRead?: typeof mockSetWorkspaceContextRead; requestWorkspaceContextRefresh?: typeof mockRequestWorkspaceContextRefresh; }; let mockState: MockState = { workflows: { items: [] }, workspaces: { activeId: null }, workspaceContextGeneration: 0, setWorkflows: mockSetWorkflows, }; vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (s: MockState) => unknown) => selector(mockState), useAppStoreApi: () => ({ getState: () => mockState }), })); vi.mock("@/lib/api", () => ({ listWorkflows: (...args: unknown[]) => mockListWorkflows(...args), })); import { useWorkflows, useEnsureWorkspaceWorkflows } from "./use-workflows"; function makeWorkflow(id: string, workspaceId: string) { return { id, workspace_id: workspaceId, name: id, description: null, prompt: undefined as string | undefined, sort_order: 0, agent_profile_id: null, hidden: false, style: null, }; } function setVisibility(value: DocumentVisibilityState) { Object.defineProperty(document, "visibilityState", { configurable: true, value }); } describe("useWorkflows — stale response guard", () => { beforeEach(() => { vi.clearAllMocks(); mockState = { workflows: { items: [] }, workspaces: { activeId: "ws-A" }, workspaceContextGeneration: 0, setWorkflows: mockSetWorkflows, }; }); it("discards an A response that resolves after reset activates B but before rerender", async () => { let resolveA: (value: { workflows: ReturnType[] }) => void = () => {}; mockState.workspaces.activeId = "ws-A"; mockListWorkflows.mockImplementationOnce( () => new Promise((resolve) => { resolveA = resolve; }), ); renderHook(() => useWorkflows("ws-A", true)); await waitFor(() => expect(mockListWorkflows).toHaveBeenCalledWith("ws-A", expect.anything())); mockState = { ...mockState, workspaces: { activeId: "ws-B" }, workspaceContextGeneration: 1, }; resolveA({ workflows: [makeWorkflow("wf-A", "ws-A")] }); for (let i = 0; i < 5; i++) await Promise.resolve(); expect(mockSetWorkflows).not.toHaveBeenCalled(); }); it("discards a stale in-flight response when the workspace switches mid-fetch", async () => { let resolveStale: (v: unknown) => void = () => {}; mockListWorkflows.mockImplementationOnce( () => new Promise((res) => { resolveStale = res; }), ); const { rerender } = renderHook( ({ workspaceId }: { workspaceId: string | null }) => useWorkflows(workspaceId, true), { initialProps: { workspaceId: "ws-A" } }, ); await waitFor(() => expect(mockListWorkflows).toHaveBeenCalledWith("ws-A", expect.anything())); // Switch to workspace B before A's fetch resolves; B resolves first. mockState = { ...mockState, workspaces: { activeId: "ws-B" }, workspaceContextGeneration: 1, }; mockListWorkflows.mockResolvedValueOnce({ workflows: [makeWorkflow("wf-B", "ws-B")] }); rerender({ workspaceId: "ws-B" }); await waitFor(() => expect(mockSetWorkflows).toHaveBeenCalledWith([expect.objectContaining({ id: "wf-B" })]), ); // Now let A resolve. It must NOT overwrite the store with A's workflows. resolveStale({ workflows: [makeWorkflow("wf-A", "ws-A")] }); for (let i = 0; i < 5; i++) await Promise.resolve(); const written = mockSetWorkflows.mock.calls.map((call) => call[0]); const wroteA = written.some( (list: Array<{ id: string }>) => list.length > 0 && list.some((w) => w.id === "wf-A"), ); expect(wroteA).toBe(false); }); it("does not clear workflows when a stale fetch fails after the workspace switched", async () => { let rejectStale: (e: Error) => void = () => {}; mockListWorkflows.mockImplementationOnce( () => new Promise((_res, rej) => { rejectStale = rej; }), ); const { rerender } = renderHook( ({ workspaceId }: { workspaceId: string | null }) => useWorkflows(workspaceId, true), { initialProps: { workspaceId: "ws-A" } }, ); await waitFor(() => expect(mockListWorkflows).toHaveBeenCalledWith("ws-A", expect.anything())); // Switch to workspace B; B resolves with data. mockState = { ...mockState, workspaces: { activeId: "ws-B" }, workspaceContextGeneration: 1, }; mockListWorkflows.mockResolvedValueOnce({ workflows: [makeWorkflow("wf-B", "ws-B")] }); rerender({ workspaceId: "ws-B" }); await waitFor(() => expect(mockSetWorkflows).toHaveBeenCalledWith([expect.objectContaining({ id: "wf-B" })]), ); // A's fetch fails after B already succeeded. Catch must NOT wipe the store. rejectStale(new Error("network")); for (let i = 0; i < 5; i++) await Promise.resolve(); const cleared = mockSetWorkflows.mock.calls.some( (call) => Array.isArray(call[0]) && call[0].length === 0, ); expect(cleared).toBe(false); }); it("does not clear hydrated workflows when the fetch for the current workspace fails", async () => { // The sidebar mounts on every route (task detail, settings, ...) and boot // hydrates `state.workflows.items` before the sidebar's refresh fetch // fires. If that fetch flakes, blowing the store away leaves the sidebar, // board, and kanban scoping with no workflow IDs until another success. mockListWorkflows.mockRejectedValueOnce(new Error("network")); renderHook(() => useWorkflows("ws-A", true)); await waitFor(() => expect(mockListWorkflows).toHaveBeenCalledWith("ws-A", expect.anything())); for (let i = 0; i < 5; i++) await Promise.resolve(); expect(mockSetWorkflows).not.toHaveBeenCalled(); }); }); describe("useWorkflows — explicit workspace selection", () => { beforeEach(() => { vi.clearAllMocks(); mockState = { workflows: { items: [] }, workspaces: { activeId: "ws-A" }, workspaceContextGeneration: 0, setWorkflows: mockSetWorkflows, }; }); it("loads workflows when the selected workspace is not globally active", async () => { mockListWorkflows.mockResolvedValueOnce({ workflows: [makeWorkflow("wf-B", "ws-B")] }); renderHook(() => useWorkflows("ws-B", true)); await waitFor(() => expect(mockSetWorkflows).toHaveBeenCalledWith([ expect.objectContaining({ id: "wf-B", workspaceId: "ws-B" }), ]), ); }); it("maps the workflow's prompt into the store item", async () => { mockListWorkflows.mockResolvedValueOnce({ workflows: [{ ...makeWorkflow("wf-B", "ws-B"), prompt: "Do the thing" }], }); renderHook(() => useWorkflows("ws-B", true)); await waitFor(() => expect(mockSetWorkflows).toHaveBeenCalledWith([ expect.objectContaining({ id: "wf-B", prompt: "Do the thing" }), ]), ); }); }); // eslint-disable-next-line max-lines-per-function -- related workspace recovery cases share one fixture describe("useEnsureWorkspaceWorkflows", () => { beforeEach(() => { vi.clearAllMocks(); mockSetWorkspaceContextRead.mockReset(); mockRequestWorkspaceContextRefresh.mockReset(); mockListWorkflows.mockResolvedValue({ workflows: [] }); mockState = { workflows: { items: [] }, workspaces: { activeId: "ws-A" }, workspaceContextGeneration: 0, setWorkflows: mockSetWorkflows, }; }); it("fetches workflows for the store's active workspace on mount", async () => { renderHook(() => useEnsureWorkspaceWorkflows()); await waitFor(() => expect(mockListWorkflows).toHaveBeenCalledWith("ws-A", expect.anything())); }); it("re-fetches when the store's active workspace changes", async () => { const { rerender } = renderHook(() => useEnsureWorkspaceWorkflows()); await waitFor(() => expect(mockListWorkflows).toHaveBeenCalledWith("ws-A", expect.anything())); mockState = { ...mockState, workspaces: { activeId: "ws-B" } }; rerender(); await waitFor(() => expect(mockListWorkflows).toHaveBeenCalledWith("ws-B", expect.anything())); }); it("bounds scheduled retry attempts and does not restart the cycle for timer refreshes", async () => { vi.useFakeTimers(); const contextRead: MockWorkspaceContextRead = { workspaceId: "ws-A", generation: 0, pending: { workflows: false, repositories: false, steps: false }, errors: { workflows: "transient", repositories: null, steps: null }, retryAfterMs: { workflows: null, repositories: null, steps: null }, retryVersion: 0, retryCycle: 0, }; mockSetWorkspaceContextRead.mockImplementation((...args: unknown[]) => { const [collection, workspaceId, generation, result, retryAfterMs, requestId] = args as WorkspaceContextReadArgs; if (!mockState.workspaceContextRead) return; mockState = { ...mockState, workspaceContextRead: { ...mockState.workspaceContextRead, workspaceId, generation, pending: { ...mockState.workspaceContextRead.pending, [collection]: result === "pending", }, errors: { ...mockState.workspaceContextRead.errors, [collection]: result === "pending" || result === "success" ? null : result, }, retryAfterMs: { ...mockState.workspaceContextRead.retryAfterMs, [collection]: result === "transient" ? (retryAfterMs ?? null) : null, }, requestIds: { workflows: null, repositories: null, steps: null, ...mockState.workspaceContextRead.requestIds, [collection]: result === "pending" ? (requestId ?? null) : null, }, }, }; }); mockRequestWorkspaceContextRefresh.mockImplementation((resetRetryCycle = true) => { if (!mockState.workspaceContextRead) return; mockState = { ...mockState, workspaceContextRead: { ...mockState.workspaceContextRead, retryVersion: mockState.workspaceContextRead.retryVersion + 1, retryCycle: mockState.workspaceContextRead.retryCycle + (resetRetryCycle ? 1 : 0), }, }; }); mockState = { ...mockState, workspaceContextRead: contextRead, setWorkspaceContextRead: mockSetWorkspaceContextRead, requestWorkspaceContextRefresh: mockRequestWorkspaceContextRefresh, }; mockListWorkflows.mockRejectedValue(new Error("offline")); const { rerender } = renderHook(() => useEnsureWorkspaceWorkflows()); await act(async () => { for (let i = 0; i < 5; i += 1) await Promise.resolve(); }); expect(mockListWorkflows).toHaveBeenCalledTimes(1); await act(async () => { vi.advanceTimersByTime(2_000); }); expect(mockRequestWorkspaceContextRefresh).toHaveBeenCalledWith(false); rerender(); await act(async () => { await Promise.resolve(); }); await act(async () => { vi.advanceTimersByTime(5_000); }); expect(mockRequestWorkspaceContextRefresh).toHaveBeenCalledTimes(2); rerender(); await act(async () => { await Promise.resolve(); }); await act(async () => { vi.advanceTimersByTime(10_000); }); expect(mockRequestWorkspaceContextRefresh).toHaveBeenCalledTimes(2); }); it("cancels a scheduled retry when the workflow loader unmounts", async () => { vi.useFakeTimers(); mockState = { ...mockState, workspaceContextRead: { workspaceId: "ws-A", generation: 0, pending: { workflows: false, repositories: false, steps: false }, errors: { workflows: "transient", repositories: null, steps: null }, retryAfterMs: { workflows: null, repositories: null, steps: null }, retryVersion: 0, retryCycle: 0, }, setWorkspaceContextRead: mockSetWorkspaceContextRead, requestWorkspaceContextRefresh: mockRequestWorkspaceContextRefresh, }; mockListWorkflows.mockImplementation(() => new Promise(() => {})); const { unmount } = renderHook(() => useEnsureWorkspaceWorkflows()); unmount(); await act(async () => { vi.advanceTimersByTime(10_000); }); expect(mockRequestWorkspaceContextRefresh).not.toHaveBeenCalled(); }); it("defers recovery while hidden and starts one bounded cycle on foreground", async () => { vi.useFakeTimers(); setVisibility("hidden"); mockState = { ...mockState, workspaceContextRead: { workspaceId: "ws-A", generation: 0, pending: { workflows: false, repositories: false, steps: false }, errors: { workflows: "transient", repositories: null, steps: null }, retryAfterMs: { workflows: null, repositories: null, steps: null }, retryVersion: 0, retryCycle: 0, }, setWorkspaceContextRead: mockSetWorkspaceContextRead, requestWorkspaceContextRefresh: mockRequestWorkspaceContextRefresh, }; mockRequestWorkspaceContextRefresh.mockImplementation(() => { if (!mockState.workspaceContextRead) return; mockState = { ...mockState, workspaceContextRead: { ...mockState.workspaceContextRead, pending: { ...mockState.workspaceContextRead.pending, workflows: true }, requestIds: { workflows: "foreground", repositories: null, steps: null }, retryVersion: mockState.workspaceContextRead.retryVersion + 1, }, }; }); mockListWorkflows.mockRejectedValue(new Error("offline")); const { rerender } = renderHook(() => useEnsureWorkspaceWorkflows()); await act(async () => { await Promise.resolve(); vi.advanceTimersByTime(10_000); }); expect(mockRequestWorkspaceContextRefresh).not.toHaveBeenCalled(); setVisibility("visible"); act(() => document.dispatchEvent(new Event("visibilitychange"))); expect(mockRequestWorkspaceContextRefresh).toHaveBeenCalledWith(); rerender(); await act(async () => { vi.advanceTimersByTime(2_000); }); expect(mockRequestWorkspaceContextRefresh).toHaveBeenCalledTimes(1); }); it("does not let an unowned pending collection block recovery", async () => { vi.useFakeTimers(); setVisibility("visible"); mockState = { ...mockState, workspaceContextRead: { workspaceId: "ws-A", generation: 0, pending: { workflows: false, repositories: true, steps: false }, errors: { workflows: "transient", repositories: null, steps: null }, retryAfterMs: { workflows: null, repositories: null, steps: null }, requestIds: { workflows: null, repositories: null, steps: null }, retryVersion: 0, retryCycle: 0, }, setWorkspaceContextRead: mockSetWorkspaceContextRead, requestWorkspaceContextRefresh: mockRequestWorkspaceContextRefresh, }; mockListWorkflows.mockRejectedValue(new Error("offline")); const { rerender } = renderHook(() => useEnsureWorkspaceWorkflows()); await act(async () => { await Promise.resolve(); }); await act(async () => { vi.advanceTimersByTime(2_000); }); expect(mockRequestWorkspaceContextRefresh).toHaveBeenCalledWith(false); rerender(); }); it("schedules bounded recovery for a failed workflow snapshot", async () => { vi.useFakeTimers(); setVisibility("visible"); mockState = { ...mockState, workspaceContextRead: { workspaceId: "ws-A", generation: 0, pending: { workflows: false, repositories: false, steps: false }, errors: { workflows: null, repositories: null, steps: null }, retryAfterMs: { workflows: null, repositories: null, steps: null }, snapshotPending: false, snapshotError: "transient", snapshotRetryAfterMs: null, retryVersion: 0, retryCycle: 0, }, setWorkspaceContextRead: mockSetWorkspaceContextRead, requestWorkspaceContextRefresh: mockRequestWorkspaceContextRefresh, }; renderHook(() => useEnsureWorkspaceWorkflows()); await act(async () => { vi.advanceTimersByTime(2_000); }); expect(mockRequestWorkspaceContextRefresh).toHaveBeenCalledWith(false); }); it("does not spend a retry attempt when a timer is replaced before dispatch", async () => { vi.useFakeTimers(); setVisibility("visible"); const contextRead: MockWorkspaceContextRead = { workspaceId: "ws-A", generation: 0, pending: { workflows: false, repositories: false, steps: false }, errors: { workflows: "transient", repositories: null, steps: null }, retryAfterMs: { workflows: null, repositories: null, steps: null }, retryVersion: 0, retryCycle: 0, }; mockState = { ...mockState, workspaceContextRead: contextRead, setWorkspaceContextRead: mockSetWorkspaceContextRead, requestWorkspaceContextRefresh: mockRequestWorkspaceContextRefresh, }; mockListWorkflows.mockImplementation(() => new Promise(() => {})); const { rerender } = renderHook(() => useEnsureWorkspaceWorkflows()); mockState = { ...mockState, workspaceContextRead: { ...contextRead, requestIds: { workflows: null, repositories: null, steps: null }, retryCycle: contextRead.retryCycle + 1, }, }; rerender(); await act(async () => { vi.advanceTimersByTime(2_000); }); expect(mockRequestWorkspaceContextRefresh).toHaveBeenCalledWith(false); }); }); afterEach(() => { cleanup(); vi.useRealTimers(); });