import { beforeEach, describe, expect, it, vi } from "vitest"; const { pollMock } = vi.hoisted(() => ({ pollMock: vi.fn(), })); vi.mock("@/actions/task/poll", () => ({ pollTaskUntilSettled: pollMock, })); import { registerTaskKindHandler, resumePendingTasks, TASK_WATCHER_STATUS, trackAndPollTask, useTaskWatcherStore, } from "./store"; const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); describe("task watcher store", () => { const onReady = vi.fn(); const onError = vi.fn(); beforeEach(() => { window.dispatchEvent(new PageTransitionEvent("pageshow")); vi.clearAllMocks(); localStorage.clear(); Object.defineProperty(navigator, "locks", { configurable: true, value: undefined, }); useTaskWatcherStore.setState({ tasks: {} }); registerTaskKindHandler("test-kind", { onReady, onError }); }); it.each(["beforeunload", "pagehide"])( "preserves pending work when %s aborts its RPC and resumes on return", async (eventType) => { let rejectPoll!: (error: Error) => void; pollMock.mockImplementationOnce( () => new Promise((_resolve, reject) => { rejectPoll = reject; }), ); const tracking = trackAndPollTask({ taskId: "reload-task", kind: "test-kind", meta: {}, }); await vi.waitFor(() => expect(pollMock).toHaveBeenCalledOnce()); window.dispatchEvent(new Event(eventType)); rejectPoll(new Error("The document was unloaded")); if (eventType === "beforeunload") { window.dispatchEvent(new PageTransitionEvent("pagehide")); } expect(await tracking).toEqual({ status: TASK_WATCHER_STATUS.PENDING }); expect(useTaskWatcherStore.getState().tasks["reload-task"]?.status).toBe( TASK_WATCHER_STATUS.PENDING, ); expect(onError).not.toHaveBeenCalled(); if (eventType === "pagehide") { await flush(); expect(pollMock).toHaveBeenCalledOnce(); expect(onReady).not.toHaveBeenCalled(); } pollMock.mockResolvedValue({ ok: true, state: "completed" }); window.dispatchEvent( new PageTransitionEvent("pageshow", { persisted: true }), ); await vi.waitFor(() => expect(onReady).toHaveBeenCalledOnce()); }, ); it("keeps the caller's promise and notifications when beforeunload does not hide the page", async () => { // Given: a download or cancelled navigation leaves this document alive. let rejectPoll!: (error: Error) => void; pollMock .mockImplementationOnce( () => new Promise((_resolve, reject) => { rejectPoll = reject; }), ) .mockResolvedValue({ ok: true, state: "completed" }); const tracking = trackAndPollTask({ taskId: "download-task", kind: "test-kind", meta: {}, notifyHandler: false, }); await vi.waitFor(() => expect(pollMock).toHaveBeenCalledOnce()); // When: only the RPC is interrupted; the caller remains in this document. window.dispatchEvent(new Event("beforeunload")); rejectPoll(new Error("The navigation interrupted the request")); // Then: the original caller receives the final result and owns notification. expect(await tracking).toEqual({ status: TASK_WATCHER_STATUS.READY }); expect(onReady).not.toHaveBeenCalled(); expect(onError).not.toHaveBeenCalled(); expect(useTaskWatcherStore.getState().tasks["download-task"]?.status).toBe( TASK_WATCHER_STATUS.READY, ); }); it("preserves a navigation abort delivered after the visible page has recovered", async () => { // Given let rejectPoll!: (error: Error) => void; pollMock .mockImplementationOnce( () => new Promise((_resolve, reject) => { rejectPoll = reject; }), ) .mockResolvedValue({ ok: true, state: "completed" }); const tracking = trackAndPollTask({ taskId: "delayed-abort", kind: "test-kind", meta: {}, }); await vi.waitFor(() => expect(pollMock).toHaveBeenCalledOnce()); // When: browser RPC cancellation arrives after the unload event's task. window.dispatchEvent(new Event("beforeunload")); await flush(); rejectPoll(new Error("The navigation interrupted the request")); // Then: the original caller gets the final result, not a provisional failure. expect(await tracking).toEqual({ status: TASK_WATCHER_STATUS.READY }); expect(onReady).toHaveBeenCalledOnce(); expect(onError).not.toHaveBeenCalled(); }); it("allows new tasks after a download without discarding existing results", async () => { // Given const existingResult = { taskId: "previous-export", kind: "export", status: TASK_WATCHER_STATUS.READY, meta: {}, startedAt: Date.now(), result: { downloadUrl: "/download" }, }; useTaskWatcherStore.getState().upsertTask(existingResult); pollMock.mockResolvedValue({ ok: true, state: "completed" }); // When: a download starts but leaves the document in place. window.dispatchEvent(new Event("beforeunload")); await flush(); const result = await trackAndPollTask({ taskId: "after-download", kind: "test-kind", meta: {}, }); // Then expect(result.status).toBe(TASK_WATCHER_STATUS.READY); expect(onReady).toHaveBeenCalledOnce(); expect(useTaskWatcherStore.getState().tasks["previous-export"]).toEqual( existingResult, ); }); it("keeps caller ownership when bfcache restores before its RPC rejects", async () => { // Given let rejectPoll!: (error: Error) => void; pollMock .mockImplementationOnce( () => new Promise((_resolve, reject) => { rejectPoll = reject; }), ) .mockResolvedValue({ ok: true, state: "completed" }); const tracking = trackAndPollTask({ taskId: "bfcache-task", kind: "test-kind", meta: {}, notifyHandler: false, }); await vi.waitFor(() => expect(pollMock).toHaveBeenCalledOnce()); // When: the cached document returns before its interrupted RPC rejects. window.dispatchEvent( new PageTransitionEvent("pagehide", { persisted: true }), ); window.dispatchEvent( new PageTransitionEvent("pageshow", { persisted: true }), ); rejectPoll(new Error("The cached document interrupted the request")); // Then expect(await tracking).toEqual({ status: TASK_WATCHER_STATUS.READY }); expect(onReady).not.toHaveBeenCalled(); window.dispatchEvent( new PageTransitionEvent("pageshow", { persisted: true }), ); await flush(); expect(onReady).not.toHaveBeenCalled(); expect(onError).not.toHaveBeenCalled(); expect(useTaskWatcherStore.getState().tasks["bfcache-task"]?.status).toBe( TASK_WATCHER_STATUS.READY, ); }); it("tracks a task, polls it to completion and fires onReady once", async () => { pollMock.mockResolvedValue({ ok: true, state: "completed" }); await trackAndPollTask({ taskId: "task-1", kind: "test-kind", meta: { complianceId: "csa_ccm_4.0" }, }); const task = useTaskWatcherStore.getState().tasks["task-1"]; expect(task?.status).toBe(TASK_WATCHER_STATUS.READY); expect(onReady).toHaveBeenCalledTimes(1); expect(onReady.mock.calls[0][0].meta.complianceId).toBe("csa_ccm_4.0"); expect(onError).not.toHaveBeenCalled(); }); it("returns and persists a completed task result while allowing the caller to own notifications", async () => { // Given pollMock.mockResolvedValue({ ok: true, state: "completed", result: { created_count: 1, failed_count: 1 }, }); // When const result = await trackAndPollTask<{ created_count: number; failed_count: number; }>({ taskId: "jira-task", kind: "test-kind", meta: {}, notifyHandler: false, }); // Then expect(result).toEqual({ status: TASK_WATCHER_STATUS.READY, result: { created_count: 1, failed_count: 1 }, }); expect(useTaskWatcherStore.getState().tasks["jira-task"]?.result).toEqual({ created_count: 1, failed_count: 1, }); expect(onReady).not.toHaveBeenCalled(); }); it("replaces settled results of the same kind when tracking new work", async () => { // Given pollMock.mockResolvedValue({ ok: true, state: "completed" }); useTaskWatcherStore.setState({ tasks: { "previous-task": { taskId: "previous-task", kind: "test-kind", status: TASK_WATCHER_STATUS.READY, meta: {}, startedAt: Date.now() - 1000, }, }, }); // When await trackAndPollTask({ taskId: "replacement-task", kind: "test-kind", meta: {}, }); // Then expect( useTaskWatcherStore.getState().tasks["previous-task"], ).toBeUndefined(); expect( useTaskWatcherStore.getState().tasks["replacement-task"]?.status, ).toBe(TASK_WATCHER_STATUS.READY); }); it("marks the task as error when the backend task fails", async () => { pollMock.mockResolvedValue({ ok: true, state: "failed" }); await trackAndPollTask({ taskId: "task-2", kind: "test-kind", meta: {}, }); expect(useTaskWatcherStore.getState().tasks["task-2"]).toBeUndefined(); expect(onError).toHaveBeenCalledTimes(1); expect(onReady).not.toHaveBeenCalled(); }); it("settles the task as error when polling itself throws", async () => { pollMock.mockRejectedValue(new Error("network down")); await trackAndPollTask({ taskId: "task-7", kind: "test-kind", meta: {} }); expect(useTaskWatcherStore.getState().tasks["task-7"]).toBeUndefined(); expect(onError).toHaveBeenCalledTimes(1); expect(onReady).not.toHaveBeenCalled(); }); it("keeps polling through transient timeouts until the ceiling", async () => { pollMock .mockResolvedValueOnce({ ok: false, error: "Task timeout" }) .mockResolvedValueOnce({ ok: true, state: "completed" }); await trackAndPollTask({ taskId: "task-3", kind: "test-kind", meta: {} }); expect(pollMock).toHaveBeenCalledTimes(2); expect(useTaskWatcherStore.getState().tasks["task-3"]?.status).toBe( TASK_WATCHER_STATUS.READY, ); }); it("does not start a second poll loop for an already-pending task", async () => { let resolvePoll: (value: unknown) => void = () => {}; pollMock.mockImplementation( () => new Promise((resolve) => (resolvePoll = resolve)), ); const first = trackAndPollTask({ taskId: "task-4", kind: "test-kind", meta: {}, }); const second = trackAndPollTask({ taskId: "task-4", kind: "test-kind", meta: {}, }); await vi.waitFor(() => expect(pollMock).toHaveBeenCalledTimes(1)); resolvePoll({ ok: true, state: "completed" }); await Promise.all([first, second]); await flush(); expect(pollMock).toHaveBeenCalledTimes(1); expect(onReady).toHaveBeenCalledTimes(1); }); it("does not poll or notify after another tab settles the same task", async () => { // Given pollMock.mockResolvedValue({ ok: true, state: "completed" }); useTaskWatcherStore.setState({ tasks: { "shared-task": { taskId: "shared-task", kind: "test-kind", status: TASK_WATCHER_STATUS.PENDING, meta: {}, startedAt: Date.now(), }, }, }); const requestLock = vi.fn( async (_name: string, callback: () => Promise) => { useTaskWatcherStore .getState() .resolveTask("shared-task", TASK_WATCHER_STATUS.READY); await callback(); }, ); Object.defineProperty(navigator, "locks", { configurable: true, value: { request: requestLock }, }); // When await resumePendingTasks(); // Then expect(requestLock).toHaveBeenCalledTimes(1); expect(pollMock).not.toHaveBeenCalled(); expect(onReady).not.toHaveBeenCalled(); }); it("resumes persisted pending tasks on watcher mount", async () => { pollMock.mockResolvedValue({ ok: true, state: "completed" }); useTaskWatcherStore.setState({ tasks: { "task-5": { taskId: "task-5", kind: "test-kind", status: TASK_WATCHER_STATUS.PENDING, meta: {}, startedAt: Date.now(), }, }, }); await resumePendingTasks(); expect(useTaskWatcherStore.getState().tasks["task-5"]?.status).toBe( TASK_WATCHER_STATUS.READY, ); expect(onReady).toHaveBeenCalledTimes(1); }); it("passes a persisted task result to its handler after resuming", async () => { // Given const taskResult = { created_count: 1, failed_count: 1, failed_finding_ids: ["finding-2"], }; pollMock.mockResolvedValue({ ok: true, state: "completed", result: taskResult, }); useTaskWatcherStore.setState({ tasks: { "resumed-jira-task": { taskId: "resumed-jira-task", kind: "test-kind", status: TASK_WATCHER_STATUS.PENDING, meta: {}, startedAt: Date.now(), }, }, }); // When await resumePendingTasks(); // Then expect(onReady).toHaveBeenCalledWith( expect.objectContaining({ result: taskResult }), ); }); it("discards settled tasks before resuming persisted work", async () => { // Given pollMock.mockResolvedValue({ ok: true, state: "completed" }); useTaskWatcherStore.setState({ tasks: { "old-ready-task": { taskId: "old-ready-task", kind: "test-kind", status: TASK_WATCHER_STATUS.READY, meta: {}, startedAt: Date.now() - 1000, }, "pending-task": { taskId: "pending-task", kind: "test-kind", status: TASK_WATCHER_STATUS.PENDING, meta: {}, startedAt: Date.now(), }, }, }); // When await resumePendingTasks(); // Then expect( useTaskWatcherStore.getState().tasks["old-ready-task"], ).toBeUndefined(); expect(useTaskWatcherStore.getState().tasks["pending-task"]?.status).toBe( TASK_WATCHER_STATUS.READY, ); }); it("expires stale persisted tasks instead of polling them", async () => { useTaskWatcherStore.setState({ tasks: { "task-6": { taskId: "task-6", kind: "test-kind", status: TASK_WATCHER_STATUS.PENDING, meta: {}, startedAt: Date.now() - 60 * 60 * 1000, }, }, }); await resumePendingTasks(); expect(pollMock).not.toHaveBeenCalled(); expect(useTaskWatcherStore.getState().tasks["task-6"]).toBeUndefined(); expect(onError).toHaveBeenCalledTimes(1); }); });