/** @jest-environment jsdom */ import { baseJourneyDef, useJourneyTestRuntime } from '../../__test-utils__/test-runtime'; import { completeJourney, journeyStep } from '../../core'; import { moduleRuntime } from '../../core/runtime'; import { getSharedPolicies } from '../../global'; import { instrumentJquery } from '../../integrations/jquery/instrument'; const emitMock = jest.fn(); function createJqueryMock() { const prefilters: ((o: any, orig: any, xhr: any) => void)[] = []; const completes: ((event: unknown, xhr: any, settings: any) => void)[] = []; const $ = ((_selector: Document) => ({ ajaxComplete(handler: (event: unknown, xhr: any, settings: any) => void) { completes.push(handler); }, })) as any; $.ajaxPrefilter = (handler: (o: any, orig: any, xhr: any) => void) => { prefilters.push(handler); }; $.__prefilters = prefilters; $.__completes = completes; return $; } function simulateAjax($: any, url: string, status: number, durationMs = 10) { const xhr = { status }; const settings = { url }; const start = globalThis.performance.now(); for (const prefilter of $.__prefilters) { prefilter(settings, settings, xhr); } (globalThis.performance.now as jest.Mock).mockReturnValue(start + durationMs); for (const complete of $.__completes) { complete({}, xhr, settings); } } describe('[journey] instrumentJquery', () => { useJourneyTestRuntime(emitMock); beforeEach(() => { jest.spyOn(globalThis.performance, 'now').mockReturnValue(1_000); }); afterEach(() => { jest.restoreAllMocks(); }); test('is idempotent for the same $ instance', () => { const $ = createJqueryMock(); instrumentJquery($); instrumentJquery($); expect($.__prefilters).toHaveLength(1); expect($.__completes).toHaveLength(1); }); test('restore then rewire does not stack prefilters', () => { const $ = createJqueryMock(); const restore = instrumentJquery($); expect($.__prefilters).toHaveLength(1); restore(); instrumentJquery($); expect($.__prefilters).toHaveLength(1); expect($.__completes).toHaveLength(1); }); test.each([undefined, null])('warns and soft-skips when $ is missing (%p)', bad => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const error = jest.spyOn(console, 'error').mockImplementation(() => {}); expect(() => instrumentJquery(bad)).not.toThrow(); expect(warn).toHaveBeenCalledWith( expect.stringContaining('[journey] instrumentJquery: skipped — missing') ); expect(error).not.toHaveBeenCalled(); }); test.each([{}, { ajaxPrefilter: 1 }, { ajaxPrefilter: undefined }])( 'errors and soft-skips when $ has invalid shape (%p)', bad => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const error = jest.spyOn(console, 'error').mockImplementation(() => {}); expect(() => instrumentJquery(bad as never)).not.toThrow(); expect(error).toHaveBeenCalledWith( expect.stringContaining('[journey] instrumentJquery: skipped — invalid shape (') ); expect(error.mock.calls[0]).toHaveLength(1); expect(warn).not.toHaveBeenCalled(); } ); test('does not re-register handlers after a partial wire failure', () => { const $ = createJqueryMock(); const error = jest.spyOn(console, 'error').mockImplementation(() => {}); const throwing = Object.assign(function throwAjaxComplete() { throw new Error('ajaxComplete boom'); }, $) as ReturnType; throwing.ajaxPrefilter = $.ajaxPrefilter; throwing.__prefilters = $.__prefilters; throwing.__completes = $.__completes; instrumentJquery(throwing); expect(throwing.__prefilters).toHaveLength(1); instrumentJquery(throwing); expect(throwing.__prefilters).toHaveLength(1); expect(error).toHaveBeenCalled(); }); test('restore after a partial wire failure allows a later retry', () => { const $ = createJqueryMock(); const error = jest.spyOn(console, 'error').mockImplementation(() => {}); let boom = true; const flaky = Object.assign(function maybeThrow(selector: Document) { if (boom) { throw new Error('ajaxComplete boom'); } return $(selector); }, $) as ReturnType; flaky.ajaxPrefilter = $.ajaxPrefilter; flaky.__prefilters = $.__prefilters; flaky.__completes = $.__completes; const restore = instrumentJquery(flaky); expect(flaky.__prefilters).toHaveLength(1); expect(flaky.__completes).toHaveLength(0); restore(); boom = false; instrumentJquery(flaky); expect(flaky.__prefilters).toHaveLength(1); expect(flaky.__completes).toHaveLength(1); expect(error).toHaveBeenCalled(); }); test('ignores ajax outside an active journey step', () => { const $ = createJqueryMock(); instrumentJquery($); moduleRuntime.startJourney({ name: 'jq-idle', team: 'test', group: 'test', service: 'test', timeoutMs: 0, slowRequestMs: 50, }); simulateAjax($, '/CallScreen/BookJob', 500); completeJourney('jq-idle'); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good'); }); test('ignore: true on ajax settings skips a 5xx', async () => { const $ = createJqueryMock(); instrumentJquery($); moduleRuntime.startJourney({ name: 'jq-meta-ignore', team: 'test', group: 'test', service: 'test', timeoutMs: 0, slowRequestMs: 50, }); await journeyStep('jq-meta-ignore', 'book-job', () => { const xhr = { status: 500 }; const settings = { url: '/telemetry', ignore: true }; for (const prefilter of $.__prefilters) { prefilter(settings, settings, xhr); } for (const complete of $.__completes) { complete({}, xhr, settings); } }); completeJourney('jq-meta-ignore'); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good'); }); test('step.stamp({ ignore: true }) skips a 5xx', async () => { const $ = createJqueryMock(); instrumentJquery($); moduleRuntime.startJourney({ name: 'jq-stamp-ignore', team: 'test', group: 'test', service: 'test', timeoutMs: 0, slowRequestMs: 50, }); await journeyStep('jq-stamp-ignore', 'book-job', step => { const tagged = step.stamp({ ignore: true }); const xhr = { status: 500, stamp: tagged }; const settings = { url: '/CallScreen/BookJob', stamp: tagged }; for (const prefilter of $.__prefilters) { prefilter(settings, settings, xhr); } for (const complete of $.__completes) { complete({}, xhr, settings); } }); completeJourney('jq-stamp-ignore'); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good'); }); test('stamped stamp is JSON-safe on jqXHR and ajax settings', async () => { const $ = createJqueryMock(); instrumentJquery($); moduleRuntime.startJourney({ name: 'jq-json-safe', team: 'test', group: 'test', service: 'test', timeoutMs: 0, slowRequestMs: 5_000, }); await journeyStep('jq-json-safe', 'book-job', step => { // Ambient stamp lands on jqXHR — common to serialize for logging. const ambientXhr: { status: number; stamp?: unknown; journeyStart?: number; } = { status: 200 }; const ambientSettings = { url: '/CallScreen/BookJob' }; for (const prefilter of $.__prefilters) { prefilter(ambientSettings, ambientSettings, ambientXhr); } expect(ambientXhr.stamp).toBeUndefined(); expect(JSON.parse(JSON.stringify(ambientXhr)).stamp).toBeUndefined(); // Explicit step.stamp() spread into ajax settings — stripped after capture. const tagged = step.stamp(); const settings = { url: '/CallScreen/BookJob', stamp: tagged }; for (const prefilter of $.__prefilters) { prefilter(settings, settings, { status: 200 }); } expect(settings.stamp).toBeUndefined(); }); completeJourney('jq-json-safe'); }); test('marks journey bad on 5xx inside a journey step', async () => { const $ = createJqueryMock(); instrumentJquery($); moduleRuntime.startJourney({ name: 'jq-5xx', team: 'test', group: 'test', service: 'test', timeoutMs: 0, slowRequestMs: 5_000, }); await journeyStep('jq-5xx', 'book-job', () => { simulateAjax($, '/CallScreen/BookJob', 500, 20); }); expect(emitMock).toHaveBeenCalledTimes(1); const event = emitMock.mock.calls[0][0].journey; expect(event.outcome).toBe('bad'); expect(event.reason).toBe('request-error'); }); test('marks journey bad on slow request inside a journey step', async () => { const $ = createJqueryMock(); instrumentJquery($); moduleRuntime.startJourney({ name: 'jq-slow', team: 'test', group: 'test', service: 'test', timeoutMs: 0, slowRequestMs: 50, }); await journeyStep('jq-slow', 'book-job', () => { simulateAjax($, '/CallScreen/BookJob', 200, 200); }); expect(emitMock).toHaveBeenCalledTimes(1); const event = emitMock.mock.calls[0][0].journey; expect(event.outcome).toBe('bad'); expect(event.reason).toBe('request-latency'); }); test('treats status 0 as a network/request error', async () => { const $ = createJqueryMock(); instrumentJquery($); moduleRuntime.startJourney({ name: 'jq-network', team: 'test', group: 'test', service: 'test', timeoutMs: 0, slowRequestMs: 5_000, }); await journeyStep('jq-network', 'book-job', () => { simulateAjax($, '/CallScreen/BookJob', 0, 10); }); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad'); expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error'); }); test('does not mark bad on 4xx (user/validation)', async () => { const $ = createJqueryMock(); instrumentJquery($); moduleRuntime.startJourney({ name: 'jq-4xx', team: 'test', group: 'test', service: 'test', timeoutMs: 0, slowRequestMs: 5_000, }); await journeyStep('jq-4xx', 'book-job', () => { simulateAjax($, '/CallScreen/BookJob', 400, 10); }); completeJourney('jq-4xx'); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good'); }); test('marks request-aborted when jqXHR statusText is abort', async () => { getSharedPolicies().httpAbortedRequests = 'bad'; const $ = createJqueryMock(); instrumentJquery($); moduleRuntime.startJourney({ name: 'jq-abort', team: 'test', group: 'test', service: 'test', timeoutMs: 0, slowRequestMs: 5_000, }); await journeyStep('jq-abort', 'book-job', () => { const xhr = { status: 0, statusText: 'abort' }; const settings = { url: '/CallScreen/BookJob' }; const start = globalThis.performance.now(); for (const prefilter of $.__prefilters) { prefilter(settings, settings, xhr); } (globalThis.performance.now as jest.Mock).mockReturnValue(start + 10); for (const complete of $.__completes) { complete({}, xhr, settings); } }); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad'); expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-aborted'); }); test('honors an explicit stamp already stamped on the XHR', async () => { const $ = createJqueryMock(); instrumentJquery($); moduleRuntime.startJourney({ name: 'jq-explicit', team: 'test', group: 'test', service: 'test', timeoutMs: 0, slowRequestMs: 5_000, }); await journeyStep('jq-explicit', 'book-job', step => { const tagged = step.stamp(); const xhr: { status: number; stamp: unknown; journeyStart?: number; } = { status: 500, stamp: tagged }; const settings = { url: '/CallScreen/BookJob' }; const start = globalThis.performance.now(); for (const prefilter of $.__prefilters) { prefilter(settings, settings, xhr); } // Prefilter must not clear the explicit step; it should stamp a start time. expect(xhr.stamp).toBeUndefined(); expect(xhr.journeyStart).toBeDefined(); (globalThis.performance.now as jest.Mock).mockReturnValue(start + 20); for (const complete of $.__completes) { complete({}, xhr, settings); } }); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad'); expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error'); }); test('falls back to performance.now when journeyStart is missing', async () => { const $ = createJqueryMock(); instrumentJquery($); moduleRuntime.startJourney({ name: 'jq-no-start', team: 'test', group: 'test', service: 'test', timeoutMs: 0, slowRequestMs: 5_000, }); await journeyStep('jq-no-start', 'book-job', step => { const tagged = step.stamp(); // Bypass prefilter stamping — only fire ajaxComplete with a step and no start. const xhr = { status: 200, stamp: tagged }; for (const complete of $.__completes) { complete({}, xhr, { url: '/ok' }); } }); completeJourney('jq-no-start'); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good'); }); test('honors step.stamp() on ajax settings without deep-merge stack overflow', async () => { const $ = createJqueryMock(); instrumentJquery($); // Mimic jQuery.ajaxSetup: deep-merge plain objects only (class instances by ref). function isPlainObject(obj: unknown): obj is Record { return ( !!obj && typeof obj === 'object' && Object.getPrototypeOf(obj) === Object.prototype ); } function deepExtend(target: Record, ...sources: unknown[]) { for (const source of sources) { if (!source || typeof source !== 'object') { continue; } for (const key of Object.keys(source as object)) { // Block prototype-polluting keys (CodeQL js/prototype-pollution-utility). if (key === '__proto__' || key === 'constructor' || key === 'prototype') { continue; } const copy = (source as Record)[key]; if (isPlainObject(copy)) { const dest = isPlainObject(target[key]) ? (target[key] as Record) : {}; target[key] = deepExtend(dest, copy); } else { target[key] = copy; } } } return target; } moduleRuntime.startJourney({ name: 'jq-settings-tag', team: 'test', group: 'test', service: 'test', timeoutMs: 0, slowRequestMs: 5_000, }); await journeyStep('jq-settings-tag', 'book-job', step => { const tagged = step.stamp(); expect(() => deepExtend({}, { stamp: tagged })).not.toThrow(); const settings = deepExtend({ url: '/CallScreen/BookJob' }, { stamp: tagged }); const xhr: { status: number; stamp?: unknown; journeyStart?: number } = { status: 500, }; const start = globalThis.performance.now(); for (const prefilter of $.__prefilters) { prefilter(settings, settings, xhr); } expect(xhr.stamp).toBeUndefined(); (globalThis.performance.now as jest.Mock).mockReturnValue(start + 20); for (const complete of $.__completes) { complete({}, xhr, settings); } }); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad'); expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error'); }); test('keys cross-origin ajax urls as host/path', async () => { const $ = createJqueryMock(); instrumentJquery($); moduleRuntime.startJourney({ name: 'jq-cross', team: 'test', group: 'test', service: 'test', timeoutMs: 0, slowRequestMs: 5_000, endpoints: [{ match: 'pdf.vendor.com/render', ignore: true }], }); await journeyStep('jq-cross', 'book-job', () => { simulateAjax($, 'https://pdf.vendor.com/render', 500, 20); }); completeJourney('jq-cross'); // Ignored endpoint — 5xx must not close the journey as bad. expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good'); }); }); describe('[journey] jQuery early response correlation', () => { useJourneyTestRuntime(emitMock); function pendingAjax($: ReturnType, stamp: unknown) { const callbacks: (() => void)[] = []; const xhr = { status: 500, getResponseHeader: jest.fn().mockReturnValue('0123456789abcdef-IAD'), always(callback: () => void) { callbacks.push(callback); }, }; const settings = { url: 'https://go.servicetitan.com/test', stamp }; for (const prefilter of $.__prefilters) { prefilter(settings, settings, xhr); } return { xhr, settings, callbacks }; } test('captures before a synchronous application failure and does not duplicate at ajaxComplete', async () => { const $ = createJqueryMock(); const restore = instrumentJquery($); try { await journeyStep({ ...baseJourneyDef, name: 'jq-early' }, 'save', step => { const { xhr, settings, callbacks } = pendingAjax($, step.stamp()); callbacks.forEach(callback => callback()); expect(emitMock).not.toHaveBeenCalled(); step.failJourney('application-error'); for (const complete of $.__completes) { complete({}, xhr, settings); } }); const event = emitMock.mock.calls[0][0].journey; expect(emitMock).toHaveBeenCalledTimes(1); expect(event.reason).toBe('application-error'); expect(event.steps[0].requests).toHaveLength(1); expect(event.steps[0].requests[0].rayId).toBe('0123456789abcdef-iad'); } finally { restore(); } }); test('keeps scoring after application callbacks, as before', async () => { const $ = createJqueryMock(); const restore = instrumentJquery($); try { await journeyStep({ ...baseJourneyDef, name: 'jq-scoring' }, 'save', step => { const { xhr, settings, callbacks } = pendingAjax($, step.stamp()); callbacks.forEach(callback => callback()); expect(emitMock).not.toHaveBeenCalled(); for (const complete of $.__completes) { complete({}, xhr, settings); } }); const event = emitMock.mock.calls[0][0].journey; expect(event.reason).toBe('request-error'); expect(event.steps[0].requests).toHaveLength(1); } finally { restore(); } }); test('restore prevents early observers from modifying outstanding requests', async () => { const $ = createJqueryMock(); const restore = instrumentJquery($); await journeyStep({ ...baseJourneyDef, name: 'jq-restored' }, 'save', step => { const { callbacks } = pendingAjax($, step.stamp()); restore(); callbacks.forEach(callback => callback()); step.failJourney('application-error'); }); expect(emitMock.mock.calls[0][0].journey.steps[0].requests).toBeUndefined(); }); test('a throwing header reader cannot stop the application callback', async () => { const $ = createJqueryMock(); const restore = instrumentJquery($); try { await journeyStep({ ...baseJourneyDef, name: 'jq-blocked' }, 'save', step => { const { xhr, callbacks } = pendingAjax($, step.stamp()); xhr.getResponseHeader.mockImplementation(() => { throw new Error('blocked'); }); expect(() => callbacks.forEach(callback => callback())).not.toThrow(); step.failJourney('application-error'); }); expect(emitMock.mock.calls[0][0].journey.steps[0].requests).toBeUndefined(); } finally { restore(); } }); });