@servicetitan/journey 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +7 -0
  2. package/dist/__mocks__/test-runtime.d.ts +16 -0
  3. package/dist/__mocks__/test-runtime.d.ts.map +1 -0
  4. package/dist/__tests__/ambient.test.d.ts +2 -0
  5. package/dist/__tests__/ambient.test.d.ts.map +1 -0
  6. package/dist/__tests__/axios.test.d.ts +2 -0
  7. package/dist/__tests__/axios.test.d.ts.map +1 -0
  8. package/dist/__tests__/config.test.d.ts +2 -0
  9. package/dist/__tests__/config.test.d.ts.map +1 -0
  10. package/dist/__tests__/context.test.d.ts +2 -0
  11. package/dist/__tests__/context.test.d.ts.map +1 -0
  12. package/dist/__tests__/datadog.test.d.ts +2 -0
  13. package/dist/__tests__/datadog.test.d.ts.map +1 -0
  14. package/dist/__tests__/define-journey.test.d.ts +2 -0
  15. package/dist/__tests__/define-journey.test.d.ts.map +1 -0
  16. package/dist/__tests__/expose-app-journey.test.d.ts +2 -0
  17. package/dist/__tests__/expose-app-journey.test.d.ts.map +1 -0
  18. package/dist/__tests__/fetch.test.d.ts +2 -0
  19. package/dist/__tests__/fetch.test.d.ts.map +1 -0
  20. package/dist/__tests__/jquery.test.d.ts +2 -0
  21. package/dist/__tests__/jquery.test.d.ts.map +1 -0
  22. package/dist/__tests__/registry.test.d.ts +2 -0
  23. package/dist/__tests__/registry.test.d.ts.map +1 -0
  24. package/dist/__tests__/request-key.test.d.ts +2 -0
  25. package/dist/__tests__/request-key.test.d.ts.map +1 -0
  26. package/dist/__tests__/requests.test.d.ts +2 -0
  27. package/dist/__tests__/requests.test.d.ts.map +1 -0
  28. package/dist/__tests__/runtime.test.d.ts +2 -0
  29. package/dist/__tests__/runtime.test.d.ts.map +1 -0
  30. package/dist/__tests__/scope.test.d.ts +2 -0
  31. package/dist/__tests__/scope.test.d.ts.map +1 -0
  32. package/dist/__tests__/sinks.test.d.ts +2 -0
  33. package/dist/__tests__/sinks.test.d.ts.map +1 -0
  34. package/dist/__tests__/step.test.d.ts +2 -0
  35. package/dist/__tests__/step.test.d.ts.map +1 -0
  36. package/dist/__tests__/timeouts.test.d.ts +2 -0
  37. package/dist/__tests__/timeouts.test.d.ts.map +1 -0
  38. package/dist/config.d.ts +42 -0
  39. package/dist/config.d.ts.map +1 -0
  40. package/dist/config.js +46 -0
  41. package/dist/config.js.map +1 -0
  42. package/dist/core/index.d.ts +6 -0
  43. package/dist/core/index.d.ts.map +1 -0
  44. package/dist/core/index.js +5 -0
  45. package/dist/core/index.js.map +1 -0
  46. package/dist/core/registry.d.ts +50 -0
  47. package/dist/core/registry.d.ts.map +1 -0
  48. package/dist/core/registry.js +102 -0
  49. package/dist/core/registry.js.map +1 -0
  50. package/dist/core/runtime.d.ts +51 -0
  51. package/dist/core/runtime.d.ts.map +1 -0
  52. package/dist/core/runtime.js +401 -0
  53. package/dist/core/runtime.js.map +1 -0
  54. package/dist/core/step-tag.d.ts +16 -0
  55. package/dist/core/step-tag.d.ts.map +1 -0
  56. package/dist/core/step-tag.js +35 -0
  57. package/dist/core/step-tag.js.map +1 -0
  58. package/dist/core/types.d.ts +143 -0
  59. package/dist/core/types.d.ts.map +1 -0
  60. package/dist/core/types.js +3 -0
  61. package/dist/core/types.js.map +1 -0
  62. package/dist/endpoint-policy.d.ts +29 -0
  63. package/dist/endpoint-policy.d.ts.map +1 -0
  64. package/dist/endpoint-policy.js +39 -0
  65. package/dist/endpoint-policy.js.map +1 -0
  66. package/dist/index.d.ts +15 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.js +19 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/integrations/axios.d.ts +23 -0
  71. package/dist/integrations/axios.d.ts.map +1 -0
  72. package/dist/integrations/axios.js +51 -0
  73. package/dist/integrations/axios.js.map +1 -0
  74. package/dist/integrations/fetch.d.ts +21 -0
  75. package/dist/integrations/fetch.d.ts.map +1 -0
  76. package/dist/integrations/fetch.js +63 -0
  77. package/dist/integrations/fetch.js.map +1 -0
  78. package/dist/integrations/jquery/index.d.ts +22 -0
  79. package/dist/integrations/jquery/index.d.ts.map +1 -0
  80. package/dist/integrations/jquery/index.js +27 -0
  81. package/dist/integrations/jquery/index.js.map +1 -0
  82. package/dist/integrations/jquery/instrument.d.ts +18 -0
  83. package/dist/integrations/jquery/instrument.d.ts.map +1 -0
  84. package/dist/integrations/jquery/instrument.js +44 -0
  85. package/dist/integrations/jquery/instrument.js.map +1 -0
  86. package/dist/integrations/react/context.d.ts +6 -0
  87. package/dist/integrations/react/context.d.ts.map +1 -0
  88. package/dist/integrations/react/context.js +14 -0
  89. package/dist/integrations/react/context.js.map +1 -0
  90. package/dist/integrations/react/scope.d.ts +13 -0
  91. package/dist/integrations/react/scope.d.ts.map +1 -0
  92. package/dist/integrations/react/scope.js +27 -0
  93. package/dist/integrations/react/scope.js.map +1 -0
  94. package/dist/integrations/request-key.d.ts +17 -0
  95. package/dist/integrations/request-key.d.ts.map +1 -0
  96. package/dist/integrations/request-key.js +39 -0
  97. package/dist/integrations/request-key.js.map +1 -0
  98. package/dist/sinks/datadog.d.ts +6 -0
  99. package/dist/sinks/datadog.d.ts.map +1 -0
  100. package/dist/sinks/datadog.js +64 -0
  101. package/dist/sinks/datadog.js.map +1 -0
  102. package/package.json +44 -0
  103. package/src/__mocks__/test-runtime.ts +37 -0
  104. package/src/__tests__/ambient.test.ts +101 -0
  105. package/src/__tests__/axios.test.ts +365 -0
  106. package/src/__tests__/config.test.ts +261 -0
  107. package/src/__tests__/context.test.tsx +84 -0
  108. package/src/__tests__/datadog.test.ts +160 -0
  109. package/src/__tests__/define-journey.test.ts +176 -0
  110. package/src/__tests__/expose-app-journey.test.ts +149 -0
  111. package/src/__tests__/fetch.test.ts +264 -0
  112. package/src/__tests__/jquery.test.ts +327 -0
  113. package/src/__tests__/registry.test.ts +265 -0
  114. package/src/__tests__/request-key.test.ts +81 -0
  115. package/src/__tests__/requests.test.ts +337 -0
  116. package/src/__tests__/runtime.test.ts +273 -0
  117. package/src/__tests__/scope.test.tsx +128 -0
  118. package/src/__tests__/sinks.test.ts +99 -0
  119. package/src/__tests__/step.test.ts +305 -0
  120. package/src/__tests__/timeouts.test.ts +226 -0
  121. package/src/config.ts +87 -0
  122. package/src/core/index.ts +39 -0
  123. package/src/core/registry.ts +162 -0
  124. package/src/core/runtime.ts +489 -0
  125. package/src/core/step-tag.ts +24 -0
  126. package/src/core/types.ts +160 -0
  127. package/src/endpoint-policy.ts +66 -0
  128. package/src/index.ts +51 -0
  129. package/src/integrations/axios.ts +85 -0
  130. package/src/integrations/fetch.ts +97 -0
  131. package/src/integrations/jquery/index.ts +62 -0
  132. package/src/integrations/jquery/instrument.ts +84 -0
  133. package/src/integrations/react/context.ts +17 -0
  134. package/src/integrations/react/scope.tsx +32 -0
  135. package/src/integrations/request-key.ts +46 -0
  136. package/src/sinks/datadog.ts +71 -0
@@ -0,0 +1,264 @@
1
+ /** @jest-environment jsdom */
2
+ import { activeJourneyStep, reportBackendRequest, type JourneyStepRef } from '../core';
3
+ import { instrumentFetch, type InstrumentFetchOptions } from '../integrations/fetch';
4
+ import { requestKey } from '../integrations/request-key';
5
+
6
+ jest.mock('../core', () => ({
7
+ ...jest.requireActual('../core'),
8
+ activeJourneyStep: jest.fn(),
9
+ reportBackendRequest: jest.fn(),
10
+ }));
11
+ jest.mock('../integrations/request-key');
12
+
13
+ describe('[journey] instrumentFetch', () => {
14
+ const KEY = 'resolved-endpoint-key';
15
+ const step = { name: 'load' } as unknown as JourneyStepRef;
16
+
17
+ let target: { fetch: jest.Mock };
18
+ let originalFetch: jest.Mock;
19
+ let response: { status: number };
20
+ let input: RequestInfo | URL;
21
+ let init: RequestInit | undefined;
22
+ let options: InstrumentFetchOptions;
23
+ let now: number;
24
+
25
+ beforeEach(() => {
26
+ jest.clearAllMocks();
27
+
28
+ response = { status: 200 };
29
+ originalFetch = jest.fn().mockResolvedValue(response);
30
+ target = { fetch: originalFetch };
31
+
32
+ input = 'https://api.example.com/orders';
33
+ init = undefined;
34
+ options = { target };
35
+
36
+ jest.mocked(activeJourneyStep).mockReturnValue(step);
37
+ jest.mocked(requestKey).mockReturnValue(KEY);
38
+
39
+ // Deterministic clock: each globalThis.performance.now() advances by 100ms.
40
+ now = 1_000;
41
+ jest.spyOn(globalThis.performance, 'now').mockImplementation(() => (now += 100));
42
+ });
43
+
44
+ afterEach(() => {
45
+ jest.restoreAllMocks();
46
+ });
47
+
48
+ const subject = () => {
49
+ instrumentFetch(options);
50
+ return target.fetch(input, init);
51
+ };
52
+
53
+ test('replaces the target fetch', () => {
54
+ instrumentFetch(options);
55
+
56
+ expect(target.fetch).not.toBe(originalFetch);
57
+ });
58
+
59
+ test('is idempotent for the same target', async () => {
60
+ const first = target.fetch;
61
+ instrumentFetch(options);
62
+ const wrapped = target.fetch;
63
+ const restoreNoop = instrumentFetch(options);
64
+
65
+ expect(target.fetch).toBe(wrapped);
66
+ expect(target.fetch).not.toBe(first);
67
+
68
+ restoreNoop();
69
+ expect(target.fetch).toBe(wrapped);
70
+
71
+ await target.fetch(input, init);
72
+ expect(originalFetch).toHaveBeenCalledTimes(1);
73
+ expect(reportBackendRequest).toHaveBeenCalledTimes(1);
74
+ });
75
+
76
+ test('delegates to the original fetch with the same arguments', async () => {
77
+ init = { method: 'POST' };
78
+
79
+ await subject();
80
+
81
+ expect(originalFetch).toHaveBeenCalledWith(input, init);
82
+ });
83
+
84
+ test('returns the original response', async () => {
85
+ const result = await subject();
86
+
87
+ expect(result).toBe(response);
88
+ });
89
+
90
+ test('reports the finished request against the active step', async () => {
91
+ await subject();
92
+
93
+ expect(reportBackendRequest).toHaveBeenCalledWith(step, 200, 100, KEY);
94
+ });
95
+
96
+ describe('when no journey step is active', () => {
97
+ beforeEach(() => jest.mocked(activeJourneyStep).mockReturnValue(null));
98
+
99
+ test('does not report the request', async () => {
100
+ await subject();
101
+
102
+ expect(reportBackendRequest).not.toHaveBeenCalled();
103
+ });
104
+
105
+ test('still delegates to the original fetch', async () => {
106
+ await subject();
107
+
108
+ expect(originalFetch).toHaveBeenCalledWith(input, init);
109
+ });
110
+ });
111
+
112
+ describe('when the active step changes before the response settles', () => {
113
+ beforeEach(() => {
114
+ originalFetch.mockImplementation(() => {
115
+ // The step captured at call time must win over whatever is active later.
116
+ jest.mocked(activeJourneyStep).mockReturnValue({ name: 'other' } as JourneyStepRef);
117
+ return Promise.resolve(response);
118
+ });
119
+ });
120
+
121
+ test('reports against the step captured at call time', async () => {
122
+ await subject();
123
+
124
+ expect(reportBackendRequest).toHaveBeenCalledWith(step, 200, 100, KEY);
125
+ });
126
+ });
127
+
128
+ describe('when the fetch rejects', () => {
129
+ const error = new Error('network down');
130
+
131
+ beforeEach(() => originalFetch.mockRejectedValue(error));
132
+
133
+ test('re-throws the original error', async () => {
134
+ await expect(subject()).rejects.toBe(error);
135
+ });
136
+
137
+ test('reports the failure with an undefined status', async () => {
138
+ await subject().catch(() => {});
139
+
140
+ expect(reportBackendRequest).toHaveBeenCalledWith(step, undefined, 100, KEY);
141
+ });
142
+ });
143
+
144
+ describe('endpoint key resolution', () => {
145
+ const baseURL = 'https://api.example.com';
146
+
147
+ beforeEach(() => (options.baseURL = baseURL));
148
+
149
+ describe('with a string url', () => {
150
+ beforeEach(() => (input = 'https://api.example.com/orders'));
151
+
152
+ test('passes the string and baseURL to requestKey', async () => {
153
+ await subject();
154
+
155
+ expect(requestKey).toHaveBeenCalledWith(
156
+ 'https://api.example.com/orders',
157
+ undefined,
158
+ baseURL
159
+ );
160
+ });
161
+ });
162
+
163
+ describe('with a URL object', () => {
164
+ beforeEach(() => (input = new URL('https://api.example.com/orders')));
165
+
166
+ test('passes its href to requestKey', async () => {
167
+ await subject();
168
+
169
+ expect(requestKey).toHaveBeenCalledWith(
170
+ 'https://api.example.com/orders',
171
+ undefined,
172
+ baseURL
173
+ );
174
+ });
175
+ });
176
+
177
+ describe('with a Request object', () => {
178
+ let request: Request;
179
+
180
+ beforeEach(() => {
181
+ request = new Request('https://api.example.com/orders');
182
+ input = request;
183
+ });
184
+
185
+ test('passes its url to requestKey', async () => {
186
+ await subject();
187
+
188
+ expect(requestKey).toHaveBeenCalledWith(request.url, undefined, baseURL);
189
+ });
190
+ });
191
+
192
+ describe('with an unrecognized input', () => {
193
+ beforeEach(() => (input = { notAUrl: true } as unknown as RequestInfo));
194
+
195
+ test('passes undefined to requestKey', async () => {
196
+ await subject();
197
+
198
+ expect(requestKey).toHaveBeenCalledWith(undefined, undefined, baseURL);
199
+ });
200
+ });
201
+ });
202
+
203
+ describe('when init carries an explicit journeyStep', () => {
204
+ const explicit = { name: 'explicit' } as unknown as JourneyStepRef;
205
+
206
+ beforeEach(() => {
207
+ init = { journeyStep: explicit } as RequestInit;
208
+ jest.mocked(activeJourneyStep).mockReturnValue(step);
209
+ });
210
+
211
+ test('prefers the explicit step over ambient', async () => {
212
+ await subject();
213
+
214
+ expect(reportBackendRequest).toHaveBeenCalledWith(explicit, 200, 100, KEY);
215
+ });
216
+ });
217
+
218
+ describe('the returned restore function', () => {
219
+ test('puts the original fetch back', () => {
220
+ const restore = instrumentFetch(options);
221
+ restore();
222
+
223
+ expect(target.fetch).toBe(originalFetch);
224
+ });
225
+
226
+ test('allows re-instrumentation after restore', () => {
227
+ const restore = instrumentFetch(options);
228
+ restore();
229
+ instrumentFetch(options);
230
+
231
+ expect(target.fetch).not.toBe(originalFetch);
232
+ });
233
+
234
+ test('is a no-op when restore is called twice', () => {
235
+ const restore = instrumentFetch(options);
236
+ restore();
237
+ target.fetch = originalFetch;
238
+ restore();
239
+
240
+ expect(target.fetch).toBe(originalFetch);
241
+ });
242
+ });
243
+
244
+ describe('without an explicit target', () => {
245
+ let originalGlobalFetch: typeof globalThis.fetch;
246
+
247
+ beforeEach(() => {
248
+ originalGlobalFetch = globalThis.fetch;
249
+ options = {};
250
+ });
251
+
252
+ afterEach(() => {
253
+ globalThis.fetch = originalGlobalFetch;
254
+ });
255
+
256
+ test('wraps the global fetch', () => {
257
+ const restore = instrumentFetch(options);
258
+
259
+ expect(globalThis.fetch).not.toBe(originalGlobalFetch);
260
+
261
+ restore();
262
+ });
263
+ });
264
+ });
@@ -0,0 +1,327 @@
1
+ /** @jest-environment jsdom */
2
+ import { useJourneyTestRuntime } from '../__mocks__/test-runtime';
3
+ import { completeJourney, journeyStep, startJourney } from '../core';
4
+ import { instrumentJquery } from '../integrations/jquery/instrument';
5
+
6
+ const emitMock = jest.fn();
7
+
8
+ function createJqueryMock() {
9
+ const prefilters: ((o: any, orig: any, xhr: any) => void)[] = [];
10
+ const completes: ((event: unknown, xhr: any, settings: any) => void)[] = [];
11
+
12
+ const $ = ((_selector: Document) => ({
13
+ ajaxComplete(handler: (event: unknown, xhr: any, settings: any) => void) {
14
+ completes.push(handler);
15
+ },
16
+ })) as any;
17
+
18
+ $.ajaxPrefilter = (handler: (o: any, orig: any, xhr: any) => void) => {
19
+ prefilters.push(handler);
20
+ };
21
+ $.__prefilters = prefilters;
22
+ $.__completes = completes;
23
+ return $;
24
+ }
25
+
26
+ function simulateAjax($: any, url: string, status: number, durationMs = 10) {
27
+ const xhr = { status };
28
+ const settings = { url };
29
+ const start = globalThis.performance.now();
30
+
31
+ for (const prefilter of $.__prefilters) {
32
+ prefilter(settings, settings, xhr);
33
+ }
34
+
35
+ (globalThis.performance.now as jest.Mock).mockReturnValue(start + durationMs);
36
+
37
+ for (const complete of $.__completes) {
38
+ complete({}, xhr, settings);
39
+ }
40
+ }
41
+
42
+ describe('[journey] instrumentJquery', () => {
43
+ useJourneyTestRuntime(emitMock);
44
+
45
+ beforeEach(() => {
46
+ jest.spyOn(globalThis.performance, 'now').mockReturnValue(1_000);
47
+ });
48
+
49
+ afterEach(() => {
50
+ jest.restoreAllMocks();
51
+ });
52
+
53
+ test('is idempotent for the same $ instance', () => {
54
+ const $ = createJqueryMock();
55
+ instrumentJquery($);
56
+ instrumentJquery($);
57
+
58
+ expect($.__prefilters).toHaveLength(1);
59
+ expect($.__completes).toHaveLength(1);
60
+ });
61
+
62
+ test('ignores ajax outside an active journey step', () => {
63
+ const $ = createJqueryMock();
64
+ instrumentJquery($);
65
+
66
+ startJourney({
67
+ name: 'jq-idle',
68
+ team: 'test',
69
+ group: 'test',
70
+ service: 'test',
71
+ timeoutMs: 0,
72
+ requestTimeoutMs: 50,
73
+ });
74
+
75
+ simulateAjax($, '/CallScreen/BookJob', 500);
76
+ completeJourney('jq-idle');
77
+
78
+ expect(emitMock).toHaveBeenCalledTimes(1);
79
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
80
+ });
81
+
82
+ test('marks journey bad on 5xx inside a journey step', async () => {
83
+ const $ = createJqueryMock();
84
+ instrumentJquery($);
85
+
86
+ startJourney({
87
+ name: 'jq-5xx',
88
+ team: 'test',
89
+ group: 'test',
90
+ service: 'test',
91
+ timeoutMs: 0,
92
+ requestTimeoutMs: 5_000,
93
+ });
94
+
95
+ await journeyStep('jq-5xx', 'book-job', () => {
96
+ simulateAjax($, '/CallScreen/BookJob', 500, 20);
97
+ });
98
+
99
+ expect(emitMock).toHaveBeenCalledTimes(1);
100
+ const event = emitMock.mock.calls[0][0].journey;
101
+ expect(event.outcome).toBe('bad');
102
+ expect(event.reason).toBe('request-error');
103
+ });
104
+
105
+ test('marks journey bad on slow request inside a journey step', async () => {
106
+ const $ = createJqueryMock();
107
+ instrumentJquery($);
108
+
109
+ startJourney({
110
+ name: 'jq-slow',
111
+ team: 'test',
112
+ group: 'test',
113
+ service: 'test',
114
+ timeoutMs: 0,
115
+ requestTimeoutMs: 50,
116
+ });
117
+
118
+ await journeyStep('jq-slow', 'book-job', () => {
119
+ simulateAjax($, '/CallScreen/BookJob', 200, 200);
120
+ });
121
+
122
+ expect(emitMock).toHaveBeenCalledTimes(1);
123
+ const event = emitMock.mock.calls[0][0].journey;
124
+ expect(event.outcome).toBe('bad');
125
+ expect(event.reason).toBe('request-latency');
126
+ });
127
+
128
+ test('treats status 0 as a network/request error', async () => {
129
+ const $ = createJqueryMock();
130
+ instrumentJquery($);
131
+
132
+ startJourney({
133
+ name: 'jq-network',
134
+ team: 'test',
135
+ group: 'test',
136
+ service: 'test',
137
+ timeoutMs: 0,
138
+ requestTimeoutMs: 5_000,
139
+ });
140
+
141
+ await journeyStep('jq-network', 'book-job', () => {
142
+ simulateAjax($, '/CallScreen/BookJob', 0, 10);
143
+ });
144
+
145
+ expect(emitMock).toHaveBeenCalledTimes(1);
146
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
147
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
148
+ });
149
+
150
+ test('does not mark bad on 4xx (user/validation)', async () => {
151
+ const $ = createJqueryMock();
152
+ instrumentJquery($);
153
+
154
+ startJourney({
155
+ name: 'jq-4xx',
156
+ team: 'test',
157
+ group: 'test',
158
+ service: 'test',
159
+ timeoutMs: 0,
160
+ requestTimeoutMs: 5_000,
161
+ });
162
+
163
+ await journeyStep('jq-4xx', 'book-job', () => {
164
+ simulateAjax($, '/CallScreen/BookJob', 400, 10);
165
+ });
166
+ completeJourney('jq-4xx');
167
+
168
+ expect(emitMock).toHaveBeenCalledTimes(1);
169
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
170
+ });
171
+
172
+ test('honors an explicit journeyStep already stamped on the XHR', async () => {
173
+ const $ = createJqueryMock();
174
+ instrumentJquery($);
175
+
176
+ startJourney({
177
+ name: 'jq-explicit',
178
+ team: 'test',
179
+ group: 'test',
180
+ service: 'test',
181
+ timeoutMs: 0,
182
+ requestTimeoutMs: 5_000,
183
+ });
184
+
185
+ await journeyStep('jq-explicit', 'book-job', step => {
186
+ const tagged = step.tag() as { journeyStep: unknown };
187
+ const xhr: {
188
+ status: number;
189
+ journeyStep: unknown;
190
+ journeyStart?: number;
191
+ } = { status: 500, journeyStep: tagged.journeyStep };
192
+ const settings = { url: '/CallScreen/BookJob' };
193
+ const start = globalThis.performance.now();
194
+ for (const prefilter of $.__prefilters) {
195
+ prefilter(settings, settings, xhr);
196
+ }
197
+ // Prefilter must not clear the explicit step; it should stamp a start time.
198
+ expect(xhr.journeyStep).toBe(tagged.journeyStep);
199
+ expect(xhr.journeyStart).toBeDefined();
200
+ (globalThis.performance.now as jest.Mock).mockReturnValue(start + 20);
201
+ for (const complete of $.__completes) {
202
+ complete({}, xhr, settings);
203
+ }
204
+ });
205
+
206
+ expect(emitMock).toHaveBeenCalledTimes(1);
207
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
208
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
209
+ });
210
+
211
+ test('falls back to performance.now when journeyStart is missing', async () => {
212
+ const $ = createJqueryMock();
213
+ instrumentJquery($);
214
+
215
+ startJourney({
216
+ name: 'jq-no-start',
217
+ team: 'test',
218
+ group: 'test',
219
+ service: 'test',
220
+ timeoutMs: 0,
221
+ requestTimeoutMs: 5_000,
222
+ });
223
+
224
+ await journeyStep('jq-no-start', 'book-job', step => {
225
+ const tagged = step.tag() as { journeyStep: unknown };
226
+ // Bypass prefilter stamping — only fire ajaxComplete with a step and no start.
227
+ const xhr = { status: 200, journeyStep: tagged.journeyStep };
228
+ for (const complete of $.__completes) {
229
+ complete({}, xhr, { url: '/ok' });
230
+ }
231
+ });
232
+ completeJourney('jq-no-start');
233
+
234
+ expect(emitMock).toHaveBeenCalledTimes(1);
235
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
236
+ });
237
+
238
+ test('honors step.tag() on ajax settings without deep-merge stack overflow', async () => {
239
+ const $ = createJqueryMock();
240
+ instrumentJquery($);
241
+
242
+ // Mimic jQuery.ajaxSetup: deep-merge plain objects only (class instances by ref).
243
+ function isPlainObject(obj: unknown): obj is Record<string, unknown> {
244
+ return (
245
+ !!obj && typeof obj === 'object' && Object.getPrototypeOf(obj) === Object.prototype
246
+ );
247
+ }
248
+ function deepExtend(target: Record<string, unknown>, ...sources: unknown[]) {
249
+ for (const source of sources) {
250
+ if (!source || typeof source !== 'object') {
251
+ continue;
252
+ }
253
+ for (const key of Object.keys(source as object)) {
254
+ // Block prototype-polluting keys (CodeQL js/prototype-pollution-utility).
255
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
256
+ continue;
257
+ }
258
+ const copy = (source as Record<string, unknown>)[key];
259
+ if (isPlainObject(copy)) {
260
+ const dest = isPlainObject(target[key])
261
+ ? (target[key] as Record<string, unknown>)
262
+ : {};
263
+ target[key] = deepExtend(dest, copy);
264
+ } else {
265
+ target[key] = copy;
266
+ }
267
+ }
268
+ }
269
+ return target;
270
+ }
271
+
272
+ startJourney({
273
+ name: 'jq-settings-tag',
274
+ team: 'test',
275
+ group: 'test',
276
+ service: 'test',
277
+ timeoutMs: 0,
278
+ requestTimeoutMs: 5_000,
279
+ });
280
+
281
+ await journeyStep('jq-settings-tag', 'book-job', step => {
282
+ const tagged = step.tag() as { journeyStep: unknown };
283
+ expect(() => deepExtend({}, tagged)).not.toThrow();
284
+ const settings = deepExtend({ url: '/CallScreen/BookJob' }, tagged);
285
+ const xhr: { status: number; journeyStep?: unknown; journeyStart?: number } = {
286
+ status: 500,
287
+ };
288
+ const start = globalThis.performance.now();
289
+ for (const prefilter of $.__prefilters) {
290
+ prefilter(settings, settings, xhr);
291
+ }
292
+ expect(xhr.journeyStep).toBe(tagged.journeyStep);
293
+ (globalThis.performance.now as jest.Mock).mockReturnValue(start + 20);
294
+ for (const complete of $.__completes) {
295
+ complete({}, xhr, settings);
296
+ }
297
+ });
298
+
299
+ expect(emitMock).toHaveBeenCalledTimes(1);
300
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
301
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
302
+ });
303
+
304
+ test('keys cross-origin ajax urls as host/path', async () => {
305
+ const $ = createJqueryMock();
306
+ instrumentJquery($);
307
+
308
+ startJourney({
309
+ name: 'jq-cross',
310
+ team: 'test',
311
+ group: 'test',
312
+ service: 'test',
313
+ timeoutMs: 0,
314
+ requestTimeoutMs: 5_000,
315
+ endpoints: [{ match: 'pdf.vendor.com/render', ignore: true }],
316
+ });
317
+
318
+ await journeyStep('jq-cross', 'book-job', () => {
319
+ simulateAjax($, 'https://pdf.vendor.com/render', 500, 20);
320
+ });
321
+ completeJourney('jq-cross');
322
+
323
+ // Ignored endpoint — 5xx must not close the journey as bad.
324
+ expect(emitMock).toHaveBeenCalledTimes(1);
325
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
326
+ });
327
+ });