diff --git a/packages/graphiql-console/src/hooks/usePolling.test.ts b/packages/graphiql-console/src/hooks/usePolling.test.ts new file mode 100644 index 00000000000..cecfc7613cb --- /dev/null +++ b/packages/graphiql-console/src/hooks/usePolling.test.ts @@ -0,0 +1,176 @@ +import {usePolling} from './usePolling.ts' +import {renderHook, act} from '@testing-library/react' +import {vi, describe, test, expect, beforeEach, afterEach} from 'vitest' + +describe('usePolling', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test('calls callback immediately on mount', () => { + const callback = vi.fn() + renderHook(() => usePolling(callback, {interval: 1000, enabled: true})) + + expect(callback).toHaveBeenCalledTimes(1) + }) + + test('calls callback at specified interval', () => { + const callback = vi.fn() + renderHook(() => usePolling(callback, {interval: 1000, enabled: true})) + + // Initial call + expect(callback).toHaveBeenCalledTimes(1) + + // After 1 second + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(callback).toHaveBeenCalledTimes(2) + + // After 2 seconds total + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(callback).toHaveBeenCalledTimes(3) + }) + + test('respects enabled=false (no polling)', () => { + const callback = vi.fn() + renderHook(() => usePolling(callback, {interval: 1000, enabled: false})) + + expect(callback).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(5000) + }) + + expect(callback).not.toHaveBeenCalled() + }) + + test('updates when callback reference changes', () => { + const callback1 = vi.fn() + const callback2 = vi.fn() + + const {rerender} = renderHook(({cb}) => usePolling(cb, {interval: 1000, enabled: true}), { + initialProps: {cb: callback1}, + }) + + // Initial call with callback1 + expect(callback1).toHaveBeenCalledTimes(1) + expect(callback2).not.toHaveBeenCalled() + + // Update callback + rerender({cb: callback2}) + + // Advance time - should call callback2 now + act(() => { + vi.advanceTimersByTime(1000) + }) + + // callback1 should still be at 1, callback2 should be at 1 + expect(callback1).toHaveBeenCalledTimes(1) + expect(callback2).toHaveBeenCalledTimes(1) + }) + + test('cleans up interval on unmount', () => { + const callback = vi.fn() + const {unmount} = renderHook(() => usePolling(callback, {interval: 1000, enabled: true})) + + expect(callback).toHaveBeenCalledTimes(1) + + unmount() + + // Advance time after unmount + act(() => { + vi.advanceTimersByTime(5000) + }) + + // Should not have been called again + expect(callback).toHaveBeenCalledTimes(1) + }) + + test('handles async callbacks', async () => { + const callback = vi.fn().mockResolvedValue(undefined) + + renderHook(() => usePolling(callback, {interval: 1000, enabled: true})) + + // Wait for initial call + await act(async () => { + await Promise.resolve() + }) + + expect(callback).toHaveBeenCalledTimes(1) + + // Advance timer and wait for async call + await act(async () => { + vi.advanceTimersByTime(1000) + await Promise.resolve() + }) + + expect(callback).toHaveBeenCalledTimes(2) + }) + + test('catches and ignores callback errors', () => { + // Suppress console.error for this test since React will report the error + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const callback = vi.fn().mockImplementation(() => { + throw new Error('Test error') + }) + + // Render the hook - errors should be caught internally + renderHook(() => usePolling(callback, {interval: 1000, enabled: true})) + + expect(callback).toHaveBeenCalledTimes(1) + + // Should continue polling despite errors + act(() => { + vi.advanceTimersByTime(1000) + }) + + expect(callback).toHaveBeenCalledTimes(2) + + // Restore console.error + consoleErrorSpy.mockRestore() + }) + + test('changes interval dynamically', () => { + const callback = vi.fn() + + const {rerender} = renderHook(({interval}) => usePolling(callback, {interval, enabled: true}), { + initialProps: {interval: 1000}, + }) + + // Initial call + expect(callback).toHaveBeenCalledTimes(1) + + // Advance by 1 second + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(callback).toHaveBeenCalledTimes(2) + + // Change interval to 500ms - this triggers immediate call and restarts interval + act(() => { + rerender({interval: 500}) + }) + // Rerender triggers an immediate call due to useEffect re-running + expect(callback).toHaveBeenCalledTimes(3) + + // Advance by 500ms - should call again + act(() => { + vi.advanceTimersByTime(500) + }) + expect(callback).toHaveBeenCalledTimes(4) + + // Another 500ms + act(() => { + vi.advanceTimersByTime(500) + }) + expect(callback).toHaveBeenCalledTimes(5) + }) +}) diff --git a/packages/graphiql-console/src/hooks/usePolling.ts b/packages/graphiql-console/src/hooks/usePolling.ts new file mode 100644 index 00000000000..8e7fd21f588 --- /dev/null +++ b/packages/graphiql-console/src/hooks/usePolling.ts @@ -0,0 +1,46 @@ +import {useEffect, useRef} from 'react' + +interface UsePollingOptions { + // Polling interval in milliseconds + interval: number + // Whether polling is active (default: true) + enabled?: boolean +} + +/** + * Generic polling hook that calls a function at regular intervals + * @param callback - Function to call on each interval + * @param options - Polling configuration + */ +export function usePolling(callback: () => void | Promise, options: UsePollingOptions) { + const {interval, enabled = true} = options + const callbackRef = useRef(callback) + + // Keep callback ref up-to-date + useEffect(() => { + callbackRef.current = callback + }, [callback]) + + useEffect(() => { + if (!enabled) return + + const executeCallback = () => { + try { + Promise.resolve(callbackRef.current()).catch(() => { + // Intentionally ignore errors in polling callbacks + }) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + // Intentionally ignore synchronous errors in polling callbacks + } + } + + // Call immediately on mount + executeCallback() + + // Set up interval + const intervalId = setInterval(executeCallback, interval) + + return () => clearInterval(intervalId) + }, [interval, enabled]) +} diff --git a/packages/graphiql-console/src/hooks/useServerStatus.test.ts b/packages/graphiql-console/src/hooks/useServerStatus.test.ts new file mode 100644 index 00000000000..8cdade91f03 --- /dev/null +++ b/packages/graphiql-console/src/hooks/useServerStatus.test.ts @@ -0,0 +1,210 @@ +import {useServerStatus} from './useServerStatus.ts' +import {renderHook, act} from '@testing-library/react' +import {vi, describe, test, expect, beforeEach, afterEach} from 'vitest' + +describe('useServerStatus', () => { + beforeEach(() => { + vi.useFakeTimers() + global.fetch = vi.fn() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + test('has initial state with serverIsLive=true and appIsInstalled=true', () => { + const {result} = renderHook(() => + useServerStatus({ + baseUrl: 'http://localhost:3457', + pingInterval: 2000, + statusInterval: 5000, + pingTimeout: 3000, + }), + ) + + expect(result.current.serverIsLive).toBe(true) + expect(result.current.appIsInstalled).toBe(true) + }) + + test('successful ping response sets serverIsLive=true', async () => { + ;(global.fetch as any).mockResolvedValueOnce({ + status: 200, + }) + + const {result} = renderHook(() => + useServerStatus({ + baseUrl: 'http://localhost:3457', + pingInterval: 2000, + pingTimeout: 3000, + }), + ) + + // Wait for the initial ping to complete + await act(async () => { + await Promise.resolve() + }) + + expect(result.current.serverIsLive).toBe(true) + expect(global.fetch).toHaveBeenCalledWith('http://localhost:3457/graphiql/ping', { + method: 'GET', + }) + }) + + test('failed ping sets serverIsLive=false', async () => { + ;(global.fetch as any).mockRejectedValueOnce(new Error('Network error')) + + const {result} = renderHook(() => + useServerStatus({ + baseUrl: 'http://localhost:3457', + pingInterval: 2000, + pingTimeout: 3000, + }), + ) + + // Wait for the initial ping to complete + await act(async () => { + await Promise.resolve() + }) + + expect(result.current.serverIsLive).toBe(false) + }) + + test('successful status check updates app info', async () => { + // Mock ping response (first call) + ;(global.fetch as any) + .mockResolvedValueOnce({ + status: 200, + }) + // Mock status response (second call) + .mockResolvedValueOnce({ + status: 200, + json: async () => ({ + status: 'OK', + storeFqdn: 'test-store.myshopify.com', + appName: 'Test App', + appUrl: 'http://localhost:3000', + }), + }) + + const {result} = renderHook(() => + useServerStatus({ + baseUrl: 'http://localhost:3457', + pingInterval: 2000, + statusInterval: 5000, + }), + ) + + // Wait for both initial calls + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + expect(result.current.appIsInstalled).toBe(true) + expect(result.current.storeFqdn).toBe('test-store.myshopify.com') + expect(result.current.appName).toBe('Test App') + expect(result.current.appUrl).toBe('http://localhost:3000') + expect(global.fetch).toHaveBeenCalledWith('http://localhost:3457/graphiql/status', { + method: 'GET', + }) + }) + + test('failed status check sets appIsInstalled=false', async () => { + // Mock ping response (first call) + ;(global.fetch as any) + .mockResolvedValueOnce({ + status: 200, + }) + // Mock failed status response (second call) + .mockRejectedValueOnce(new Error('Status check failed')) + + const {result} = renderHook(() => + useServerStatus({ + baseUrl: 'http://localhost:3457', + pingInterval: 2000, + statusInterval: 5000, + }), + ) + + // Wait for both initial calls + await act(async () => { + await Promise.resolve() + await Promise.resolve() + }) + + expect(result.current.appIsInstalled).toBe(false) + }) + + test('polling intervals are respected', async () => { + ;(global.fetch as any).mockResolvedValue({ + status: 200, + json: async () => ({status: 'OK'}), + }) + + renderHook(() => + useServerStatus({ + baseUrl: 'http://localhost:3457', + pingInterval: 2000, + statusInterval: 5000, + }), + ) + + // Initial calls (2 - ping and status) + await act(async () => { + await Promise.resolve() + }) + + const initialCallCount = (global.fetch as any).mock.calls.length + expect(initialCallCount).toBe(2) + + // Advance by ping interval (2 seconds) + await act(async () => { + vi.advanceTimersByTime(2000) + await Promise.resolve() + }) + + // Should have one more ping call + expect((global.fetch as any).mock.calls.length).toBe(initialCallCount + 1) + + // Advance by status interval (5 seconds total) + await act(async () => { + vi.advanceTimersByTime(3000) + await Promise.resolve() + }) + + // Should have another ping call and a status call + expect((global.fetch as any).mock.calls.length).toBeGreaterThan(initialCallCount + 1) + }) + + test('cleanup of resources on unmount', async () => { + ;(global.fetch as any).mockResolvedValue({ + status: 200, + }) + + const {unmount} = renderHook(() => + useServerStatus({ + baseUrl: 'http://localhost:3457', + pingInterval: 2000, + statusInterval: 5000, + }), + ) + + // Wait for initial calls + await act(async () => { + await Promise.resolve() + }) + + const callCountBeforeUnmount = (global.fetch as any).mock.calls.length + + unmount() + + // Advance time after unmount + await act(async () => { + vi.advanceTimersByTime(10000) + await Promise.resolve() + }) + + // Should not have made any more calls + expect((global.fetch as any).mock.calls.length).toBe(callCountBeforeUnmount) + }) +}) diff --git a/packages/graphiql-console/src/hooks/useServerStatus.ts b/packages/graphiql-console/src/hooks/useServerStatus.ts new file mode 100644 index 00000000000..68f6c1048fa --- /dev/null +++ b/packages/graphiql-console/src/hooks/useServerStatus.ts @@ -0,0 +1,95 @@ +/* eslint-disable no-restricted-globals */ +/* eslint-disable no-catch-all/no-catch-all */ +// Browser environment - fetch is the correct API, not @shopify/cli-kit/node/http +// Catch blocks intentionally handle all errors as server unavailability + +import {usePolling} from './usePolling.ts' +import {useState, useCallback, useRef} from 'react' +import type {ServerStatus} from '@/components/types' + +interface UseServerStatusOptions { + // e.g., "http://localhost:3457" + baseUrl: string + // Default: 2000ms + pingInterval?: number + // Default: 5000ms + statusInterval?: number + // Default: 3000ms + pingTimeout?: number +} + +/** + * Hook that monitors server health and app installation status + * Replaces vanilla JS polling logic from current implementation + */ +export function useServerStatus(options: UseServerStatusOptions) { + const {baseUrl, pingInterval = 2000, statusInterval = 5000, pingTimeout = 3000} = options + + const [status, setStatus] = useState({ + serverIsLive: true, + appIsInstalled: true, + }) + + const timeoutRefs = useRef([]) + + // Ping polling: Check if server is running + const checkServerPing = useCallback(async () => { + // Set timeout to mark server dead after pingTimeout ms + const timeoutId = setTimeout(() => { + setStatus((prev) => ({...prev, serverIsLive: false})) + }, pingTimeout) + timeoutRefs.current.push(timeoutId) + + try { + const response = await fetch(`${baseUrl}/graphiql/ping`, { + method: 'GET', + }) + + if (response.status === 200) { + // Clear all pending "mark dead" timeouts + timeoutRefs.current.forEach((id) => clearTimeout(id)) + timeoutRefs.current = [] + setStatus((prev) => ({...prev, serverIsLive: true})) + } else { + setStatus((prev) => ({...prev, serverIsLive: false})) + } + } catch { + // Network error - server is down + setStatus((prev) => ({...prev, serverIsLive: false})) + } + }, [baseUrl, pingTimeout]) + + // Status polling: Check app installation and get store info + const checkAppStatus = useCallback(async () => { + try { + const response = await fetch(`${baseUrl}/graphiql/status`, { + method: 'GET', + }) + const data = await response.json() + + if (data.status === 'OK') { + setStatus((prev) => ({ + ...prev, + appIsInstalled: true, + storeFqdn: data.storeFqdn, + appName: data.appName, + appUrl: data.appUrl, + })) + } else { + setStatus((prev) => ({ + ...prev, + appIsInstalled: false, + })) + } + } catch { + // If status check fails, assume app is not installed + setStatus((prev) => ({...prev, appIsInstalled: false})) + } + }, [baseUrl]) + + // Set up polling + usePolling(checkServerPing, {interval: pingInterval}) + usePolling(checkAppStatus, {interval: statusInterval}) + + return status +}