diff --git a/__tests__/detect-env.test.ts b/__tests__/detect-env.test.ts index afd706c..78d7f5e 100644 --- a/__tests__/detect-env.test.ts +++ b/__tests__/detect-env.test.ts @@ -1,8 +1,14 @@ import { detectEnvironment } from '../src/detect-env' import { Env } from '../src/env.types' -import { describe, it, expect } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' describe('Detect user env', () => { + afterEach(() => { + document.getElementById('__NEXT_DATA__')?.remove() + Reflect.deleteProperty(window, 'next') + vi.restoreAllMocks() + }) + describe('Preact', () => { it('should detect preact if class components receive any arguments in render', () => { const env = detectEnvironment({ @@ -67,5 +73,89 @@ describe('Detect user env', () => { version, }) }) + + it('should skip Next detection when window is unavailable', () => { + const originalWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window') + Object.defineProperty(globalThis, 'window', { + value: undefined, + configurable: true, + }) + + try { + const env = detectEnvironment({ + context: { classRenderReceivesAnyArguments: false }, + }) + + expect(env).toEqual({ + name: Env.React, + }) + } finally { + if (originalWindowDescriptor) { + Object.defineProperty(globalThis, 'window', originalWindowDescriptor) + } else { + Reflect.deleteProperty(globalThis, 'window') + } + } + }) + }) +}) + +describe('getEnvironment', () => { + afterEach(() => { + vi.resetModules() + vi.doUnmock('../src/env') + }) + + it('returns parsed env details when the build-time env placeholder is valid JSON', async () => { + vi.resetModules() + vi.doMock('../src/env', () => ({ + env: JSON.stringify({ name: 'react', version: '18.0.0' }), + })) + + const { getEnvironment: getEnvironmentFresh } = await import('../src/get-env') + + expect( + getEnvironmentFresh({ + context: { classRenderReceivesAnyArguments: false }, + }) + ).toEqual({ + name: 'react', + version: '18.0.0', + }) + }) + + it('falls back to detection when the build-time env JSON is not env details', async () => { + vi.resetModules() + vi.doMock('../src/env', () => ({ + env: JSON.stringify({ foo: 'bar' }), + })) + + const { getEnvironment: getEnvironmentFresh } = await import('../src/get-env') + + expect( + getEnvironmentFresh({ + // absence of classRenderReceivesAnyArguments is React signal + context: { classRenderReceivesAnyArguments: false }, + }) + ).toEqual({ + name: Env.React, + }) + }) + + it('falls back to detection when the build-time env is invalid', async () => { + vi.resetModules() + vi.doMock('../src/env', () => ({ + env: '%DETECTED_ENV%', + })) + + const { getEnvironment: getEnvironmentFresh } = await import('../src/get-env') + + expect( + getEnvironmentFresh({ + context: { classRenderReceivesAnyArguments: false }, + }) + ).toEqual({ + name: Env.React, + }) }) }) diff --git a/__tests__/fpjs-provider.test.tsx b/__tests__/fpjs-provider.test.tsx index 3c5efeb..a09068d 100644 --- a/__tests__/fpjs-provider.test.tsx +++ b/__tests__/fpjs-provider.test.tsx @@ -1,18 +1,60 @@ -import { useContext } from 'react' -import { renderHook } from '@testing-library/react' -import { FingerprintContext } from '../src' -import { createWrapper, getDefaultLoadOptions } from './helpers' +import { PropsWithChildren, useContext } from 'react' +import { act, render, renderHook } from '@testing-library/react' +import { FingerprintContext, FingerprintProvider, FingerprintProviderOptions, useVisitorData } from '../src' +import { createWrapper, getDefaultStartOptions } from './helpers' import { version } from '../package.json' -import { describe, it, expect, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import * as agent from '@fingerprint/agent' +import type { GetOptions } from '@fingerprint/agent' +import * as ssr from '../src/ssr' vi.mock('@fingerprint/agent', { spy: true }) +const mockGet = vi.fn() +const mockAgent = { + get: mockGet, + collect: vi.fn(), +} + const mockStart = vi.mocked(agent.start) +type InternalFingerprintProviderOptions = FingerprintProviderOptions & { + customAgent?: Pick + getOptions?: GetOptions +} + +const InternalFingerprintProvider = (props: PropsWithChildren) => ( + +) + +const renderProvider = (props: Partial = {}) => + render( + +
+ + ) + describe('FingerprintProvider', () => { + beforeEach(() => { + vi.resetAllMocks() + mockStart.mockReturnValue(mockAgent) + mockGet.mockResolvedValue({ + visitor_id: 'visitor', + event_id: 'event', + sealed_result: null, + cache_hit: false, + suspect_score: 0, + }) + }) + + afterEach(() => { + document.getElementById('__NEXT_DATA__')?.remove() + Reflect.deleteProperty(window, 'next') + vi.restoreAllMocks() + }) + it('should configure an instance of the Fp Agent', () => { - const loadOptions = getDefaultLoadOptions() + const loadOptions = getDefaultStartOptions() const wrapper = createWrapper({ cache: { cachePrefix: 'cache', @@ -33,4 +75,118 @@ describe('FingerprintProvider', () => { }, }) }) + + it('should include next version in integrationInfo when Next.js is detected', () => { + Object.assign(window, { next: { version: '14.2.0' } }) + + renderProvider() + + expect(mockStart).toHaveBeenCalledWith( + expect.objectContaining({ + integrationInfo: [`react-sdk/${version}/next/14.2.0`], + }) + ) + }) + + it('should rebuild the agent when forceRebuild is enabled and options change', () => { + const { rerender } = renderProvider({ apiKey: 'key-a', forceRebuild: true }) + + expect(mockStart).toHaveBeenCalledTimes(1) + + rerender( + +
+ + ) + + expect(mockStart).toHaveBeenCalledTimes(2) + expect(mockStart).toHaveBeenLastCalledWith( + expect.objectContaining({ + apiKey: 'key-b', + }) + ) + }) + + it('should not rebuild the agent when options change without forceRebuild', () => { + const { rerender } = renderProvider({ apiKey: 'key-a' }) + + expect(mockStart).toHaveBeenCalledTimes(1) + + rerender( + +
+ + ) + + expect(mockStart).toHaveBeenCalledTimes(1) + }) + + it('should use customAgent.start when a valid custom agent loader is provided', async () => { + const customStart = vi.fn().mockReturnValue(mockAgent) + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ) + + const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) + + await act(async () => { + await result.current.getData() + }) + + expect(customStart).toHaveBeenCalled() + expect(mockStart).not.toHaveBeenCalled() + }) + + it('should fall back to the default agent when customAgent is invalid', async () => { + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ) + + const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) + + await act(async () => { + await result.current.getData() + }) + + expect(mockStart).toHaveBeenCalled() + }) + + it('should merge provider getOptions into visitor data requests', async () => { + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ) + + const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) + + await act(async () => { + await result.current.getData({ tag: { source: 'getData' } }) + }) + + expect(mockGet).toHaveBeenCalledWith({ + linkedId: 'from-provider', + tag: { source: 'getData' }, + }) + }) + + it('should throw when the client is used during SSR', async () => { + vi.spyOn(ssr, 'isSSR').mockReturnValue(true) + + const wrapper = createWrapper() + const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) + + await expect(result.current.getData()).rejects.toThrow('FingerprintProvider client cannot be used in SSR') + }) }) diff --git a/__tests__/helpers.tsx b/__tests__/helpers.tsx index 437ed5b..7bf6974 100644 --- a/__tests__/helpers.tsx +++ b/__tests__/helpers.tsx @@ -1,14 +1,14 @@ import { PropsWithChildren } from 'react' import { FingerprintProvider, FingerprintProviderOptions } from '../src' -export const getDefaultLoadOptions = () => ({ +export const getDefaultStartOptions = () => ({ apiKey: 'test_api_key', }) export const createWrapper = (providerProps: Partial = {}) => ({ children }: PropsWithChildren) => ( - + {children} ) diff --git a/__tests__/use-visitor-data.test.tsx b/__tests__/use-visitor-data.test.tsx index b5afa45..9ed83e4 100644 --- a/__tests__/use-visitor-data.test.tsx +++ b/__tests__/use-visitor-data.test.tsx @@ -27,31 +27,21 @@ const mockStart = vi.mocked(agent.start) describe('useVisitorData', () => { beforeEach(() => { + vi.restoreAllMocks() vi.resetAllMocks() mockStart.mockReturnValue(mockAgent) }) - it('should provide the Fp context', () => { - const wrapper = createWrapper() - const { - result: { current }, - rerender, - } = renderHook(() => useVisitorData(), { wrapper }) - - rerender() - - expect(current).toBeDefined() - }) - - it('should call getData on mount by default', async () => { + it('should fetch on mount when called without options', async () => { mockGet.mockImplementation(() => mockGetResult) const wrapper = createWrapper() - const { result } = renderHook(() => useVisitorData({ immediate: true }), { wrapper }) + const { result } = renderHook(() => useVisitorData(), { wrapper }) expect(result.current).toMatchObject( expect.objectContaining({ isLoading: true, + isFetched: false, data: undefined, }) ) @@ -62,6 +52,7 @@ describe('useVisitorData', () => { expect(result.current).toMatchObject( expect.objectContaining({ isLoading: false, + isFetched: true, data: mockGetResult, }) ) @@ -97,6 +88,24 @@ describe('useVisitorData', () => { ) }) + it('should allow a matching request again after the pending request settles', async () => { + const secondResult = { ...mockGetResult, visitor_id: 'second-visitor' } + mockGet.mockResolvedValueOnce(mockGetResult).mockResolvedValueOnce(secondResult) + + const wrapper = createWrapper() + const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) + + await act(async () => { + await expect(result.current.getData({ linkedId: 'same-request' })).resolves.toEqual(mockGetResult) + }) + await act(async () => { + await expect(result.current.getData({ linkedId: 'same-request' })).resolves.toEqual(secondResult) + }) + + expect(mockGet).toHaveBeenCalledTimes(2) + expect(result.current.data).toEqual(secondResult) + }) + it('should not deduplicate requests with distinct empty tag values', async () => { mockGet.mockImplementation(async () => { await wait(250) @@ -115,6 +124,50 @@ describe('useVisitorData', () => { expect(mockGet).toHaveBeenCalledTimes(3) }) + it('should reset fetched data while a subsequent request is pending', async () => { + const secondResult = { ...mockGetResult, visitor_id: 'second-visitor' } + let resolveSecondRequest!: (value: GetResult) => void + const secondRequest = new Promise((resolve) => { + resolveSecondRequest = resolve + }) + mockGet.mockResolvedValueOnce(mockGetResult).mockReturnValueOnce(secondRequest) + + const wrapper = createWrapper() + const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) + + await act(async () => { + await result.current.getData() + }) + expect(result.current).toMatchObject({ + isLoading: false, + isFetched: true, + data: mockGetResult, + error: undefined, + }) + + let pendingRequest!: Promise + act(() => { + pendingRequest = result.current.getData() + }) + expect(result.current).toMatchObject({ + isLoading: true, + isFetched: false, + data: undefined, + error: undefined, + }) + + await act(async () => { + resolveSecondRequest(secondResult) + await pendingRequest + }) + expect(result.current).toMatchObject({ + isLoading: false, + isFetched: true, + data: secondResult, + error: undefined, + }) + }) + it("shouldn't call getData on mount if 'immediate' option is set to false", () => { mockGet.mockImplementation(() => mockGetResult) @@ -353,9 +406,79 @@ describe('useVisitorData', () => { expect(result.current.data).toEqual(secondResult) }) - it('should correctly pass errors from agent', async () => { - const ERROR_CLIENT_TIMEOUT = 'timeout' - mockGet.mockRejectedValue(new Error(ERROR_CLIENT_TIMEOUT)) + it('should not apply an outdated immediate error after getOptions change mid-flight', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + let rejectFirst!: (reason?: unknown) => void + const firstRequest = new Promise((_resolve, reject) => { + rejectFirst = reject + }) + const secondResult = { ...mockGetResult, visitor_id: 'second-visitor' } + + mockGet.mockImplementationOnce(() => firstRequest).mockResolvedValueOnce(secondResult) + + const wrapper = createWrapper() + const { result, rerender } = renderHook(({ tag }: { tag: number }) => useVisitorData({ immediate: true, tag }), { + wrapper, + initialProps: { tag: 1 }, + }) + + await waitFor(() => { + expect(mockGet).toHaveBeenCalledTimes(1) + }) + + rerender({ tag: 2 }) + + await waitFor(() => { + expect(mockGet).toHaveBeenCalledTimes(2) + expect(result.current.data).toEqual(secondResult) + }) + + await act(async () => { + rejectFirst(new Error('stale failure')) + await firstRequest.catch(() => undefined) + }) + + expect(result.current).toMatchObject({ + isLoading: false, + isFetched: true, + data: secondResult, + error: undefined, + }) + expect(consoleError).not.toHaveBeenCalledWith( + expect.stringContaining('Failed to fetch visitor data automatically: Error: stale failure') + ) + + consoleError.mockRestore() + }) + + it('should reject getData when params are null', async () => { + const wrapper = createWrapper() + const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) + + await expect( + // @ts-expect-error intentional invalid call + result.current.getData(null) + ).rejects.toThrow('getDataParams must not be null or undefined') + }) + + it('should normalize non-Error rejections from getData', async () => { + mockGet.mockRejectedValue('raw failure') + + const wrapper = createWrapper() + const { result } = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) + + await act(async () => { + await expect(result.current.getData()).rejects.toThrow('raw failure') + }) + + expect(result.current.error).toEqual(expect.any(Error)) + expect(result.current.error?.message).toBe('raw failure') + }) + + it('should preserve Error instances from agent', async () => { + const error = new Error('timeout') + mockGet.mockRejectedValue(error) const wrapper = createWrapper() const hook = renderHook(() => useVisitorData({ immediate: false }), { wrapper }) @@ -363,10 +486,10 @@ describe('useVisitorData', () => { await act(async () => { const promise = hook.result.current.getData() - await expect(promise).rejects.toThrow(ERROR_CLIENT_TIMEOUT) + await expect(promise).rejects.toBe(error) }) - expect(hook.result.current.error?.message).toBe(ERROR_CLIENT_TIMEOUT) + expect(hook.result.current.error).toBe(error) }) it('`getVisitorData` `getOptions` should be passed from `getVisitorData` `getOptions`', async () => { @@ -502,25 +625,30 @@ describe('useVisitorData', () => { expect(effectCount).toEqual(3) }) - it('should treat tags with differently ordered object keys as equal', async () => { - const getDataValues: UseVisitorDataReturn['getData'][] = [] - const Component = () => { - const [reverseKeys, setReverseKeys] = useState(false) - const { getData } = useVisitorData({ - immediate: false, - tag: reverseKeys ? { second: 2, first: 1 } : { first: 1, second: 2 }, - }) + it('should not refetch for reordered options but refetch when a value changes', async () => { + mockGet.mockResolvedValue(mockGetResult) - getDataValues.push(getData) + const Component = () => { + const [tag, setTag] = useState({ nested: { first: 1, second: 2 } }) + useVisitorData({ immediate: true, tag }) return ( - + <> + + + ) } const Wrapper = createWrapper() @@ -531,9 +659,23 @@ describe('useVisitorData', () => { ) - await user.click(screen.getByRole('button', { name: 'Reverse keys' })) - expect(getDataValues).toHaveLength(2) - expect(getDataValues[1]).toBe(getDataValues[0]) + await waitFor(() => { + expect(mockGet).toHaveBeenCalledTimes(1) + }) + + await user.click(screen.getByRole('button', { name: 'Reorder options' })) + await act(async () => { + await wait(0) + }) + expect(mockGet).toHaveBeenCalledTimes(1) + + await user.click(screen.getByRole('button', { name: 'Change nested value' })) + await waitFor(() => { + expect(mockGet).toHaveBeenCalledTimes(2) + }) + expect(mockGet).toHaveBeenLastCalledWith({ + tag: { nested: { second: 3, first: 1 } }, + }) }) }) diff --git a/__tests__/with-environment.test.tsx b/__tests__/with-environment.test.tsx index 884ff5f..09cfb48 100644 --- a/__tests__/with-environment.test.tsx +++ b/__tests__/with-environment.test.tsx @@ -13,14 +13,6 @@ describe('WithEnvironment', () => { expect(renderChild).toHaveBeenCalledWith(expect.objectContaining({ name: 'react' })) }) - it('keeps the original props of the element', () => { - const Echo = ({ message }: { message: string }) => {message} - - const { container } = render({() => }) - - expect(container.innerHTML).toContain('hello') - }) - it('should not break navigation', async () => { const user = userEvent.setup() const Home = () => ( diff --git a/src/utils/wait-until.ts b/src/utils/wait-until.ts deleted file mode 100644 index 394122a..0000000 --- a/src/utils/wait-until.ts +++ /dev/null @@ -1,23 +0,0 @@ -export interface WaitUntilParams { - checkCondition: () => boolean - timeoutMs?: number - intervalMs?: number -} - -export function waitUntil({ checkCondition, intervalMs = 250, timeoutMs = 2000 }: WaitUntilParams) { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - clearInterval(interval) - - reject(new Error('Timeout')) - }, timeoutMs) - - const interval = setInterval(() => { - if (checkCondition()) { - clearTimeout(timeoutId) - clearInterval(interval) - resolve() - } - }, intervalMs) - }) -}