@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,365 @@
1
+ /** @jest-environment jsdom */
2
+ import { useJourneyTestRuntime } from '../__mocks__/test-runtime';
3
+ import { completeJourney, JourneyStepTag, journeyStep, startJourney } from '../core';
4
+ import { instrumentAxios, type AxiosLikeInstance } from '../integrations/axios';
5
+
6
+ const emitMock = jest.fn();
7
+
8
+ type RequestHandler = (config: any) => any;
9
+ type ResponseOk = (response: any) => any;
10
+ type ResponseErr = (error: any) => any;
11
+
12
+ function createAxiosMock(): AxiosLikeInstance & {
13
+ requestHandlers: RequestHandler[];
14
+ responseOkHandlers: ResponseOk[];
15
+ responseErrHandlers: ResponseErr[];
16
+ } {
17
+ const request: RequestHandler[] = [];
18
+ const responseOk: ResponseOk[] = [];
19
+ const responseErr: ResponseErr[] = [];
20
+
21
+ return {
22
+ defaults: { baseURL: 'https://app.example.com' },
23
+ interceptors: {
24
+ request: {
25
+ use(onFulfilled) {
26
+ request.push(onFulfilled);
27
+ return 0;
28
+ },
29
+ },
30
+ response: {
31
+ use(onFulfilled, onRejected) {
32
+ responseOk.push(onFulfilled);
33
+ if (onRejected) {
34
+ responseErr.push(onRejected);
35
+ }
36
+ return 0;
37
+ },
38
+ },
39
+ },
40
+ requestHandlers: request,
41
+ responseOkHandlers: responseOk,
42
+ responseErrHandlers: responseErr,
43
+ };
44
+ }
45
+
46
+ describe('[journey] instrumentAxios', () => {
47
+ const def = {
48
+ name: 'axios-journey',
49
+ team: 'test',
50
+ group: 'test',
51
+ service: 'test',
52
+ timeoutMs: 0,
53
+ requestTimeoutMs: 5_000,
54
+ };
55
+
56
+ useJourneyTestRuntime(emitMock);
57
+
58
+ beforeEach(() => {
59
+ jest.spyOn(globalThis.performance, 'now').mockReturnValue(1_000);
60
+ });
61
+
62
+ afterEach(() => {
63
+ jest.restoreAllMocks();
64
+ });
65
+
66
+ test('is idempotent for the same axios instance', () => {
67
+ const axios = createAxiosMock();
68
+ instrumentAxios(axios);
69
+ instrumentAxios(axios);
70
+
71
+ expect(axios.requestHandlers).toHaveLength(1);
72
+ expect(axios.responseOkHandlers).toHaveLength(1);
73
+ expect(axios.responseErrHandlers).toHaveLength(1);
74
+ });
75
+
76
+ test('reports a successful response against the ambient step', async () => {
77
+ const axios = createAxiosMock();
78
+ instrumentAxios(axios);
79
+
80
+ startJourney(def);
81
+ await journeyStep(def.name, 'book-job', () => {
82
+ const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
83
+ expect(config.journeyStep).toBeInstanceOf(JourneyStepTag);
84
+ expect(config.journeyStart).toBe(1_000);
85
+
86
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_050);
87
+ const response = axios.responseOkHandlers[0]({ status: 200, config });
88
+ expect(response.status).toBe(200);
89
+ });
90
+ completeJourney(def.name);
91
+
92
+ expect(emitMock).toHaveBeenCalledTimes(1);
93
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
94
+ });
95
+
96
+ test('marks journey bad on 5xx via the rejection interceptor', async () => {
97
+ const axios = createAxiosMock();
98
+ instrumentAxios(axios);
99
+
100
+ startJourney(def);
101
+ await expect(
102
+ journeyStep(def.name, 'book-job', async () => {
103
+ const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
104
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
105
+ await axios.responseErrHandlers[0]({
106
+ config,
107
+ response: { status: 500 },
108
+ });
109
+ })
110
+ ).rejects.toMatchObject({ response: { status: 500 } });
111
+
112
+ expect(emitMock).toHaveBeenCalledTimes(1);
113
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
114
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
115
+ });
116
+
117
+ test('honors an explicit journeyStep from step.tag()', async () => {
118
+ const axios = createAxiosMock();
119
+ instrumentAxios(axios);
120
+
121
+ startJourney(def);
122
+ await journeyStep(def.name, 'book-job', async step => {
123
+ const tagged = step.tag() as { journeyStep: unknown };
124
+ const config = axios.requestHandlers[0]({
125
+ url: '/CallScreen/BookJob',
126
+ journeyStep: tagged.journeyStep,
127
+ });
128
+ expect(config.journeyStep).toBe(tagged.journeyStep);
129
+
130
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
131
+ await expect(
132
+ axios.responseErrHandlers[0]({
133
+ config,
134
+ response: { status: 503 },
135
+ })
136
+ ).rejects.toBeDefined();
137
+ });
138
+
139
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
140
+ });
141
+
142
+ test('soft ambient: overlapping steps do not auto-stamp HTTP', async () => {
143
+ const other = { ...def, name: 'axios-journey-other' };
144
+ const axios = createAxiosMock();
145
+ instrumentAxios(axios);
146
+
147
+ startJourney(def);
148
+ startJourney(other);
149
+
150
+ let releaseA!: () => void;
151
+ const gateA = new Promise<void>(r => {
152
+ releaseA = r;
153
+ });
154
+
155
+ const stepA = journeyStep(def.name, 'load', async () => {
156
+ await gateA;
157
+ });
158
+
159
+ await journeyStep(other.name, 'book', async () => {
160
+ const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
161
+ expect(config.journeyStep).toBeUndefined();
162
+
163
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
164
+ await expect(
165
+ axios.responseErrHandlers[0]({
166
+ config,
167
+ response: { status: 500 },
168
+ })
169
+ ).rejects.toMatchObject({ response: { status: 500 } });
170
+ });
171
+
172
+ releaseA();
173
+ await stepA;
174
+
175
+ expect(emitMock).not.toHaveBeenCalled();
176
+ completeJourney(def.name);
177
+ completeJourney(other.name);
178
+ expect(emitMock).toHaveBeenCalledTimes(2);
179
+ expect(emitMock.mock.calls.every(c => c[0].journey.outcome === 'good')).toBe(true);
180
+ });
181
+
182
+ test('soft ambient: step.tag() still attributes during overlap', async () => {
183
+ const other = { ...def, name: 'axios-journey-tagged' };
184
+ const axios = createAxiosMock();
185
+ instrumentAxios(axios);
186
+
187
+ startJourney(def);
188
+ startJourney(other);
189
+
190
+ let releaseA!: () => void;
191
+ const gateA = new Promise<void>(r => {
192
+ releaseA = r;
193
+ });
194
+
195
+ const stepA = journeyStep(def.name, 'load', async () => {
196
+ await gateA;
197
+ });
198
+
199
+ await expect(
200
+ journeyStep(other.name, 'book', async step => {
201
+ const tagged = step.tag() as { journeyStep: unknown };
202
+ const config = axios.requestHandlers[0]({
203
+ url: '/CallScreen/BookJob',
204
+ journeyStep: tagged.journeyStep,
205
+ });
206
+ expect(config.journeyStep).toBe(tagged.journeyStep);
207
+
208
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
209
+ await axios.responseErrHandlers[0]({
210
+ config,
211
+ response: { status: 500 },
212
+ });
213
+ })
214
+ ).rejects.toMatchObject({ response: { status: 500 } });
215
+
216
+ releaseA();
217
+ await stepA;
218
+
219
+ expect(emitMock).toHaveBeenCalledTimes(1);
220
+ expect(emitMock.mock.calls[0][0].journey.name).toBe(other.name);
221
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
222
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
223
+
224
+ completeJourney(def.name);
225
+ });
226
+
227
+ test('ignores responses with no journey step on the config', () => {
228
+ const axios = createAxiosMock();
229
+ instrumentAxios(axios);
230
+
231
+ // Outside a step — request interceptor finds no ambient step.
232
+ const config = axios.requestHandlers[0]({ url: '/orphan' });
233
+ expect(config.journeyStep).toBeUndefined();
234
+ axios.responseOkHandlers[0]({ status: 500, config });
235
+
236
+ expect(emitMock).not.toHaveBeenCalled();
237
+ });
238
+
239
+ test('non-axios errors without config are rethrown without reporting', async () => {
240
+ const axios = createAxiosMock();
241
+ instrumentAxios(axios);
242
+
243
+ await expect(axios.responseErrHandlers[0](new Error('boom'))).rejects.toThrow('boom');
244
+ expect(emitMock).not.toHaveBeenCalled();
245
+ });
246
+
247
+ test('falls back to performance.now when journeyStart is missing', async () => {
248
+ const axios = createAxiosMock();
249
+ instrumentAxios(axios);
250
+
251
+ startJourney(def);
252
+ await journeyStep(def.name, 'book-job', step => {
253
+ const tagged = step.tag() as { journeyStep: unknown };
254
+ const config = {
255
+ url: '/CallScreen/BookJob',
256
+ journeyStep: tagged.journeyStep,
257
+ };
258
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_200);
259
+ axios.responseOkHandlers[0]({ status: 200, config });
260
+ });
261
+ completeJourney(def.name);
262
+
263
+ expect(emitMock).toHaveBeenCalledTimes(1);
264
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
265
+ });
266
+
267
+ test('marks request-latency on a slow successful response', async () => {
268
+ const axios = createAxiosMock();
269
+ instrumentAxios(axios);
270
+
271
+ startJourney({ ...def, requestTimeoutMs: 50 });
272
+ await journeyStep(def.name, 'book-job', () => {
273
+ const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
274
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_200);
275
+ axios.responseOkHandlers[0]({ status: 200, config });
276
+ });
277
+
278
+ expect(emitMock).toHaveBeenCalledTimes(1);
279
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
280
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-latency');
281
+ });
282
+
283
+ test('does not mark bad on 4xx via the rejection interceptor', async () => {
284
+ const axios = createAxiosMock();
285
+ instrumentAxios(axios);
286
+
287
+ startJourney(def);
288
+ await journeyStep(def.name, 'book-job', async () => {
289
+ const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
290
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
291
+ await expect(
292
+ axios.responseErrHandlers[0]({
293
+ config,
294
+ response: { status: 400 },
295
+ })
296
+ ).rejects.toMatchObject({ response: { status: 400 } });
297
+ });
298
+ completeJourney(def.name);
299
+
300
+ expect(emitMock).toHaveBeenCalledTimes(1);
301
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
302
+ });
303
+
304
+ test('network errors without a response status mark request-error', async () => {
305
+ const axios = createAxiosMock();
306
+ instrumentAxios(axios);
307
+
308
+ startJourney(def);
309
+ await expect(
310
+ journeyStep(def.name, 'book-job', async () => {
311
+ const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
312
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
313
+ await axios.responseErrHandlers[0]({ config });
314
+ })
315
+ ).rejects.toBeDefined();
316
+
317
+ expect(emitMock).toHaveBeenCalledTimes(1);
318
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
319
+ });
320
+
321
+ test('ok interceptor tolerates a missing response config', () => {
322
+ const axios = createAxiosMock();
323
+ instrumentAxios(axios);
324
+
325
+ expect(() => axios.responseOkHandlers[0]({ status: 200 })).not.toThrow();
326
+ expect(emitMock).not.toHaveBeenCalled();
327
+ });
328
+
329
+ test('rejection interceptor tolerates a missing error config', async () => {
330
+ const axios = createAxiosMock();
331
+ instrumentAxios(axios);
332
+
333
+ await expect(axios.responseErrHandlers[0]({ response: { status: 500 } })).rejects.toEqual({
334
+ response: { status: 500 },
335
+ });
336
+ expect(emitMock).not.toHaveBeenCalled();
337
+ });
338
+
339
+ test('uses config.baseURL when building the request key', async () => {
340
+ const axios = createAxiosMock();
341
+ instrumentAxios(axios);
342
+
343
+ startJourney({
344
+ ...def,
345
+ endpoints: [{ match: 'api.example.com/invoices', ignore: true }],
346
+ });
347
+ await journeyStep(def.name, 'book-job', async () => {
348
+ const config = axios.requestHandlers[0]({
349
+ url: '/invoices',
350
+ baseURL: 'https://api.example.com',
351
+ });
352
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
353
+ await expect(
354
+ axios.responseErrHandlers[0]({
355
+ config,
356
+ response: { status: 500 },
357
+ })
358
+ ).rejects.toBeDefined();
359
+ });
360
+ completeJourney(def.name);
361
+
362
+ expect(emitMock).toHaveBeenCalledTimes(1);
363
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
364
+ });
365
+ });
@@ -0,0 +1,261 @@
1
+ /** @jest-environment jsdom */
2
+ jest.mock('../sinks/datadog', () => ({
3
+ sendToDatadog: jest.fn(),
4
+ }));
5
+
6
+ jest.mock('../integrations/axios', () => ({
7
+ instrumentAxios: jest.fn(),
8
+ }));
9
+
10
+ jest.mock('../integrations/fetch', () => ({
11
+ instrumentFetch: jest.fn(() => jest.fn()),
12
+ }));
13
+
14
+ jest.mock('../integrations/jquery', () => ({
15
+ instrumentJquery: jest.fn(),
16
+ exposeAppJourney: jest.fn(),
17
+ }));
18
+
19
+ import {
20
+ configureJourney,
21
+ getRequestTimeouts,
22
+ longestEndpointMatch,
23
+ sortEndpointsByLongestMatch,
24
+ } from '../config';
25
+ import { resetJourney } from '../core';
26
+ import { emitJourneyEvent } from '../core/registry';
27
+ import type { JourneyEvent } from '../core/types';
28
+ import { instrumentAxios } from '../integrations/axios';
29
+ import { instrumentFetch } from '../integrations/fetch';
30
+ import {
31
+ exposeAppJourney,
32
+ instrumentJquery,
33
+ type WindowWithAppJourney,
34
+ } from '../integrations/jquery';
35
+ import { sendToDatadog } from '../sinks/datadog';
36
+
37
+ const instrumentAxiosMock = jest.mocked(instrumentAxios);
38
+ const instrumentFetchMock = jest.mocked(instrumentFetch);
39
+ const instrumentJqueryMock = jest.mocked(instrumentJquery);
40
+ const exposeAppJourneyMock = jest.mocked(exposeAppJourney);
41
+
42
+ describe('[journey] configureJourney / getRequestTimeouts', () => {
43
+ afterEach(() => {
44
+ resetJourney({
45
+ sinks: [sendToDatadog],
46
+ requestTimeouts: { defaultMs: 4_000, endpoints: [] },
47
+ });
48
+ instrumentAxiosMock.mockClear();
49
+ instrumentFetchMock.mockClear();
50
+ instrumentJqueryMock.mockClear();
51
+ exposeAppJourneyMock.mockClear();
52
+ });
53
+
54
+ test('defaults to 4s with empty endpoints', () => {
55
+ expect(getRequestTimeouts()).toEqual({ defaultMs: 4_000, endpoints: [] });
56
+ });
57
+
58
+ test('overrides the app-wide request timeout tier', () => {
59
+ configureJourney({
60
+ requestTimeouts: {
61
+ defaultMs: 2_000,
62
+ endpoints: [{ match: '/slow', timeoutMs: 8_000 }],
63
+ },
64
+ });
65
+
66
+ expect(getRequestTimeouts()).toEqual({
67
+ defaultMs: 2_000,
68
+ endpoints: [{ match: '/slow', timeoutMs: 8_000 }],
69
+ });
70
+ });
71
+
72
+ test('ignores configureJourney when requestTimeouts is omitted', () => {
73
+ configureJourney({ requestTimeouts: { defaultMs: 1_000, endpoints: [] } });
74
+ configureJourney({});
75
+ expect(getRequestTimeouts().defaultMs).toBe(1_000);
76
+ });
77
+
78
+ describe('with sinks', () => {
79
+ let payload: JourneyEvent;
80
+ let custom: jest.Mock;
81
+
82
+ beforeEach(() => {
83
+ jest.mocked(sendToDatadog).mockClear();
84
+ custom = jest.fn();
85
+ payload = {
86
+ journey: {
87
+ name: 'test',
88
+ team: 't',
89
+ group: 'g',
90
+ service: 's',
91
+ outcome: 'good',
92
+ durationMs: 1,
93
+ steps: [],
94
+ },
95
+ };
96
+ });
97
+
98
+ test('replaces the sink list', () => {
99
+ configureJourney({ sinks: [custom] });
100
+ emitJourneyEvent(payload);
101
+
102
+ expect(custom).toHaveBeenCalledWith(payload);
103
+ expect(sendToDatadog).not.toHaveBeenCalled();
104
+ });
105
+
106
+ test('leaves sinks unchanged when sinks is omitted', () => {
107
+ configureJourney({ sinks: [custom] });
108
+ configureJourney({ requestTimeouts: { defaultMs: 2_000, endpoints: [] } });
109
+ emitJourneyEvent(payload);
110
+
111
+ expect(custom).toHaveBeenCalledWith(payload);
112
+ expect(sendToDatadog).not.toHaveBeenCalled();
113
+ });
114
+
115
+ test('disables emission when sinks is empty', () => {
116
+ configureJourney({ sinks: [] });
117
+ emitJourneyEvent(payload);
118
+
119
+ expect(sendToDatadog).not.toHaveBeenCalled();
120
+ expect(custom).not.toHaveBeenCalled();
121
+ });
122
+ });
123
+
124
+ describe('with transport wiring', () => {
125
+ const axios = {
126
+ defaults: {},
127
+ interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
128
+ };
129
+ const jquery = { ajaxPrefilter: jest.fn(), bind: jest.fn() } as never;
130
+ const fetchTarget = { fetch: jest.fn() };
131
+
132
+ test('instruments axios when provided', () => {
133
+ configureJourney({ axios: axios as never });
134
+
135
+ expect(instrumentAxiosMock).toHaveBeenCalledWith(axios);
136
+ });
137
+
138
+ test('instruments fetch with defaults when fetch is true', () => {
139
+ configureJourney({ fetch: true });
140
+
141
+ expect(instrumentFetchMock).toHaveBeenCalledWith({});
142
+ });
143
+
144
+ test('instruments fetch with options when provided', () => {
145
+ configureJourney({
146
+ fetch: { target: fetchTarget, baseURL: 'https://api.example.com' },
147
+ });
148
+
149
+ expect(instrumentFetchMock).toHaveBeenCalledWith({
150
+ target: fetchTarget,
151
+ baseURL: 'https://api.example.com',
152
+ });
153
+ });
154
+
155
+ test('instruments jquery when provided', () => {
156
+ configureJourney({ jquery });
157
+
158
+ expect(instrumentJqueryMock).toHaveBeenCalledWith(jquery);
159
+ });
160
+
161
+ test('exposes App.Journey when exposeAppJourney is true', () => {
162
+ configureJourney({ exposeAppJourney: true });
163
+
164
+ expect(exposeAppJourneyMock).toHaveBeenCalledWith(undefined);
165
+ });
166
+
167
+ test('exposes App.Journey on a custom target', () => {
168
+ const target = { App: {} } as WindowWithAppJourney;
169
+ configureJourney({ exposeAppJourney: target });
170
+
171
+ expect(exposeAppJourneyMock).toHaveBeenCalledWith(target);
172
+ });
173
+
174
+ test('skips transport helpers when omitted', () => {
175
+ configureJourney({ requestTimeouts: { defaultMs: 1_000, endpoints: [] } });
176
+
177
+ expect(instrumentAxiosMock).not.toHaveBeenCalled();
178
+ expect(instrumentFetchMock).not.toHaveBeenCalled();
179
+ expect(instrumentJqueryMock).not.toHaveBeenCalled();
180
+ expect(exposeAppJourneyMock).not.toHaveBeenCalled();
181
+ });
182
+ });
183
+ });
184
+
185
+ describe('longestEndpointMatch', () => {
186
+ // Lookup expects longest-match-first (configureJourney / startJourney sort for you).
187
+ const endpoints = [
188
+ { match: '/api/invoices', timeoutMs: 2_000 },
189
+ { match: '/api', timeoutMs: 1_000 },
190
+ { match: '/other', ignore: true },
191
+ ];
192
+
193
+ test('returns undefined for empty / undefined lists', () => {
194
+ expect(longestEndpointMatch(undefined, '/api')).toBeUndefined();
195
+ expect(longestEndpointMatch([], '/api')).toBeUndefined();
196
+ });
197
+
198
+ test('picks the longest matching prefix (first hit when sorted)', () => {
199
+ expect(longestEndpointMatch(endpoints, '/api/invoices/42')?.timeoutMs).toBe(2_000);
200
+ expect(longestEndpointMatch(endpoints, '/api/jobs')?.timeoutMs).toBe(1_000);
201
+ });
202
+
203
+ test('returns undefined when nothing matches', () => {
204
+ expect(longestEndpointMatch(endpoints, '/health')).toBeUndefined();
205
+ });
206
+
207
+ test('requires a path boundary after the prefix', () => {
208
+ const user = [{ match: '/api/user', timeoutMs: 1_000 }];
209
+ expect(longestEndpointMatch(user, '/api/user')?.timeoutMs).toBe(1_000);
210
+ expect(longestEndpointMatch(user, '/api/user/42')?.timeoutMs).toBe(1_000);
211
+ expect(longestEndpointMatch(user, '/api/user-preferences')).toBeUndefined();
212
+ expect(longestEndpointMatch(user, '/api/users')).toBeUndefined();
213
+
214
+ const trailed = [{ match: '/api/user/', timeoutMs: 2_000 }];
215
+ expect(longestEndpointMatch(trailed, '/api/user/42')?.timeoutMs).toBe(2_000);
216
+ });
217
+ });
218
+
219
+ describe('configureJourney endpoint ordering', () => {
220
+ afterEach(() => {
221
+ resetJourney({
222
+ sinks: [sendToDatadog],
223
+ requestTimeouts: { defaultMs: 4_000, endpoints: [] },
224
+ });
225
+ });
226
+
227
+ test('stores endpoints longest-match-first', () => {
228
+ configureJourney({
229
+ requestTimeouts: {
230
+ defaultMs: 4_000,
231
+ endpoints: [
232
+ { match: '/api', timeoutMs: 1_000 },
233
+ { match: '/api/invoices', timeoutMs: 2_000 },
234
+ ],
235
+ },
236
+ });
237
+
238
+ expect(getRequestTimeouts().endpoints?.map(e => e.match)).toEqual([
239
+ '/api/invoices',
240
+ '/api',
241
+ ]);
242
+ });
243
+ });
244
+
245
+ describe('[journey] sortEndpointsByLongestMatch', () => {
246
+ test('returns undefined for undefined input', () => {
247
+ expect(sortEndpointsByLongestMatch(undefined)).toBeUndefined();
248
+ });
249
+
250
+ test('returns a copy for a single endpoint', () => {
251
+ const only = [{ match: '/api', timeoutMs: 1_000 }];
252
+ const sorted = sortEndpointsByLongestMatch(only);
253
+
254
+ expect(sorted).toEqual(only);
255
+ expect(sorted).not.toBe(only);
256
+ });
257
+
258
+ test('returns an empty array for an empty list', () => {
259
+ expect(sortEndpointsByLongestMatch([])).toEqual([]);
260
+ });
261
+ });