/** @jest-environment jsdom */ jest.mock('../sinks/datadog', () => ({ sendToDatadog: jest.fn(), })); jest.mock('../integrations/axios', () => ({ instrumentAxios: jest.fn(), })); jest.mock('../integrations/fetch', () => ({ instrumentFetch: jest.fn(() => jest.fn()), })); jest.mock('../integrations/jquery', () => ({ instrumentJquery: jest.fn(), exposeAppJourney: jest.fn(), })); import { configureJourney, getSlowRequests } from '../config'; import { resetJourney } from '../core'; import { moduleRuntime } from '../core/runtime'; import type { JourneyEvent } from '../core/types'; import { longestEndpointMatch, sortEndpointsByLongestMatch } from '../core/endpoint-policy'; import { resetSharedPolicies, setJourneyDefaultEnabled } from '../global'; import { instrumentAxios } from '../integrations/axios'; import { instrumentFetch } from '../integrations/fetch'; import { exposeAppJourney, instrumentJquery, type WindowWithAppJourney, } from '../integrations/jquery'; import { sendToConsole } from '../sinks/console'; import { sendToDatadog } from '../sinks/datadog'; const instrumentAxiosMock = jest.mocked(instrumentAxios); const instrumentFetchMock = jest.mocked(instrumentFetch); const instrumentJqueryMock = jest.mocked(instrumentJquery); const exposeAppJourneyMock = jest.mocked(exposeAppJourney); describe('[journey] configureJourney / getSlowRequests', () => { beforeEach(() => { setJourneyDefaultEnabled(true); }); afterEach(() => { setJourneyDefaultEnabled(false); localStorage.clear(); resetSharedPolicies(); resetJourney({ sinks: [sendToDatadog, sendToConsole], slowRequests: { defaultMs: 4_000, endpoints: [] }, }); instrumentAxiosMock.mockClear(); instrumentFetchMock.mockClear(); instrumentJqueryMock.mockClear(); exposeAppJourneyMock.mockClear(); }); test('defaults to 4s with empty endpoints', () => { expect(getSlowRequests()).toEqual({ defaultMs: 4_000, endpoints: [] }); }); test('overrides the app-wide request timeout tier', () => { configureJourney({ slowRequests: { defaultMs: 2_000, endpoints: [{ match: '/slow', slowRequestMs: 8_000 }], }, }); expect(getSlowRequests()).toEqual({ defaultMs: 2_000, endpoints: [{ match: '/slow', slowRequestMs: 8_000 }], }); }); test('ignores configureJourney when slowRequests is omitted', () => { configureJourney({ slowRequests: { defaultMs: 1_000, endpoints: [] } }); configureJourney({}); expect(getSlowRequests().defaultMs).toBe(1_000); }); test('clamps maxSteps to 1..200', () => { configureJourney({ maxSteps: Infinity }); expect(moduleRuntime.getMaxSteps()).toBe(50); configureJourney({ maxSteps: 0 }); expect(moduleRuntime.getMaxSteps()).toBe(50); configureJourney({ maxSteps: 500 }); expect(moduleRuntime.getMaxSteps()).toBe(200); }); describe('with sinks', () => { let payload: JourneyEvent; let custom: jest.Mock; beforeEach(() => { jest.mocked(sendToDatadog).mockClear(); custom = jest.fn(); payload = { journey: { name: 'test', team: 't', group: 'g', service: 's', outcome: 'good', durationMs: 1, steps: [], }, }; }); test('replaces the sink list', () => { configureJourney({ sinks: [custom] }); moduleRuntime.emit(payload); expect(custom).toHaveBeenCalledWith(payload); expect(sendToDatadog).not.toHaveBeenCalled(); }); test('leaves sinks unchanged when sinks is omitted', () => { configureJourney({ sinks: [custom] }); configureJourney({ slowRequests: { defaultMs: 2_000, endpoints: [] } }); moduleRuntime.emit(payload); expect(custom).toHaveBeenCalledWith(payload); expect(sendToDatadog).not.toHaveBeenCalled(); }); test('ignores a later sinks replacement on this runtime', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); configureJourney({ sinks: [custom] }); configureJourney({ sinks: [sendToDatadog] }); moduleRuntime.emit(payload); expect(custom).toHaveBeenCalledWith(payload); expect(sendToDatadog).not.toHaveBeenCalled(); expect(warn).toHaveBeenCalledWith( expect.stringContaining('sinks already set on this runtime') ); warn.mockRestore(); }); test('disables emission when sinks is empty', () => { configureJourney({ sinks: [] }); moduleRuntime.emit(payload); expect(sendToDatadog).not.toHaveBeenCalled(); expect(custom).not.toHaveBeenCalled(); }); }); describe('with transport wiring', () => { const axios = { defaults: {}, interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, }; const jquery = { ajaxPrefilter: jest.fn(), bind: jest.fn() } as never; const fetchTarget = { fetch: jest.fn() }; let warnSpy: jest.SpyInstance; beforeEach(() => { warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); }); afterEach(() => { warnSpy.mockRestore(); }); test('instruments axios when provided', () => { configureJourney({ axios: axios as never }); expect(instrumentAxiosMock).toHaveBeenCalledWith(axios); }); test('instruments fetch with defaults when fetch is true', () => { configureJourney({ fetch: true }); expect(instrumentFetchMock).toHaveBeenCalledWith({}); }); test('instruments fetch with options when provided', () => { configureJourney({ fetch: { target: fetchTarget, baseURL: 'https://api.example.com' }, }); expect(instrumentFetchMock).toHaveBeenCalledWith({ target: fetchTarget, baseURL: 'https://api.example.com', }); }); test('instruments jquery when provided', () => { configureJourney({ jquery }); expect(instrumentJqueryMock).toHaveBeenCalledWith(jquery); }); test('instruments global $ when jquery is true', () => { const $ = Object.assign(jest.fn(), { ajaxPrefilter: jest.fn() }); (globalThis as { $?: unknown }).$ = $; try { configureJourney({ jquery: true }); expect(instrumentJqueryMock).toHaveBeenCalledWith($); } finally { delete (globalThis as { $?: unknown }).$; } }); test('falls back to global jQuery when jquery is true and $ is absent', () => { const jQuery = Object.assign(jest.fn(), { ajaxPrefilter: jest.fn() }); (globalThis as { jQuery?: unknown }).jQuery = jQuery; try { configureJourney({ jquery: true }); expect(instrumentJqueryMock).toHaveBeenCalledWith(jQuery); } finally { delete (globalThis as { jQuery?: unknown }).jQuery; } }); test('skips jquery wiring when jquery is false', () => { configureJourney({ jquery: false }); expect(instrumentJqueryMock).not.toHaveBeenCalled(); }); test('exposes App.Journey when exposeAppJourney is true', () => { configureJourney({ exposeAppJourney: true }); expect(exposeAppJourneyMock).toHaveBeenCalledWith(undefined); }); test('exposes App.Journey on a custom target', () => { const target = { App: {} } as WindowWithAppJourney; configureJourney({ exposeAppJourney: target }); expect(exposeAppJourneyMock).toHaveBeenCalledWith(target); }); test('skips transport helpers when omitted', () => { configureJourney({ slowRequests: { defaultMs: 1_000, endpoints: [] } }); expect(instrumentAxiosMock).not.toHaveBeenCalled(); expect(instrumentFetchMock).not.toHaveBeenCalled(); expect(instrumentJqueryMock).not.toHaveBeenCalled(); expect(exposeAppJourneyMock).not.toHaveBeenCalled(); }); test('never throws on missing config', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); expect(() => configureJourney(null as never)).not.toThrow(); expect(() => configureJourney(undefined as never)).not.toThrow(); expect(warn).toHaveBeenCalledWith( expect.stringContaining('[journey] configureJourney: skipped — missing') ); }); test('skips when journey is disabled', () => { setJourneyDefaultEnabled(false); const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); configureJourney({ axios: axios as never, fetch: true }); expect(warn).toHaveBeenCalledWith( expect.stringContaining('[journey] configureJourney: skipped — disabled') ); expect(instrumentAxiosMock).not.toHaveBeenCalled(); expect(instrumentFetchMock).not.toHaveBeenCalled(); }); test('never throws when a transport step fails', () => { const error = jest.spyOn(console, 'error').mockImplementation(() => {}); instrumentAxiosMock.mockImplementationOnce(() => { throw new Error('axios boom'); }); expect(() => configureJourney({ axios: axios as never, jquery, exposeAppJourney: true, }) ).not.toThrow(); expect(error).toHaveBeenCalledWith( '[journey] configureJourney failed', expect.any(Error) ); }); }); }); describe('longestEndpointMatch', () => { // Lookup expects longest-match-first (configureJourney / startJourney sort for you). const endpoints = [ { match: '/api/invoices', slowRequestMs: 2_000 }, { match: '/api', slowRequestMs: 1_000 }, { match: '/other', ignore: true }, ]; test('returns undefined for empty / undefined lists', () => { expect(longestEndpointMatch(undefined, '/api')).toBeUndefined(); expect(longestEndpointMatch([], '/api')).toBeUndefined(); }); test('picks the longest matching prefix (first hit when sorted)', () => { expect(longestEndpointMatch(endpoints, '/api/invoices/42')?.slowRequestMs).toBe(2_000); expect(longestEndpointMatch(endpoints, '/api/jobs')?.slowRequestMs).toBe(1_000); }); test('returns undefined when nothing matches', () => { expect(longestEndpointMatch(endpoints, '/health')).toBeUndefined(); }); test('requires a path boundary after the prefix', () => { const user = [{ match: '/api/user', slowRequestMs: 1_000 }]; expect(longestEndpointMatch(user, '/api/user')?.slowRequestMs).toBe(1_000); expect(longestEndpointMatch(user, '/api/user/42')?.slowRequestMs).toBe(1_000); expect(longestEndpointMatch(user, '/api/user-preferences')).toBeUndefined(); expect(longestEndpointMatch(user, '/api/users')).toBeUndefined(); const trailed = [{ match: '/api/user/', slowRequestMs: 2_000 }]; expect(longestEndpointMatch(trailed, '/api/user/42')?.slowRequestMs).toBe(2_000); }); }); describe('configureJourney endpoint ordering', () => { beforeEach(() => { setJourneyDefaultEnabled(true); }); afterEach(() => { setJourneyDefaultEnabled(false); localStorage.clear(); resetSharedPolicies(); resetJourney({ sinks: [sendToDatadog, sendToConsole], slowRequests: { defaultMs: 4_000, endpoints: [] }, }); }); test('stores endpoints longest-match-first', () => { configureJourney({ slowRequests: { defaultMs: 4_000, endpoints: [ { match: '/api', slowRequestMs: 1_000 }, { match: '/api/invoices', slowRequestMs: 2_000 }, ], }, }); expect(getSlowRequests().endpoints?.map(e => e.match)).toEqual(['/api/invoices', '/api']); }); }); describe('[journey] sortEndpointsByLongestMatch', () => { test('returns undefined for undefined input', () => { expect(sortEndpointsByLongestMatch(undefined)).toBeUndefined(); }); test('returns a copy for a single endpoint', () => { const only = [{ match: '/api', slowRequestMs: 1_000 }]; const sorted = sortEndpointsByLongestMatch(only); expect(sorted).toEqual(only); expect(sorted).not.toBe(only); }); test('returns an empty array for an empty list', () => { expect(sortEndpointsByLongestMatch([])).toEqual([]); }); });