@servicetitan/journey 2.1.0 → 3.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 (163) hide show
  1. package/README.md +1 -1
  2. package/dist/__test-utils__/test-runtime.d.ts.map +1 -1
  3. package/dist/__test-utils__/test-runtime.js +9 -2
  4. package/dist/__test-utils__/test-runtime.js.map +1 -1
  5. package/dist/__tests__/core/sanitize.test.d.ts +2 -0
  6. package/dist/__tests__/core/sanitize.test.d.ts.map +1 -0
  7. package/dist/__tests__/hardening.test.d.ts +2 -0
  8. package/dist/__tests__/hardening.test.d.ts.map +1 -0
  9. package/dist/__tests__/integrations/host-mfe.test.d.ts +2 -0
  10. package/dist/__tests__/integrations/host-mfe.test.d.ts.map +1 -0
  11. package/dist/__tests__/integrations/use-journey-step.test.d.ts +2 -0
  12. package/dist/__tests__/integrations/use-journey-step.test.d.ts.map +1 -0
  13. package/dist/config.d.ts +57 -13
  14. package/dist/config.d.ts.map +1 -1
  15. package/dist/config.js +87 -24
  16. package/dist/config.js.map +1 -1
  17. package/dist/core/abort-error.d.ts +4 -0
  18. package/dist/core/abort-error.d.ts.map +1 -0
  19. package/dist/core/abort-error.js +22 -0
  20. package/dist/core/abort-error.js.map +1 -0
  21. package/dist/core/api.d.ts +22 -9
  22. package/dist/core/api.d.ts.map +1 -1
  23. package/dist/core/api.js +77 -30
  24. package/dist/core/api.js.map +1 -1
  25. package/dist/core/endpoint-policy.d.ts +14 -4
  26. package/dist/core/endpoint-policy.d.ts.map +1 -1
  27. package/dist/core/endpoint-policy.js +32 -1
  28. package/dist/core/endpoint-policy.js.map +1 -1
  29. package/dist/core/index.d.ts +4 -3
  30. package/dist/core/index.d.ts.map +1 -1
  31. package/dist/core/index.js +3 -3
  32. package/dist/core/index.js.map +1 -1
  33. package/dist/core/limits.d.ts +29 -0
  34. package/dist/core/limits.d.ts.map +1 -0
  35. package/dist/core/limits.js +63 -0
  36. package/dist/core/limits.js.map +1 -0
  37. package/dist/core/request-policy.d.ts +39 -0
  38. package/dist/core/request-policy.d.ts.map +1 -0
  39. package/dist/core/request-policy.js +18 -0
  40. package/dist/core/request-policy.js.map +1 -0
  41. package/dist/core/runtime.d.ts +65 -15
  42. package/dist/core/runtime.d.ts.map +1 -1
  43. package/dist/core/runtime.js +403 -126
  44. package/dist/core/runtime.js.map +1 -1
  45. package/dist/core/sanitize.d.ts +23 -0
  46. package/dist/core/sanitize.d.ts.map +1 -0
  47. package/dist/core/sanitize.js +143 -0
  48. package/dist/core/sanitize.js.map +1 -0
  49. package/dist/core/step-tag.d.ts +25 -6
  50. package/dist/core/step-tag.d.ts.map +1 -1
  51. package/dist/core/step-tag.js +56 -10
  52. package/dist/core/step-tag.js.map +1 -1
  53. package/dist/core/types.d.ts +73 -25
  54. package/dist/core/types.d.ts.map +1 -1
  55. package/dist/core/types.js +4 -1
  56. package/dist/core/types.js.map +1 -1
  57. package/dist/global.d.ts +91 -13
  58. package/dist/global.d.ts.map +1 -1
  59. package/dist/global.js +235 -25
  60. package/dist/global.js.map +1 -1
  61. package/dist/index.d.ts +7 -8
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +4 -6
  64. package/dist/index.js.map +1 -1
  65. package/dist/integrations/axios.d.ts +5 -1
  66. package/dist/integrations/axios.d.ts.map +1 -1
  67. package/dist/integrations/axios.js +146 -34
  68. package/dist/integrations/axios.js.map +1 -1
  69. package/dist/integrations/fetch.d.ts +3 -2
  70. package/dist/integrations/fetch.d.ts.map +1 -1
  71. package/dist/integrations/fetch.js +105 -26
  72. package/dist/integrations/fetch.js.map +1 -1
  73. package/dist/integrations/http-report.d.ts +27 -0
  74. package/dist/integrations/http-report.d.ts.map +1 -0
  75. package/dist/integrations/http-report.js +123 -0
  76. package/dist/integrations/http-report.js.map +1 -0
  77. package/dist/integrations/jquery/index.d.ts +8 -4
  78. package/dist/integrations/jquery/index.d.ts.map +1 -1
  79. package/dist/integrations/jquery/index.js +26 -8
  80. package/dist/integrations/jquery/index.js.map +1 -1
  81. package/dist/integrations/jquery/instrument.d.ts +14 -5
  82. package/dist/integrations/jquery/instrument.d.ts.map +1 -1
  83. package/dist/integrations/jquery/instrument.js +111 -29
  84. package/dist/integrations/jquery/instrument.js.map +1 -1
  85. package/dist/integrations/react/context.d.ts +4 -1
  86. package/dist/integrations/react/context.d.ts.map +1 -1
  87. package/dist/integrations/react/context.js +4 -1
  88. package/dist/integrations/react/context.js.map +1 -1
  89. package/dist/integrations/react/scope.d.ts +14 -3
  90. package/dist/integrations/react/scope.d.ts.map +1 -1
  91. package/dist/integrations/react/scope.js +46 -8
  92. package/dist/integrations/react/scope.js.map +1 -1
  93. package/dist/integrations/react/stable-config-keys.d.ts +6 -0
  94. package/dist/integrations/react/stable-config-keys.d.ts.map +1 -0
  95. package/dist/integrations/react/stable-config-keys.js +21 -0
  96. package/dist/integrations/react/stable-config-keys.js.map +1 -0
  97. package/dist/integrations/react/use-journey-step.d.ts +11 -0
  98. package/dist/integrations/react/use-journey-step.d.ts.map +1 -0
  99. package/dist/integrations/react/use-journey-step.js +19 -0
  100. package/dist/integrations/react/use-journey-step.js.map +1 -0
  101. package/dist/integrations/request-key.d.ts +8 -3
  102. package/dist/integrations/request-key.d.ts.map +1 -1
  103. package/dist/integrations/request-key.js +10 -9
  104. package/dist/integrations/request-key.js.map +1 -1
  105. package/dist/integrations/soft-skip.d.ts +7 -0
  106. package/dist/integrations/soft-skip.d.ts.map +1 -0
  107. package/dist/integrations/soft-skip.js +30 -0
  108. package/dist/integrations/soft-skip.js.map +1 -0
  109. package/dist/sinks/console.d.ts.map +1 -1
  110. package/dist/sinks/console.js +2 -1
  111. package/dist/sinks/console.js.map +1 -1
  112. package/dist/sinks/datadog.d.ts +1 -1
  113. package/dist/sinks/datadog.d.ts.map +1 -1
  114. package/dist/sinks/datadog.js +16 -20
  115. package/dist/sinks/datadog.js.map +1 -1
  116. package/package.json +2 -2
  117. package/src/__test-utils__/test-runtime.ts +9 -2
  118. package/src/__tests__/config.test.ts +150 -31
  119. package/src/__tests__/core/ambient.test.ts +16 -10
  120. package/src/__tests__/core/api.test.ts +7 -7
  121. package/src/__tests__/core/define-journey.test.ts +28 -11
  122. package/src/__tests__/core/requests.test.ts +49 -31
  123. package/src/__tests__/core/runtime.test.ts +67 -20
  124. package/src/__tests__/core/sanitize.test.ts +69 -0
  125. package/src/__tests__/core/step.test.ts +132 -36
  126. package/src/__tests__/core/timeouts.test.ts +69 -4
  127. package/src/__tests__/global.test.ts +44 -3
  128. package/src/__tests__/hardening.test.ts +613 -0
  129. package/src/__tests__/integrations/axios.test.ts +247 -27
  130. package/src/__tests__/integrations/expose-app-journey.test.ts +9 -3
  131. package/src/__tests__/integrations/fetch.test.ts +153 -19
  132. package/src/__tests__/integrations/host-mfe.test.ts +548 -0
  133. package/src/__tests__/integrations/jquery.test.ts +209 -34
  134. package/src/__tests__/integrations/scope.test.tsx +160 -2
  135. package/src/__tests__/integrations/use-journey-step.test.tsx +43 -0
  136. package/src/__tests__/sinks/console.test.ts +9 -4
  137. package/src/__tests__/sinks/datadog.test.ts +47 -0
  138. package/src/config.ts +162 -38
  139. package/src/core/abort-error.ts +24 -0
  140. package/src/core/api.ts +83 -33
  141. package/src/core/endpoint-policy.ts +46 -4
  142. package/src/core/index.ts +12 -4
  143. package/src/core/limits.ts +73 -0
  144. package/src/core/request-policy.ts +66 -0
  145. package/src/core/runtime.ts +507 -133
  146. package/src/core/sanitize.ts +177 -0
  147. package/src/core/step-tag.ts +82 -12
  148. package/src/core/types.ts +79 -25
  149. package/src/global.ts +312 -42
  150. package/src/index.ts +17 -17
  151. package/src/integrations/axios.ts +183 -45
  152. package/src/integrations/fetch.ts +125 -39
  153. package/src/integrations/http-report.ts +184 -0
  154. package/src/integrations/jquery/index.ts +34 -10
  155. package/src/integrations/jquery/instrument.ts +126 -42
  156. package/src/integrations/react/context.ts +4 -1
  157. package/src/integrations/react/scope.tsx +59 -9
  158. package/src/integrations/react/stable-config-keys.ts +28 -0
  159. package/src/integrations/react/use-journey-step.ts +31 -0
  160. package/src/integrations/request-key.ts +18 -10
  161. package/src/integrations/soft-skip.ts +35 -0
  162. package/src/sinks/console.ts +2 -1
  163. package/src/sinks/datadog.ts +23 -29
@@ -1,7 +1,8 @@
1
1
  /** @jest-environment jsdom */
2
2
  import { useJourneyTestRuntime } from '../../__test-utils__/test-runtime';
3
- import { completeJourney, JourneyStepTag, journeyStep } from '../../core';
3
+ import { completeJourney, journeyStep } from '../../core';
4
4
  import { moduleRuntime } from '../../core/runtime';
5
+ import { getSharedPolicies } from '../../global';
5
6
  import { instrumentAxios, type AxiosLikeInstance } from '../../integrations/axios';
6
7
 
7
8
  const emitMock = jest.fn();
@@ -25,7 +26,12 @@ function createAxiosMock(): AxiosLikeInstance & {
25
26
  request: {
26
27
  use(onFulfilled) {
27
28
  request.push(onFulfilled);
28
- return 0;
29
+ return request.length - 1;
30
+ },
31
+ eject(id) {
32
+ if (typeof id === 'number') {
33
+ request.splice(id, 1);
34
+ }
29
35
  },
30
36
  },
31
37
  response: {
@@ -34,7 +40,13 @@ function createAxiosMock(): AxiosLikeInstance & {
34
40
  if (onRejected) {
35
41
  responseErr.push(onRejected);
36
42
  }
37
- return 0;
43
+ return responseOk.length - 1;
44
+ },
45
+ eject(id) {
46
+ if (typeof id === 'number') {
47
+ responseOk.splice(id, 1);
48
+ responseErr.splice(id, 1);
49
+ }
38
50
  },
39
51
  },
40
52
  },
@@ -51,7 +63,7 @@ describe('[journey] instrumentAxios', () => {
51
63
  group: 'test',
52
64
  service: 'test',
53
65
  timeoutMs: 0,
54
- requestTimeoutMs: 5_000,
66
+ slowRequestMs: 5_000,
55
67
  };
56
68
 
57
69
  useJourneyTestRuntime(emitMock);
@@ -74,6 +86,71 @@ describe('[journey] instrumentAxios', () => {
74
86
  expect(axios.responseErrHandlers).toHaveLength(1);
75
87
  });
76
88
 
89
+ test.each([undefined, null])('warns and soft-skips when axios is missing (%p)', bad => {
90
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
91
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
92
+
93
+ expect(() => instrumentAxios(bad)).not.toThrow();
94
+ expect(warn).toHaveBeenCalledWith(
95
+ expect.stringContaining('[journey] instrumentAxios: skipped — missing')
96
+ );
97
+ expect(error).not.toHaveBeenCalled();
98
+ });
99
+
100
+ test.each([{}, { interceptors: {} }, { interceptors: { request: {}, response: {} } }])(
101
+ 'errors and soft-skips when axios has invalid shape (%p)',
102
+ bad => {
103
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
104
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
105
+
106
+ expect(() => instrumentAxios(bad as never)).not.toThrow();
107
+ expect(error).toHaveBeenCalledWith(
108
+ expect.stringContaining('[journey] instrumentAxios: skipped — invalid shape (')
109
+ );
110
+ expect(error.mock.calls[0]).toHaveLength(1);
111
+ expect(warn).not.toHaveBeenCalled();
112
+ }
113
+ );
114
+
115
+ test('retries instrumentation after a partial wire failure', () => {
116
+ const axios = createAxiosMock();
117
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
118
+ axios.interceptors.response.use = () => {
119
+ throw new Error('response interceptor boom');
120
+ };
121
+
122
+ instrumentAxios(axios);
123
+ expect(axios.requestHandlers).toHaveLength(0);
124
+
125
+ axios.interceptors.response.use = (onFulfilled, onRejected) => {
126
+ axios.responseOkHandlers.push(onFulfilled);
127
+ if (onRejected) {
128
+ axios.responseErrHandlers.push(onRejected);
129
+ }
130
+ return axios.responseOkHandlers.length - 1;
131
+ };
132
+ instrumentAxios(axios);
133
+
134
+ expect(axios.requestHandlers).toHaveLength(1);
135
+ expect(axios.responseOkHandlers).toHaveLength(1);
136
+ expect(error).toHaveBeenCalled();
137
+ });
138
+
139
+ test('restore ejects interceptors so a later wire does not stack', () => {
140
+ const axios = createAxiosMock();
141
+ const restore = instrumentAxios(axios);
142
+ expect(axios.requestHandlers).toHaveLength(1);
143
+ expect(axios.responseOkHandlers).toHaveLength(1);
144
+
145
+ restore();
146
+ expect(axios.requestHandlers).toHaveLength(0);
147
+ expect(axios.responseOkHandlers).toHaveLength(0);
148
+
149
+ instrumentAxios(axios);
150
+ expect(axios.requestHandlers).toHaveLength(1);
151
+ expect(axios.responseOkHandlers).toHaveLength(1);
152
+ });
153
+
77
154
  test('reports a successful response against the ambient step', async () => {
78
155
  const axios = createAxiosMock();
79
156
  instrumentAxios(axios);
@@ -81,7 +158,7 @@ describe('[journey] instrumentAxios', () => {
81
158
  moduleRuntime.startJourney(def);
82
159
  await journeyStep(def.name, 'book-job', () => {
83
160
  const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
84
- expect(config.journeyStep).toBeInstanceOf(JourneyStepTag);
161
+ expect(config.stamp).toBeUndefined();
85
162
  expect(config.journeyStart).toBe(1_000);
86
163
 
87
164
  (globalThis.performance.now as jest.Mock).mockReturnValue(1_050);
@@ -94,7 +171,7 @@ describe('[journey] instrumentAxios', () => {
94
171
  expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
95
172
  });
96
173
 
97
- test('stamped journeyStep is JSON-safe (AxiosError.toJSON includes config)', async () => {
174
+ test('stamped stamp is JSON-safe (AxiosError.toJSON includes config)', async () => {
98
175
  const axios = createAxiosMock();
99
176
  instrumentAxios(axios);
100
177
 
@@ -102,24 +179,23 @@ describe('[journey] instrumentAxios', () => {
102
179
  await journeyStep(def.name, 'book-job', step => {
103
180
  // Ambient stamp — AxiosError.toJSON() embeds config by reference.
104
181
  const ambient = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
105
- expect(ambient.journeyStep).toBeInstanceOf(JourneyStepTag);
182
+ expect(ambient.stamp).toBeUndefined();
106
183
  const ambientSerialized = JSON.stringify({
107
184
  message: 'Request failed',
108
185
  name: 'AxiosError',
109
186
  config: ambient,
110
187
  status: 500,
111
188
  });
112
- expect(JSON.parse(ambientSerialized).config.journeyStep).toBe('[JourneyStepTag]');
189
+ expect(JSON.parse(ambientSerialized).config.stamp).toBeUndefined();
113
190
 
114
- // Explicit step.tag() spread into config — same circular risk.
115
- const tagged = step.tag() as { journeyStep: unknown };
191
+ // Explicit step.stamp() stays on config, but serializes safely via toJSON.
192
+ const tagged = step.stamp();
116
193
  const explicit = axios.requestHandlers[0]({
117
194
  url: '/CallScreen/BookJob',
118
- journeyStep: tagged.journeyStep,
195
+ stamp: tagged,
119
196
  });
120
- expect(explicit.journeyStep).toBe(tagged.journeyStep);
121
- expect(explicit.journeyStep).toBeInstanceOf(JourneyStepTag);
122
- expect(JSON.parse(JSON.stringify(explicit)).journeyStep).toBe('[JourneyStepTag]');
197
+ expect(explicit.stamp).toBe(tagged);
198
+ expect(JSON.parse(JSON.stringify(explicit)).stamp).toBe('[JourneyStepStamp]');
123
199
  });
124
200
  completeJourney(def.name);
125
201
  });
@@ -145,18 +221,18 @@ describe('[journey] instrumentAxios', () => {
145
221
  expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
146
222
  });
147
223
 
148
- test('honors an explicit journeyStep from step.tag()', async () => {
224
+ test('honors an explicit stamp from step.stamp()', async () => {
149
225
  const axios = createAxiosMock();
150
226
  instrumentAxios(axios);
151
227
 
152
228
  moduleRuntime.startJourney(def);
153
229
  await journeyStep(def.name, 'book-job', async step => {
154
- const tagged = step.tag() as { journeyStep: unknown };
230
+ const tagged = step.stamp();
155
231
  const config = axios.requestHandlers[0]({
156
232
  url: '/CallScreen/BookJob',
157
- journeyStep: tagged.journeyStep,
233
+ stamp: tagged,
158
234
  });
159
- expect(config.journeyStep).toBe(tagged.journeyStep);
235
+ expect(config.stamp).toBe(tagged);
160
236
 
161
237
  (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
162
238
  await expect(
@@ -170,6 +246,102 @@ describe('[journey] instrumentAxios', () => {
170
246
  expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
171
247
  });
172
248
 
249
+ test('step.stamp score can exclude a 5xx from the response body', async () => {
250
+ const axios = createAxiosMock();
251
+ instrumentAxios(axios);
252
+
253
+ moduleRuntime.startJourney(def);
254
+ await expect(
255
+ journeyStep(def.name, 'book-job', async step => {
256
+ const tagged = step.stamp(ctx => {
257
+ const data = (ctx.response as { data?: { code?: string } } | undefined)?.data;
258
+ return data?.code === 'USER_DATA' ? 'exclude' : undefined;
259
+ });
260
+ const config = axios.requestHandlers[0]({
261
+ url: '/CallScreen/BookJob',
262
+ stamp: tagged,
263
+ });
264
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
265
+ await axios.responseErrHandlers[0]({
266
+ config,
267
+ response: { status: 500, data: { code: 'USER_DATA' } },
268
+ });
269
+ })
270
+ ).rejects.toMatchObject({ response: { status: 500 } });
271
+
272
+ expect(emitMock).toHaveBeenCalledTimes(1);
273
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('excluded');
274
+ });
275
+
276
+ test('ambient auto-attribute does not run a stamp score', async () => {
277
+ const axios = createAxiosMock();
278
+ instrumentAxios(axios);
279
+
280
+ moduleRuntime.startJourney(def);
281
+ await expect(
282
+ journeyStep(def.name, 'book-job', async step => {
283
+ step.stamp(() => 'exclude');
284
+ const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
285
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
286
+ await axios.responseErrHandlers[0]({
287
+ config,
288
+ response: { status: 500, data: { code: 'USER_DATA' } },
289
+ });
290
+ })
291
+ ).rejects.toMatchObject({ response: { status: 500 } });
292
+
293
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
294
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
295
+ });
296
+
297
+ test('step.stamp({ ignore: true }) does not score a 5xx', async () => {
298
+ const axios = createAxiosMock();
299
+ instrumentAxios(axios);
300
+
301
+ moduleRuntime.startJourney(def);
302
+ await journeyStep(def.name, 'book-job', async step => {
303
+ const tagged = step.stamp({ ignore: true });
304
+ const config = axios.requestHandlers[0]({
305
+ url: '/CallScreen/BookJob',
306
+ stamp: tagged,
307
+ });
308
+ expect(config.stamp).toBe(tagged);
309
+ await expect(
310
+ axios.responseErrHandlers[0]({
311
+ config,
312
+ response: { status: 500 },
313
+ })
314
+ ).rejects.toMatchObject({ response: { status: 500 } });
315
+ });
316
+ completeJourney(def.name);
317
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
318
+ });
319
+
320
+ test('ignore: true skips auto-attribute of a 5xx', async () => {
321
+ const axios = createAxiosMock();
322
+ instrumentAxios(axios);
323
+
324
+ moduleRuntime.startJourney(def);
325
+ await journeyStep(def.name, 'book-job', async () => {
326
+ const config = axios.requestHandlers[0]({
327
+ url: '/telemetry',
328
+ ignore: true,
329
+ });
330
+ expect(config.stamp).toBeUndefined();
331
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
332
+ await expect(
333
+ axios.responseErrHandlers[0]({
334
+ config,
335
+ response: { status: 500 },
336
+ })
337
+ ).rejects.toMatchObject({ response: { status: 500 } });
338
+ });
339
+ completeJourney(def.name);
340
+
341
+ expect(emitMock).toHaveBeenCalledTimes(1);
342
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
343
+ });
344
+
173
345
  test('soft ambient: overlapping steps do not auto-stamp HTTP', async () => {
174
346
  const other = { ...def, name: 'axios-journey-other' };
175
347
  const axios = createAxiosMock();
@@ -189,7 +361,7 @@ describe('[journey] instrumentAxios', () => {
189
361
 
190
362
  await journeyStep(other.name, 'book', async () => {
191
363
  const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
192
- expect(config.journeyStep).toBeUndefined();
364
+ expect(config.stamp).toBeUndefined();
193
365
 
194
366
  (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
195
367
  await expect(
@@ -210,7 +382,7 @@ describe('[journey] instrumentAxios', () => {
210
382
  expect(emitMock.mock.calls.every(c => c[0].journey.outcome === 'good')).toBe(true);
211
383
  });
212
384
 
213
- test('soft ambient: step.tag() still attributes during overlap', async () => {
385
+ test('soft ambient: step.stamp() still attributes during overlap', async () => {
214
386
  const other = { ...def, name: 'axios-journey-tagged' };
215
387
  const axios = createAxiosMock();
216
388
  instrumentAxios(axios);
@@ -229,12 +401,12 @@ describe('[journey] instrumentAxios', () => {
229
401
 
230
402
  await expect(
231
403
  journeyStep(other.name, 'book', async step => {
232
- const tagged = step.tag() as { journeyStep: unknown };
404
+ const tagged = step.stamp();
233
405
  const config = axios.requestHandlers[0]({
234
406
  url: '/CallScreen/BookJob',
235
- journeyStep: tagged.journeyStep,
407
+ stamp: tagged,
236
408
  });
237
- expect(config.journeyStep).toBe(tagged.journeyStep);
409
+ expect(config.stamp).toBe(tagged);
238
410
 
239
411
  (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
240
412
  await axios.responseErrHandlers[0]({
@@ -255,13 +427,40 @@ describe('[journey] instrumentAxios', () => {
255
427
  completeJourney(def.name);
256
428
  });
257
429
 
430
+ test('keeps explicit stamp attribution when a later interceptor clones config', async () => {
431
+ const axios = createAxiosMock();
432
+ instrumentAxios(axios);
433
+
434
+ moduleRuntime.startJourney(def);
435
+ await expect(
436
+ journeyStep(def.name, 'book-job', async step => {
437
+ const tagged = step.stamp();
438
+ const config = axios.requestHandlers[0]({
439
+ url: '/CallScreen/BookJob',
440
+ stamp: tagged,
441
+ });
442
+ const clonedConfig = { ...config };
443
+
444
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
445
+ await axios.responseErrHandlers[0]({
446
+ config: clonedConfig,
447
+ response: { status: 500 },
448
+ });
449
+ })
450
+ ).rejects.toMatchObject({ response: { status: 500 } });
451
+
452
+ expect(emitMock).toHaveBeenCalledTimes(1);
453
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
454
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
455
+ });
456
+
258
457
  test('ignores responses with no journey step on the config', () => {
259
458
  const axios = createAxiosMock();
260
459
  instrumentAxios(axios);
261
460
 
262
461
  // Outside a step — request interceptor finds no ambient step.
263
462
  const config = axios.requestHandlers[0]({ url: '/orphan' });
264
- expect(config.journeyStep).toBeUndefined();
463
+ expect(config.stamp).toBeUndefined();
265
464
  axios.responseOkHandlers[0]({ status: 500, config });
266
465
 
267
466
  expect(emitMock).not.toHaveBeenCalled();
@@ -281,10 +480,10 @@ describe('[journey] instrumentAxios', () => {
281
480
 
282
481
  moduleRuntime.startJourney(def);
283
482
  await journeyStep(def.name, 'book-job', step => {
284
- const tagged = step.tag() as { journeyStep: unknown };
483
+ const tagged = step.stamp();
285
484
  const config = {
286
485
  url: '/CallScreen/BookJob',
287
- journeyStep: tagged.journeyStep,
486
+ stamp: tagged,
288
487
  };
289
488
  (globalThis.performance.now as jest.Mock).mockReturnValue(1_200);
290
489
  axios.responseOkHandlers[0]({ status: 200, config });
@@ -299,7 +498,7 @@ describe('[journey] instrumentAxios', () => {
299
498
  const axios = createAxiosMock();
300
499
  instrumentAxios(axios);
301
500
 
302
- moduleRuntime.startJourney({ ...def, requestTimeoutMs: 50 });
501
+ moduleRuntime.startJourney({ ...def, slowRequestMs: 50 });
303
502
  await journeyStep(def.name, 'book-job', () => {
304
503
  const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
305
504
  (globalThis.performance.now as jest.Mock).mockReturnValue(1_200);
@@ -332,6 +531,27 @@ describe('[journey] instrumentAxios', () => {
332
531
  expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
333
532
  });
334
533
 
534
+ test('marks request-aborted when axios reports a cancel', async () => {
535
+ getSharedPolicies().httpAbortedRequests = 'bad';
536
+ const axios = createAxiosMock();
537
+ instrumentAxios(axios);
538
+
539
+ moduleRuntime.startJourney(def);
540
+ await expect(
541
+ journeyStep(def.name, 'book-job', async () => {
542
+ const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
543
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
544
+ const cancelError: any = { config, name: 'CanceledError' };
545
+ cancelError.__CANCEL__ = true;
546
+ await axios.responseErrHandlers[0](cancelError);
547
+ })
548
+ ).rejects.toMatchObject({ name: 'CanceledError' });
549
+
550
+ expect(emitMock).toHaveBeenCalledTimes(1);
551
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
552
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-aborted');
553
+ });
554
+
335
555
  test('network errors without a response status mark request-error', async () => {
336
556
  const axios = createAxiosMock();
337
557
  instrumentAxios(axios);
@@ -20,9 +20,15 @@ describe('[journey] exposeAppJourney', () => {
20
20
 
21
21
  expect(target.App).toBeDefined();
22
22
  expect(target.App.Journey).toBe(api);
23
+ expect(Object.isFrozen(target.App.Journey)).toBe(true);
24
+ expect(() => {
25
+ target.App.Journey.defineJourney = () => {
26
+ throw new Error('swapped');
27
+ };
28
+ }).toThrow();
23
29
  expect(typeof api.defineJourney).toBe('function');
24
30
  expect(typeof api.journeyStep).toBe('function');
25
- expect(typeof api.firstOpenJourney).toBe('function');
31
+ expect(typeof api.firstOpenJourneyName).toBe('function');
26
32
  });
27
33
 
28
34
  test('preserves existing window.App properties', () => {
@@ -104,10 +110,10 @@ describe('[journey] exposeAppJourney', () => {
104
110
  group: 'test',
105
111
  service: 'test',
106
112
  timeoutMs: 0,
107
- steps: ['load'],
113
+ expected: ['load'],
108
114
  });
109
115
  bookJob.start();
110
- expect(j.firstOpenJourney(['missing', 'ko-bridge-tags'])).toBe('ko-bridge-tags');
116
+ expect(j.firstOpenJourneyName(['missing', 'ko-bridge-tags'])).toBe('ko-bridge-tags');
111
117
 
112
118
  await bookJob.step('load', step => {
113
119
  step.setAttribute('phase', 'load');