import type { MockedFunction } from "@vitest/spy"; import flushPromises from "flush-promises"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useResourceWatcher, type WatchOptions, type WatchResourceHandler } from "./resourceWatcher"; // Mock the global document object const mockAddEventListener = vi.fn(); const mockRemoveEventListener = vi.fn(); interface MockDocument { addEventListener: MockedFunction; removeEventListener: MockedFunction; visibilityState: "visible" | "hidden"; } const mockDocument: MockDocument = { addEventListener: mockAddEventListener, removeEventListener: mockRemoveEventListener, visibilityState: "visible", }; Object.defineProperty(global, "document", { value: mockDocument, writable: true, }); // Mock setTimeout and clearTimeout vi.useFakeTimers(); // Helper function to get visibility change handler with proper typing function getVisibilityChangeHandler(): () => void { const call = mockAddEventListener.mock.calls.find((call) => call[0] === "visibilitychange"); return call?.[1] as () => void; } describe("useResourceWatcher", () => { let mockWatchHandler: MockedFunction; beforeEach(() => { vi.clearAllTimers(); vi.clearAllMocks(); mockWatchHandler = vi.fn().mockResolvedValue(); mockAddEventListener.mockClear(); mockRemoveEventListener.mockClear(); // Reset document visibility state mockDocument.visibilityState = "visible"; }); afterEach(() => { vi.runOnlyPendingTimers(); vi.useRealTimers(); vi.useFakeTimers(); }); describe("basic functionality", () => { it("should call the watch handler immediately when starting", async () => { const { startWatchingResource } = useResourceWatcher(mockWatchHandler); startWatchingResource(); expect(mockWatchHandler).toHaveBeenCalledTimes(1); expect(mockWatchHandler).toHaveBeenCalledWith(undefined); }); it("should pass the app parameter to the watch handler", async () => { const mockApp = { id: "test-app" }; const { startWatchingResource } = useResourceWatcher(mockWatchHandler); startWatchingResource(mockApp); expect(mockWatchHandler).toHaveBeenCalledWith(mockApp); }); it("should stop watching when stopWatchingResource is called", async () => { const { startWatchingResource, stopWatchingResource } = useResourceWatcher(mockWatchHandler); startWatchingResource(); expect(mockWatchHandler).toHaveBeenCalledTimes(1); stopWatchingResource(); // Fast-forward time to ensure no more calls are made vi.advanceTimersByTime(60000); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(1); }); it("should return correct value for isWatchingResource", async () => { const { startWatchingResource, stopWatchingResource, isWatchingResource } = useResourceWatcher(mockWatchHandler); expect(isWatchingResource.value).toBe(false); startWatchingResource(); expect(isWatchingResource.value).toBe(true); stopWatchingResource(); expect(isWatchingResource.value).toBe(false); }); }); describe("polling intervals", () => { it("should use default short polling interval (3000ms) when app is active", async () => { const { startWatchingResource } = useResourceWatcher(mockWatchHandler); startWatchingResource(); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(1); // Advance time by the default short polling interval vi.advanceTimersByTime(3000); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(2); // Advance time again vi.advanceTimersByTime(3000); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(3); }); it("should use custom short polling interval when provided", async () => { const customOptions: WatchOptions = { shortPollingInterval: 1500, }; const { startWatchingResource } = useResourceWatcher(mockWatchHandler, customOptions); startWatchingResource(); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(1); // Advance time by the custom short polling interval vi.advanceTimersByTime(1500); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(2); }); it("should switch to long polling interval when app becomes hidden", async () => { const { startWatchingResource } = useResourceWatcher(mockWatchHandler); startWatchingResource(); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(1); // Simulate visibility change event setup expect(mockAddEventListener).toHaveBeenCalledWith("visibilitychange", expect.any(Function)); const visibilityChangeHandler = getVisibilityChangeHandler(); // Change document visibility to hidden mockDocument.visibilityState = "hidden"; visibilityChangeHandler(); // The current timer (with short interval) should still complete first vi.advanceTimersByTime(3000); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(2); // Now the next timer should use the long polling interval vi.advanceTimersByTime(10000); // Default long interval await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(3); }); it("should use custom long polling interval when app is hidden", async () => { const customOptions: WatchOptions = { longPollingInterval: 5000, }; const { startWatchingResource } = useResourceWatcher(mockWatchHandler, customOptions); startWatchingResource(); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(1); // Get the visibility change handler const visibilityChangeHandler = getVisibilityChangeHandler(); // Change document visibility to hidden mockDocument.visibilityState = "hidden"; visibilityChangeHandler(); // Current timer (short interval) completes first vi.advanceTimersByTime(3000); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(2); // Fast-forward by custom long interval vi.advanceTimersByTime(5000); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(3); }); it("should disable background polling when enableBackgroundPolling is false", async () => { const customOptions: WatchOptions = { enableBackgroundPolling: false, }; const { startWatchingResource } = useResourceWatcher(mockWatchHandler, customOptions); startWatchingResource(); expect(mockWatchHandler).toHaveBeenCalledTimes(1); // Get the visibility change handler const visibilityChangeHandler = getVisibilityChangeHandler(); // Change document visibility to hidden mockDocument.visibilityState = "hidden"; visibilityChangeHandler(); // Fast-forward by any amount of time vi.advanceTimersByTime(30000); await flushPromises(); // Should not have been called again expect(mockWatchHandler).toHaveBeenCalledTimes(1); }); }); describe("visibility change handling", () => { it("should set up visibility change listener only once", () => { useResourceWatcher(mockWatchHandler); useResourceWatcher(mockWatchHandler); // Should only set up listener once per instance expect(mockAddEventListener).toHaveBeenCalledTimes(2); expect(mockAddEventListener).toHaveBeenCalledWith("visibilitychange", expect.any(Function)); }); it("should switch to short interval when app becomes visible without restarting watcher", async () => { const { startWatchingResource } = useResourceWatcher(mockWatchHandler); startWatchingResource(); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(1); // Get the visibility change handler const visibilityChangeHandler = getVisibilityChangeHandler(); // Simulate app becoming hidden mockDocument.visibilityState = "hidden"; visibilityChangeHandler(); // Clear the handler calls mockWatchHandler.mockClear(); // Simulate app becoming visible again mockDocument.visibilityState = "visible"; visibilityChangeHandler(); // Should NOT immediately call the handler (watcher already running, no restart) await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(0); // But should continue with short polling interval on next scheduled poll vi.advanceTimersByTime(3000); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(1); }); }); describe("error handling", () => { it("should handle errors in watch handler gracefully and continue polling", async () => { const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const error = new Error("Network error"); mockWatchHandler.mockRejectedValueOnce(error).mockResolvedValue(undefined); const { startWatchingResource } = useResourceWatcher(mockWatchHandler); startWatchingResource(); await flushPromises(); expect(consoleWarnSpy).toHaveBeenCalledWith(error); expect(mockWatchHandler).toHaveBeenCalledTimes(1); // Should continue polling despite the error vi.advanceTimersByTime(3000); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(2); consoleWarnSpy.mockRestore(); }); it("should handle multiple consecutive errors", async () => { const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const error1 = new Error("First error"); const error2 = new Error("Second error"); mockWatchHandler.mockRejectedValueOnce(error1).mockRejectedValueOnce(error2).mockResolvedValue(undefined); const { startWatchingResource } = useResourceWatcher(mockWatchHandler); startWatchingResource(); await flushPromises(); expect(consoleWarnSpy).toHaveBeenCalledWith(error1); vi.advanceTimersByTime(3000); await flushPromises(); expect(consoleWarnSpy).toHaveBeenCalledWith(error2); vi.advanceTimersByTime(3000); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(3); consoleWarnSpy.mockRestore(); }); }); describe("cleanup and resource management", () => { it("should clear existing timeout when starting watching again", async () => { const { startWatchingResource } = useResourceWatcher(mockWatchHandler); // Start watching startWatchingResource(); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(1); // Start watching again before the first timeout fires startWatchingResource(); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(2); // Fast-forward time - should only fire once more (from the second start) vi.advanceTimersByTime(3000); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(3); }); it("should stop polling even when watch handler takes longer than interval", async () => { // Create a handler that takes 5 seconds to complete (longer than 3s interval) const slowHandler = vi.fn().mockImplementation(async () => { // Simulate a slow network request that takes longer than the polling interval await new Promise((resolve) => setTimeout(resolve, 5000)); }); const { startWatchingResource, stopWatchingResource } = useResourceWatcher(slowHandler); // Start watching startWatchingResource(); expect(slowHandler).toHaveBeenCalledTimes(1); // Advance time by 2 seconds (handler still running) vi.advanceTimersByTime(2000); await flushPromises(); // Stop watching while the handler is still executing stopWatchingResource(); // Complete the slow handler execution vi.advanceTimersByTime(3000); // Total 5 seconds for handler to complete await flushPromises(); // Advance time well beyond the polling interval vi.advanceTimersByTime(10000); await flushPromises(); expect(slowHandler).toHaveBeenCalledTimes(1); }); it("should handle multiple overlapping slow handlers correctly", async () => { const slowHandler = vi.fn().mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 8000)); // 8 seconds (longer than 2 intervals) }); const { startWatchingResource, stopWatchingResource } = useResourceWatcher(slowHandler); startWatchingResource(); expect(slowHandler).toHaveBeenCalledTimes(1); // Let the first handler run for 3 seconds (still running) vi.advanceTimersByTime(3000); await flushPromises(); // The 3-second interval timer should NOT fire a new handler yet because the first is still running expect(slowHandler).toHaveBeenCalledTimes(1); // Stop watching while first handler is still running stopWatchingResource(); // Complete the first handler (8 seconds total) vi.advanceTimersByTime(5000); await flushPromises(); // Advance time, which should not trigger another call vi.advanceTimersByTime(3000); await flushPromises(); expect(slowHandler).toHaveBeenCalledTimes(1); }); it("should update isWatchingResource flag correctly with slow handlers", async () => { const slowHandler = vi.fn().mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 5000)); }); const { startWatchingResource, stopWatchingResource, isWatchingResource } = useResourceWatcher(slowHandler); // Start watching startWatchingResource(); expect(isWatchingResource.value).toBe(true); // Still watching while handler is running vi.advanceTimersByTime(2000); await flushPromises(); expect(isWatchingResource.value).toBe(true); // Stop watching while handler is still running stopWatchingResource(); expect(isWatchingResource.value).toBe(false); // Should remain stopped even after handler completes vi.advanceTimersByTime(10000); await flushPromises(); expect(isWatchingResource.value).toBe(false); }); it("should not schedule new timeout if current polling interval is undefined", async () => { const customOptions: WatchOptions = { enableBackgroundPolling: false, }; const { startWatchingResource } = useResourceWatcher(mockWatchHandler, customOptions); startWatchingResource(); // Get the visibility change handler const visibilityChangeHandler = getVisibilityChangeHandler(); // Change to hidden (disables polling) mockDocument.visibilityState = "hidden"; visibilityChangeHandler(); // Clear previous calls mockWatchHandler.mockClear(); // Fast-forward time significantly vi.advanceTimersByTime(60000); await flushPromises(); // Should not have been called expect(mockWatchHandler).not.toHaveBeenCalled(); }); }); describe("integration scenarios", () => { it("should work correctly with all custom options", async () => { const customOptions: WatchOptions = { shortPollingInterval: 1000, longPollingInterval: 4000, enableBackgroundPolling: true, }; const { startWatchingResource } = useResourceWatcher(mockWatchHandler, customOptions); startWatchingResource(); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(1); // Test short interval vi.advanceTimersByTime(1000); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(2); // Change to hidden const visibilityChangeHandler = getVisibilityChangeHandler(); mockDocument.visibilityState = "hidden"; visibilityChangeHandler(); // Test long interval (after current short interval timer completes) vi.advanceTimersByTime(1000); // Complete current timer await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(3); vi.advanceTimersByTime(4000); // Long interval await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(4); }); it("should handle rapid visibility state changes", async () => { const { startWatchingResource } = useResourceWatcher(mockWatchHandler); startWatchingResource(); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(1); // Initial call const visibilityChangeHandler = getVisibilityChangeHandler(); // Change to hidden - this changes currentPollingInterval but doesn't restart timer mockDocument.visibilityState = "hidden"; visibilityChangeHandler(); // Change to visible - this changes currentPollingInterval back to short but doesn't restart mockDocument.visibilityState = "visible"; visibilityChangeHandler(); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(1); // No additional call from becoming visible // The timer continues with the short interval vi.advanceTimersByTime(3000); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(2); // + 1 from short interval timer // Change to hidden again mockDocument.visibilityState = "hidden"; visibilityChangeHandler(); // The next timer should use the long interval vi.advanceTimersByTime(10000); await flushPromises(); expect(mockWatchHandler).toHaveBeenCalledTimes(3); // + 1 from long interval timer }); }); });