@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 { instrumentJquery } from '../../integrations/jquery/instrument';
6
7
 
7
8
  const emitMock = jest.fn();
@@ -60,6 +61,87 @@ describe('[journey] instrumentJquery', () => {
60
61
  expect($.__completes).toHaveLength(1);
61
62
  });
62
63
 
64
+ test('restore then rewire does not stack prefilters', () => {
65
+ const $ = createJqueryMock();
66
+ const restore = instrumentJquery($);
67
+ expect($.__prefilters).toHaveLength(1);
68
+
69
+ restore();
70
+ instrumentJquery($);
71
+ expect($.__prefilters).toHaveLength(1);
72
+ expect($.__completes).toHaveLength(1);
73
+ });
74
+
75
+ test.each([undefined, null])('warns and soft-skips when $ is missing (%p)', bad => {
76
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
77
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
78
+
79
+ expect(() => instrumentJquery(bad)).not.toThrow();
80
+ expect(warn).toHaveBeenCalledWith(
81
+ expect.stringContaining('[journey] instrumentJquery: skipped — missing')
82
+ );
83
+ expect(error).not.toHaveBeenCalled();
84
+ });
85
+
86
+ test.each([{}, { ajaxPrefilter: 1 }, { ajaxPrefilter: undefined }])(
87
+ 'errors and soft-skips when $ has invalid shape (%p)',
88
+ bad => {
89
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
90
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
91
+
92
+ expect(() => instrumentJquery(bad as never)).not.toThrow();
93
+ expect(error).toHaveBeenCalledWith(
94
+ expect.stringContaining('[journey] instrumentJquery: skipped — invalid shape (')
95
+ );
96
+ expect(error.mock.calls[0]).toHaveLength(1);
97
+ expect(warn).not.toHaveBeenCalled();
98
+ }
99
+ );
100
+
101
+ test('does not re-register handlers after a partial wire failure', () => {
102
+ const $ = createJqueryMock();
103
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
104
+ const throwing = Object.assign(function throwAjaxComplete() {
105
+ throw new Error('ajaxComplete boom');
106
+ }, $) as ReturnType<typeof createJqueryMock>;
107
+ throwing.ajaxPrefilter = $.ajaxPrefilter;
108
+ throwing.__prefilters = $.__prefilters;
109
+ throwing.__completes = $.__completes;
110
+
111
+ instrumentJquery(throwing);
112
+ expect(throwing.__prefilters).toHaveLength(1);
113
+
114
+ instrumentJquery(throwing);
115
+ expect(throwing.__prefilters).toHaveLength(1);
116
+ expect(error).toHaveBeenCalled();
117
+ });
118
+
119
+ test('restore after a partial wire failure allows a later retry', () => {
120
+ const $ = createJqueryMock();
121
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
122
+ let boom = true;
123
+ const flaky = Object.assign(function maybeThrow(selector: Document) {
124
+ if (boom) {
125
+ throw new Error('ajaxComplete boom');
126
+ }
127
+ return $(selector);
128
+ }, $) as ReturnType<typeof createJqueryMock>;
129
+ flaky.ajaxPrefilter = $.ajaxPrefilter;
130
+ flaky.__prefilters = $.__prefilters;
131
+ flaky.__completes = $.__completes;
132
+
133
+ const restore = instrumentJquery(flaky);
134
+ expect(flaky.__prefilters).toHaveLength(1);
135
+ expect(flaky.__completes).toHaveLength(0);
136
+ restore();
137
+
138
+ boom = false;
139
+ instrumentJquery(flaky);
140
+ expect(flaky.__prefilters).toHaveLength(1);
141
+ expect(flaky.__completes).toHaveLength(1);
142
+ expect(error).toHaveBeenCalled();
143
+ });
144
+
63
145
  test('ignores ajax outside an active journey step', () => {
64
146
  const $ = createJqueryMock();
65
147
  instrumentJquery($);
@@ -70,7 +152,7 @@ describe('[journey] instrumentJquery', () => {
70
152
  group: 'test',
71
153
  service: 'test',
72
154
  timeoutMs: 0,
73
- requestTimeoutMs: 50,
155
+ slowRequestMs: 50,
74
156
  });
75
157
 
76
158
  simulateAjax($, '/CallScreen/BookJob', 500);
@@ -80,7 +162,66 @@ describe('[journey] instrumentJquery', () => {
80
162
  expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
81
163
  });
82
164
 
83
- test('stamped journeyStep is JSON-safe on jqXHR and ajax settings', async () => {
165
+ test('ignore: true on ajax settings skips a 5xx', async () => {
166
+ const $ = createJqueryMock();
167
+ instrumentJquery($);
168
+
169
+ moduleRuntime.startJourney({
170
+ name: 'jq-meta-ignore',
171
+ team: 'test',
172
+ group: 'test',
173
+ service: 'test',
174
+ timeoutMs: 0,
175
+ slowRequestMs: 50,
176
+ });
177
+
178
+ await journeyStep('jq-meta-ignore', 'book-job', () => {
179
+ const xhr = { status: 500 };
180
+ const settings = { url: '/telemetry', ignore: true };
181
+ for (const prefilter of $.__prefilters) {
182
+ prefilter(settings, settings, xhr);
183
+ }
184
+ for (const complete of $.__completes) {
185
+ complete({}, xhr, settings);
186
+ }
187
+ });
188
+ completeJourney('jq-meta-ignore');
189
+
190
+ expect(emitMock).toHaveBeenCalledTimes(1);
191
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
192
+ });
193
+
194
+ test('step.stamp({ ignore: true }) skips a 5xx', async () => {
195
+ const $ = createJqueryMock();
196
+ instrumentJquery($);
197
+
198
+ moduleRuntime.startJourney({
199
+ name: 'jq-stamp-ignore',
200
+ team: 'test',
201
+ group: 'test',
202
+ service: 'test',
203
+ timeoutMs: 0,
204
+ slowRequestMs: 50,
205
+ });
206
+
207
+ await journeyStep('jq-stamp-ignore', 'book-job', step => {
208
+ const tagged = step.stamp({ ignore: true });
209
+ const xhr = { status: 500, stamp: tagged };
210
+ const settings = { url: '/CallScreen/BookJob', stamp: tagged };
211
+ for (const prefilter of $.__prefilters) {
212
+ prefilter(settings, settings, xhr);
213
+ }
214
+ for (const complete of $.__completes) {
215
+ complete({}, xhr, settings);
216
+ }
217
+ });
218
+ completeJourney('jq-stamp-ignore');
219
+
220
+ expect(emitMock).toHaveBeenCalledTimes(1);
221
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
222
+ });
223
+
224
+ test('stamped stamp is JSON-safe on jqXHR and ajax settings', async () => {
84
225
  const $ = createJqueryMock();
85
226
  instrumentJquery($);
86
227
 
@@ -90,28 +231,30 @@ describe('[journey] instrumentJquery', () => {
90
231
  group: 'test',
91
232
  service: 'test',
92
233
  timeoutMs: 0,
93
- requestTimeoutMs: 5_000,
234
+ slowRequestMs: 5_000,
94
235
  });
95
236
 
96
237
  await journeyStep('jq-json-safe', 'book-job', step => {
97
238
  // Ambient stamp lands on jqXHR — common to serialize for logging.
98
239
  const ambientXhr: {
99
240
  status: number;
100
- journeyStep?: unknown;
241
+ stamp?: unknown;
101
242
  journeyStart?: number;
102
243
  } = { status: 200 };
103
244
  const ambientSettings = { url: '/CallScreen/BookJob' };
104
245
  for (const prefilter of $.__prefilters) {
105
246
  prefilter(ambientSettings, ambientSettings, ambientXhr);
106
247
  }
107
- expect(ambientXhr.journeyStep).toBeInstanceOf(JourneyStepTag);
108
- expect(JSON.parse(JSON.stringify(ambientXhr)).journeyStep).toBe('[JourneyStepTag]');
109
-
110
- // Explicit step.tag() spread into ajax settings — same circular risk.
111
- const tagged = step.tag() as { journeyStep: unknown };
112
- const settings = { url: '/CallScreen/BookJob', ...tagged };
113
- expect(settings.journeyStep).toBeInstanceOf(JourneyStepTag);
114
- expect(JSON.parse(JSON.stringify(settings)).journeyStep).toBe('[JourneyStepTag]');
248
+ expect(ambientXhr.stamp).toBeUndefined();
249
+ expect(JSON.parse(JSON.stringify(ambientXhr)).stamp).toBeUndefined();
250
+
251
+ // Explicit step.stamp() spread into ajax settings — stripped after capture.
252
+ const tagged = step.stamp();
253
+ const settings = { url: '/CallScreen/BookJob', stamp: tagged };
254
+ for (const prefilter of $.__prefilters) {
255
+ prefilter(settings, settings, { status: 200 });
256
+ }
257
+ expect(settings.stamp).toBeUndefined();
115
258
  });
116
259
  completeJourney('jq-json-safe');
117
260
  });
@@ -126,7 +269,7 @@ describe('[journey] instrumentJquery', () => {
126
269
  group: 'test',
127
270
  service: 'test',
128
271
  timeoutMs: 0,
129
- requestTimeoutMs: 5_000,
272
+ slowRequestMs: 5_000,
130
273
  });
131
274
 
132
275
  await journeyStep('jq-5xx', 'book-job', () => {
@@ -149,7 +292,7 @@ describe('[journey] instrumentJquery', () => {
149
292
  group: 'test',
150
293
  service: 'test',
151
294
  timeoutMs: 0,
152
- requestTimeoutMs: 50,
295
+ slowRequestMs: 50,
153
296
  });
154
297
 
155
298
  await journeyStep('jq-slow', 'book-job', () => {
@@ -172,7 +315,7 @@ describe('[journey] instrumentJquery', () => {
172
315
  group: 'test',
173
316
  service: 'test',
174
317
  timeoutMs: 0,
175
- requestTimeoutMs: 5_000,
318
+ slowRequestMs: 5_000,
176
319
  });
177
320
 
178
321
  await journeyStep('jq-network', 'book-job', () => {
@@ -194,7 +337,7 @@ describe('[journey] instrumentJquery', () => {
194
337
  group: 'test',
195
338
  service: 'test',
196
339
  timeoutMs: 0,
197
- requestTimeoutMs: 5_000,
340
+ slowRequestMs: 5_000,
198
341
  });
199
342
 
200
343
  await journeyStep('jq-4xx', 'book-job', () => {
@@ -206,7 +349,39 @@ describe('[journey] instrumentJquery', () => {
206
349
  expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
207
350
  });
208
351
 
209
- test('honors an explicit journeyStep already stamped on the XHR', async () => {
352
+ test('marks request-aborted when jqXHR statusText is abort', async () => {
353
+ getSharedPolicies().httpAbortedRequests = 'bad';
354
+ const $ = createJqueryMock();
355
+ instrumentJquery($);
356
+
357
+ moduleRuntime.startJourney({
358
+ name: 'jq-abort',
359
+ team: 'test',
360
+ group: 'test',
361
+ service: 'test',
362
+ timeoutMs: 0,
363
+ slowRequestMs: 5_000,
364
+ });
365
+
366
+ await journeyStep('jq-abort', 'book-job', () => {
367
+ const xhr = { status: 0, statusText: 'abort' };
368
+ const settings = { url: '/CallScreen/BookJob' };
369
+ const start = globalThis.performance.now();
370
+ for (const prefilter of $.__prefilters) {
371
+ prefilter(settings, settings, xhr);
372
+ }
373
+ (globalThis.performance.now as jest.Mock).mockReturnValue(start + 10);
374
+ for (const complete of $.__completes) {
375
+ complete({}, xhr, settings);
376
+ }
377
+ });
378
+
379
+ expect(emitMock).toHaveBeenCalledTimes(1);
380
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
381
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-aborted');
382
+ });
383
+
384
+ test('honors an explicit stamp already stamped on the XHR', async () => {
210
385
  const $ = createJqueryMock();
211
386
  instrumentJquery($);
212
387
 
@@ -216,23 +391,23 @@ describe('[journey] instrumentJquery', () => {
216
391
  group: 'test',
217
392
  service: 'test',
218
393
  timeoutMs: 0,
219
- requestTimeoutMs: 5_000,
394
+ slowRequestMs: 5_000,
220
395
  });
221
396
 
222
397
  await journeyStep('jq-explicit', 'book-job', step => {
223
- const tagged = step.tag() as { journeyStep: unknown };
398
+ const tagged = step.stamp();
224
399
  const xhr: {
225
400
  status: number;
226
- journeyStep: unknown;
401
+ stamp: unknown;
227
402
  journeyStart?: number;
228
- } = { status: 500, journeyStep: tagged.journeyStep };
403
+ } = { status: 500, stamp: tagged };
229
404
  const settings = { url: '/CallScreen/BookJob' };
230
405
  const start = globalThis.performance.now();
231
406
  for (const prefilter of $.__prefilters) {
232
407
  prefilter(settings, settings, xhr);
233
408
  }
234
409
  // Prefilter must not clear the explicit step; it should stamp a start time.
235
- expect(xhr.journeyStep).toBe(tagged.journeyStep);
410
+ expect(xhr.stamp).toBeUndefined();
236
411
  expect(xhr.journeyStart).toBeDefined();
237
412
  (globalThis.performance.now as jest.Mock).mockReturnValue(start + 20);
238
413
  for (const complete of $.__completes) {
@@ -255,13 +430,13 @@ describe('[journey] instrumentJquery', () => {
255
430
  group: 'test',
256
431
  service: 'test',
257
432
  timeoutMs: 0,
258
- requestTimeoutMs: 5_000,
433
+ slowRequestMs: 5_000,
259
434
  });
260
435
 
261
436
  await journeyStep('jq-no-start', 'book-job', step => {
262
- const tagged = step.tag() as { journeyStep: unknown };
437
+ const tagged = step.stamp();
263
438
  // Bypass prefilter stamping — only fire ajaxComplete with a step and no start.
264
- const xhr = { status: 200, journeyStep: tagged.journeyStep };
439
+ const xhr = { status: 200, stamp: tagged };
265
440
  for (const complete of $.__completes) {
266
441
  complete({}, xhr, { url: '/ok' });
267
442
  }
@@ -272,7 +447,7 @@ describe('[journey] instrumentJquery', () => {
272
447
  expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
273
448
  });
274
449
 
275
- test('honors step.tag() on ajax settings without deep-merge stack overflow', async () => {
450
+ test('honors step.stamp() on ajax settings without deep-merge stack overflow', async () => {
276
451
  const $ = createJqueryMock();
277
452
  instrumentJquery($);
278
453
 
@@ -312,21 +487,21 @@ describe('[journey] instrumentJquery', () => {
312
487
  group: 'test',
313
488
  service: 'test',
314
489
  timeoutMs: 0,
315
- requestTimeoutMs: 5_000,
490
+ slowRequestMs: 5_000,
316
491
  });
317
492
 
318
493
  await journeyStep('jq-settings-tag', 'book-job', step => {
319
- const tagged = step.tag() as { journeyStep: unknown };
320
- expect(() => deepExtend({}, tagged)).not.toThrow();
321
- const settings = deepExtend({ url: '/CallScreen/BookJob' }, tagged);
322
- const xhr: { status: number; journeyStep?: unknown; journeyStart?: number } = {
494
+ const tagged = step.stamp();
495
+ expect(() => deepExtend({}, { stamp: tagged })).not.toThrow();
496
+ const settings = deepExtend({ url: '/CallScreen/BookJob' }, { stamp: tagged });
497
+ const xhr: { status: number; stamp?: unknown; journeyStart?: number } = {
323
498
  status: 500,
324
499
  };
325
500
  const start = globalThis.performance.now();
326
501
  for (const prefilter of $.__prefilters) {
327
502
  prefilter(settings, settings, xhr);
328
503
  }
329
- expect(xhr.journeyStep).toBe(tagged.journeyStep);
504
+ expect(xhr.stamp).toBeUndefined();
330
505
  (globalThis.performance.now as jest.Mock).mockReturnValue(start + 20);
331
506
  for (const complete of $.__completes) {
332
507
  complete({}, xhr, settings);
@@ -348,7 +523,7 @@ describe('[journey] instrumentJquery', () => {
348
523
  group: 'test',
349
524
  service: 'test',
350
525
  timeoutMs: 0,
351
- requestTimeoutMs: 5_000,
526
+ slowRequestMs: 5_000,
352
527
  endpoints: [{ match: 'pdf.vendor.com/render', ignore: true }],
353
528
  });
354
529
 
@@ -1,9 +1,10 @@
1
1
  /** @jest-environment jsdom */
2
2
  import { render, screen } from '@testing-library/react';
3
- import { useContext } from 'react';
3
+ import { useContext, useEffect } from 'react';
4
4
 
5
5
  import { useJourneyTestRuntime } from '../../__test-utils__/test-runtime';
6
- import { completeJourney, failJourney } from '../../core';
6
+ import { completeJourney, failJourney, journeyStep } from '../../core';
7
+ import { onHttpComplete } from '../../integrations/http-report';
7
8
  import { JourneyNameContext } from '../../integrations/react/context';
8
9
  import { JourneyScope } from '../../integrations/react/scope';
9
10
 
@@ -25,6 +26,14 @@ function NameProbe() {
25
26
  describe('[journey] JourneyScope', () => {
26
27
  useJourneyTestRuntime(emitMock);
27
28
 
29
+ beforeEach(() => {
30
+ jest.useFakeTimers();
31
+ });
32
+
33
+ afterEach(() => {
34
+ jest.useRealTimers();
35
+ });
36
+
28
37
  test('opens a journey on mount and publishes the name via context', () => {
29
38
  render(
30
39
  <JourneyScope {...def}>
@@ -125,4 +134,153 @@ describe('[journey] JourneyScope', () => {
125
134
  expect(emitMock).not.toHaveBeenCalled();
126
135
  });
127
136
  });
137
+
138
+ test('applies updated tags without restarting', () => {
139
+ const { rerender } = render(
140
+ <JourneyScope {...def} tags={{ plan: 'a' }}>
141
+ <NameProbe />
142
+ </JourneyScope>
143
+ );
144
+
145
+ rerender(
146
+ <JourneyScope {...def} tags={{ plan: 'b' }}>
147
+ <NameProbe />
148
+ </JourneyScope>
149
+ );
150
+
151
+ completeJourney(def.name);
152
+ expect(emitMock).toHaveBeenCalledTimes(1);
153
+ expect(emitMock.mock.calls[0][0].journey.tags).toEqual({ plan: 'b' });
154
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
155
+ });
156
+
157
+ test('child abort cleanup then unmount is excluded not request-aborted', async () => {
158
+ let stamp: Parameters<typeof onHttpComplete>[0]['stamp'];
159
+ let release!: () => void;
160
+ const hang = new Promise<void>(resolve => {
161
+ release = resolve;
162
+ });
163
+
164
+ function Child() {
165
+ useEffect(
166
+ () => () => {
167
+ if (stamp) {
168
+ onHttpComplete({
169
+ stamp,
170
+ aborted: true,
171
+ requestKey: '/x',
172
+ durationMs: 5,
173
+ });
174
+ }
175
+ },
176
+ []
177
+ );
178
+ return null;
179
+ }
180
+
181
+ const { unmount } = render(
182
+ <JourneyScope {...def}>
183
+ <Child />
184
+ </JourneyScope>
185
+ );
186
+
187
+ const stepPromise = journeyStep(def.name, 'load', async step => {
188
+ stamp = step.stamp();
189
+ await hang;
190
+ });
191
+ await Promise.resolve();
192
+ unmount();
193
+
194
+ expect(emitMock).toHaveBeenCalledTimes(1);
195
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('excluded');
196
+
197
+ release();
198
+ await stepPromise;
199
+ });
200
+
201
+ test('rerendering with equal endpoint values does not re-arm timeout', async () => {
202
+ const timeoutDef = {
203
+ ...def,
204
+ name: 'scope-timer-stable',
205
+ timeoutMs: 30,
206
+ endpoints: [{ match: '/api/jobs', slowRequestMs: 100 }],
207
+ };
208
+
209
+ const { rerender } = render(
210
+ <JourneyScope {...timeoutDef}>
211
+ <NameProbe />
212
+ </JourneyScope>
213
+ );
214
+
215
+ await jest.advanceTimersByTimeAsync(20);
216
+
217
+ rerender(
218
+ <JourneyScope {...timeoutDef} endpoints={[{ match: '/api/jobs', slowRequestMs: 100 }]}>
219
+ <NameProbe />
220
+ </JourneyScope>
221
+ );
222
+
223
+ await jest.advanceTimersByTimeAsync(15);
224
+
225
+ expect(emitMock).toHaveBeenCalledTimes(1);
226
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
227
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('journey-timeout');
228
+ });
229
+
230
+ test('live config updates keep original timeout budget', async () => {
231
+ const timeoutDef = {
232
+ ...def,
233
+ name: 'scope-timeout-updates',
234
+ timeoutMs: 30,
235
+ team: 'team-a',
236
+ };
237
+
238
+ const { rerender } = render(
239
+ <JourneyScope {...timeoutDef}>
240
+ <NameProbe />
241
+ </JourneyScope>
242
+ );
243
+
244
+ await jest.advanceTimersByTimeAsync(20);
245
+
246
+ rerender(
247
+ <JourneyScope {...timeoutDef} team="team-b">
248
+ <NameProbe />
249
+ </JourneyScope>
250
+ );
251
+
252
+ await jest.advanceTimersByTimeAsync(15);
253
+
254
+ expect(emitMock).toHaveBeenCalledTimes(1);
255
+ expect(emitMock.mock.calls[0][0].journey.name).toBe(timeoutDef.name);
256
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('journey-timeout');
257
+ });
258
+
259
+ test('live maxSteps updates apply without restarting', async () => {
260
+ const maxStepsDef = {
261
+ ...def,
262
+ name: 'scope-max-steps',
263
+ maxSteps: 1,
264
+ };
265
+
266
+ const { rerender } = render(
267
+ <JourneyScope {...maxStepsDef}>
268
+ <NameProbe />
269
+ </JourneyScope>
270
+ );
271
+
272
+ await journeyStep(maxStepsDef.name, 'first', async () => {});
273
+
274
+ rerender(
275
+ <JourneyScope {...maxStepsDef} maxSteps={2}>
276
+ <NameProbe />
277
+ </JourneyScope>
278
+ );
279
+
280
+ await journeyStep(maxStepsDef.name, 'second', async () => {});
281
+
282
+ completeJourney(maxStepsDef.name);
283
+ expect(emitMock).toHaveBeenCalledTimes(1);
284
+ expect(emitMock.mock.calls[0][0].journey.steps).toHaveLength(2);
285
+ });
128
286
  });
@@ -0,0 +1,43 @@
1
+ /** @jest-environment jsdom */
2
+ import { renderHook } from '@testing-library/react';
3
+ import { type ReactNode } from 'react';
4
+
5
+ import {
6
+ useJourneyTestRuntime,
7
+ baseJourneyDef,
8
+ lastJourney,
9
+ } from '../../__test-utils__/test-runtime';
10
+ import { completeJourney, defineJourney } from '../../core';
11
+ import { JourneyScope } from '../../integrations/react/scope';
12
+ import { useJourneyStep } from '../../integrations/react/use-journey-step';
13
+
14
+ const emitMock = jest.fn();
15
+
16
+ describe('[journey] useJourneyStep', () => {
17
+ useJourneyTestRuntime(emitMock);
18
+
19
+ test('runs a step on the JourneyScope journey', async () => {
20
+ const wrapper = ({ children }: { children: ReactNode }) => (
21
+ <JourneyScope {...baseJourneyDef} name="hook-journey">
22
+ {children}
23
+ </JourneyScope>
24
+ );
25
+
26
+ const { result } = renderHook(() => useJourneyStep(), { wrapper });
27
+ await result.current('load', () => 'ok');
28
+ completeJourney('hook-journey');
29
+
30
+ expect(lastJourney(emitMock).steps[0].name).toBe('load');
31
+ expect(lastJourney(emitMock).outcome).toBe('good');
32
+ });
33
+
34
+ test('auto-starts when given a handle', async () => {
35
+ const handle = defineJourney({ ...baseJourneyDef, name: 'hook-handle' });
36
+ const { result } = renderHook(() => useJourneyStep(handle));
37
+ await result.current('load', () => 'ok');
38
+ handle.complete();
39
+
40
+ expect(lastJourney(emitMock).steps[0].name).toBe('load');
41
+ expect(lastJourney(emitMock).outcome).toBe('good');
42
+ });
43
+ });
@@ -23,12 +23,14 @@ describe('[journey] sendToConsole / isJourneyDebugEnabled', () => {
23
23
 
24
24
  beforeEach(() => {
25
25
  localStorage.clear();
26
+ sessionStorage.clear();
26
27
  debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => undefined);
27
28
  });
28
29
 
29
30
  afterEach(() => {
30
31
  debugSpy.mockRestore();
31
32
  localStorage.clear();
33
+ sessionStorage.clear();
32
34
  });
33
35
 
34
36
  describe('when st:journey:debug is not set', () => {
@@ -45,7 +47,7 @@ describe('[journey] sendToConsole / isJourneyDebugEnabled', () => {
45
47
 
46
48
  describe('when st:journey:debug is a non-true value', () => {
47
49
  beforeEach(() => {
48
- localStorage.setItem(JOURNEY_DEBUG_STORAGE_KEY, '1');
50
+ sessionStorage.setItem(JOURNEY_DEBUG_STORAGE_KEY, '1');
49
51
  });
50
52
 
51
53
  test('isJourneyDebugEnabled is false', () => {
@@ -61,7 +63,7 @@ describe('[journey] sendToConsole / isJourneyDebugEnabled', () => {
61
63
 
62
64
  describe('when st:journey:debug is true', () => {
63
65
  beforeEach(() => {
64
- localStorage.setItem(JOURNEY_DEBUG_STORAGE_KEY, 'true');
66
+ sessionStorage.setItem(JOURNEY_DEBUG_STORAGE_KEY, 'true');
65
67
  });
66
68
 
67
69
  test('isJourneyDebugEnabled is true', () => {
@@ -74,11 +76,14 @@ describe('[journey] sendToConsole / isJourneyDebugEnabled', () => {
74
76
  sendToConsole(event);
75
77
 
76
78
  expect(debugSpy).toHaveBeenCalledTimes(1);
77
- expect(debugSpy).toHaveBeenCalledWith('[journey] Book Job → bad', event.journey);
79
+ expect(debugSpy).toHaveBeenCalledWith(
80
+ '[journey] book_job → bad',
81
+ expect.objectContaining({ name: 'book_job', outcome: 'bad' })
82
+ );
78
83
  });
79
84
  });
80
85
 
81
- describe('when localStorage throws', () => {
86
+ describe('when sessionStorage throws', () => {
82
87
  beforeEach(() => {
83
88
  jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
84
89
  throw new Error('denied');