diff --git a/frontend/src/__tests__/turnstile.test.tsx b/frontend/src/__tests__/turnstile.test.tsx new file mode 100644 index 0000000..a9cc75d --- /dev/null +++ b/frontend/src/__tests__/turnstile.test.tsx @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { render, cleanup } from '@solidjs/testing-library'; +import Turnstile from '~/components/Turnstile'; + +beforeEach(() => { + vi.useFakeTimers(); + delete (window as any).turnstile; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + delete (window as any).turnstile; + cleanup(); +}); + +describe('Turnstile component', () => { + it('should render widget immediately when window.turnstile is available', () => { + const onToken = vi.fn(); + const renderMock = vi.fn((_el: HTMLElement, opts: any) => { + opts.callback('test-token'); + return 'widget-1'; + }); + + (window as any).turnstile = { + render: renderMock, + reset: vi.fn(), + remove: vi.fn(), + }; + + render(() => ); + + expect(renderMock).toHaveBeenCalledTimes(1); + expect(onToken).toHaveBeenCalledWith('test-token'); + }); + + it('should poll and render when window.turnstile appears after delay', () => { + const onToken = vi.fn(); + const renderMock = vi.fn((_el: HTMLElement, opts: any) => { + opts.callback('delayed-token'); + return 'widget-2'; + }); + + render(() => ); + + // Turnstile not yet available — advance a few ticks + vi.advanceTimersByTime(300); + expect(onToken).not.toHaveBeenCalled(); + + // Simulate script loading + (window as any).turnstile = { + render: renderMock, + reset: vi.fn(), + remove: vi.fn(), + }; + + vi.advanceTimersByTime(100); + + expect(renderMock).toHaveBeenCalledTimes(1); + expect(onToken).toHaveBeenCalledWith('delayed-token'); + }); + + it('should call onError after 10 seconds if window.turnstile never loads', () => { + const onToken = vi.fn(); + const onError = vi.fn(); + + render(() => ); + + // Advance to just before timeout (99 ticks = 9.9s) + vi.advanceTimersByTime(9900); + expect(onError).not.toHaveBeenCalled(); + + // Advance past timeout (100th tick = 10s) + vi.advanceTimersByTime(100); + expect(onError).toHaveBeenCalledTimes(1); + expect(onToken).not.toHaveBeenCalled(); + + // Verify interval is cleared — no further calls after timeout + vi.advanceTimersByTime(1000); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('should clear interval on unmount during polling', () => { + const onToken = vi.fn(); + const onError = vi.fn(); + + const { unmount } = render(() => ( + + )); + + vi.advanceTimersByTime(500); + unmount(); + + // Advance past what would be the timeout — nothing should fire + vi.advanceTimersByTime(15000); + expect(onError).not.toHaveBeenCalled(); + expect(onToken).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/Turnstile.tsx b/frontend/src/components/Turnstile.tsx index 6083967..ca72810 100644 --- a/frontend/src/components/Turnstile.tsx +++ b/frontend/src/components/Turnstile.tsx @@ -35,8 +35,9 @@ const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY ?? '1x00000000000000000 * **Widget lifecycle**: * 1. On mount, if `window.turnstile` is available (script already loaded), * the widget is rendered immediately. - * 2. Otherwise, a 100ms polling interval waits for the external Turnstile - * script to load and expose the global API, then renders. + * 2. Otherwise, a 100 ms polling interval waits for the external Turnstile + * script to load (up to 10 seconds / 100 attempts). If the script does + * not load in time, polling stops and `onError` is called. * 3. On cleanup (component unmount), the widget is removed via * `turnstile.remove()` and any polling interval is cleared to prevent * memory leaks. @@ -67,10 +68,15 @@ const Turnstile: Component = (props) => { if (window.turnstile) { renderWidget(); } else { + let attempts = 0; const checkInterval = setInterval(() => { + attempts++; if (window.turnstile) { clearInterval(checkInterval); renderWidget(); + } else if (attempts >= 100) { + clearInterval(checkInterval); + props.onError?.(); } }, 100);