@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
@@ -4,7 +4,7 @@ import {
4
4
  lastJourney,
5
5
  useJourneyTestRuntime,
6
6
  } from '../../__test-utils__/test-runtime';
7
- import { completeJourney, failJourney, journeyStep, resolveJourneyStep } from '../../core';
7
+ import { completeJourney, failJourney, JourneyStepStamp, journeyStep } from '../../core';
8
8
  import { moduleRuntime } from '../../core/runtime';
9
9
 
10
10
  const emitMock = jest.fn();
@@ -19,7 +19,8 @@ describe('[journey] journeyStep', () => {
19
19
  moduleRuntime.startJourney(baseDef);
20
20
  });
21
21
 
22
- const subject = () => journeyStep(['missing', baseDef.name], 'do-work', step => step.name);
22
+ const subject = () =>
23
+ journeyStep(['missing', baseDef.name], 'do-work', step => step.journeyName);
23
24
 
24
25
  test('resolves the first open name', async () => {
25
26
  await expect(subject()).resolves.toBe(baseDef.name);
@@ -33,7 +34,7 @@ describe('[journey] journeyStep', () => {
33
34
  });
34
35
 
35
36
  const subject = () =>
36
- journeyStep([baseDef.name, otherDef.name], 'do-work', step => step.name);
37
+ journeyStep([baseDef.name, otherDef.name], 'do-work', step => step.journeyName);
37
38
 
38
39
  test('prefers the first name in order', async () => {
39
40
  await expect(subject()).resolves.toBe(baseDef.name);
@@ -43,9 +44,9 @@ describe('[journey] journeyStep', () => {
43
44
  describe('with no matching open journey', () => {
44
45
  const subject = () =>
45
46
  journeyStep(['missing-a', 'missing-b'], 'do-work', step => {
46
- step.complete({ a: 1 });
47
- step.fail('noop');
48
- return step.name;
47
+ step.completeJourney({ a: 1 });
48
+ step.failJourney('noop');
49
+ return step.journeyName;
49
50
  });
50
51
 
51
52
  test('runs untraced', async () => {
@@ -54,12 +55,21 @@ describe('[journey] journeyStep', () => {
54
55
  });
55
56
  });
56
57
 
58
+ describe('with a bare name and nothing open', () => {
59
+ test('does not auto-start', async () => {
60
+ await journeyStep(baseDef.name, 'do-work', step => {
61
+ expect(step.journeyName).toBeNull();
62
+ expect(step.stamp()).toBeUndefined();
63
+ });
64
+ expect(emitMock).not.toHaveBeenCalled();
65
+ });
66
+ });
67
+
57
68
  describe('with a null target', () => {
58
- const subject = () =>
59
- journeyStep(null, 'untraced', step => step.tag() as Record<string, unknown>);
69
+ const subject = () => journeyStep(null, 'untraced', step => step.stamp());
60
70
 
61
- test('returns an empty tag object', async () => {
62
- await expect(subject()).resolves.toEqual({});
71
+ test('returns undefined', async () => {
72
+ await expect(subject()).resolves.toBeUndefined();
63
73
  });
64
74
  });
65
75
 
@@ -72,7 +82,7 @@ describe('[journey] journeyStep', () => {
72
82
  const subject = () =>
73
83
  journeyStep(baseDef.name, 'do-work', step => {
74
84
  step.setAttribute('x', 1);
75
- step.complete({ 'payment.id': 42 });
85
+ step.completeJourney({ 'payment.id': 42 });
76
86
  });
77
87
 
78
88
  test('emits good with tags and attributes', async () => {
@@ -103,29 +113,63 @@ describe('[journey] journeyStep', () => {
103
113
  });
104
114
  });
105
115
 
106
- describe('when step.tag is called', () => {
107
- let tagged: Record<string, unknown> | null;
116
+ describe('when setAttribute uses unsafe or overflowing keys', () => {
117
+ test('skips prototype keys and caps the map', async () => {
118
+ await journeyStep(baseDef.name, 'attrs', step => {
119
+ step.setAttribute('__proto__', 'nope');
120
+ step.setAttribute('token', 'secret');
121
+ step.setAttribute('ok', 1);
122
+ for (let i = 0; i < 70; i += 1) {
123
+ step.setAttribute(`k${i}`, i);
124
+ }
125
+ });
126
+ completeJourney(baseDef.name);
127
+
128
+ const attributes = lastJourney(emitMock).steps[0].attributes as Record<
129
+ string,
130
+ unknown
131
+ >;
132
+ expect(Object.prototype.hasOwnProperty.call(attributes, '__proto__')).toBe(false);
133
+ expect(attributes.token).toBeUndefined();
134
+ expect(attributes.ok).toBe(1);
135
+ expect(Object.keys(attributes).length).toBe(64);
136
+ expect(({} as { polluted?: boolean }).polluted).toBeUndefined();
137
+ });
138
+ });
139
+
140
+ describe('when step.stamp is called', () => {
141
+ let tagged: JourneyStepStamp | undefined;
108
142
 
109
143
  beforeEach(() => {
110
- tagged = null;
144
+ tagged = undefined;
111
145
  });
112
146
 
113
147
  const subject = () =>
114
148
  journeyStep(baseDef.name, 'do-work', step => {
115
- tagged = step.tag() as Record<string, unknown>;
149
+ tagged = step.stamp();
116
150
  });
117
151
 
118
- test('binds journeyStep', async () => {
152
+ test('binds stamp', async () => {
119
153
  await subject();
120
154
 
121
- expect(tagged).toHaveProperty('journeyStep');
155
+ expect(tagged).toBeInstanceOf(JourneyStepStamp);
156
+ });
157
+ });
158
+
159
+ describe('when step.stamp is called with a score function', () => {
160
+ test('stores score on the stamp', async () => {
161
+ await journeyStep(baseDef.name, 'do-work', step => {
162
+ const score = () => 'exclude' as const;
163
+ const stamp = step.stamp(score);
164
+ expect(stamp?.score).toBe(score);
165
+ });
122
166
  });
123
167
  });
124
168
 
125
- describe('when step.fail is called', () => {
169
+ describe('when step.failJourney is called', () => {
126
170
  const subject = () =>
127
171
  journeyStep(baseDef.name, 'do-work', step => {
128
- step.fail('empty-catalog');
172
+ step.failJourney('empty-catalog');
129
173
  });
130
174
 
131
175
  test('marks bad without throwing', async () => {
@@ -145,7 +189,7 @@ describe('[journey] journeyStep', () => {
145
189
  test('marks step-error and rethrows', async () => {
146
190
  await expect(subject()).rejects.toThrow('boom');
147
191
  expect(lastJourney(emitMock).reason).toBe('step-error');
148
- expect(lastJourney(emitMock).steps[0].attributes).toEqual({ error: 'boom' });
192
+ expect(lastJourney(emitMock).steps[0].attributes).toBeUndefined();
149
193
  });
150
194
  });
151
195
 
@@ -156,27 +200,22 @@ describe('[journey] journeyStep', () => {
156
200
  throw 'string-boom';
157
201
  });
158
202
 
159
- test('stringifies the error attribute', async () => {
203
+ test('does not stringify the throw onto attributes', async () => {
160
204
  await expect(subject()).rejects.toBe('string-boom');
161
- expect(lastJourney(emitMock).steps[0].attributes).toEqual({
162
- error: 'string-boom',
163
- });
205
+ expect(lastJourney(emitMock).steps[0].attributes).toBeUndefined();
164
206
  });
165
207
  });
166
208
 
167
209
  describe('when error is pre-set on the step', () => {
168
210
  const subject = () =>
169
211
  journeyStep(baseDef.name, 'do-work', step => {
170
- const ref = resolveJourneyStep(
171
- (step.tag() as { journeyStep: unknown }).journeyStep
172
- )!;
173
- ref.attributes.error = 'already';
212
+ step.setAttribute('phase', 'load');
174
213
  throw new Error('boom');
175
214
  });
176
215
 
177
- test('preserves the first error attribute', async () => {
216
+ test('keeps developer attributes', async () => {
178
217
  await expect(subject()).rejects.toThrow('boom');
179
- expect(lastJourney(emitMock).steps[0].attributes).toEqual({ error: 'already' });
218
+ expect(lastJourney(emitMock).steps[0].attributes).toEqual({ phase: 'load' });
180
219
  });
181
220
  });
182
221
 
@@ -184,7 +223,7 @@ describe('[journey] journeyStep', () => {
184
223
  beforeEach(() => {
185
224
  // Parent already opened baseDef; restart with expected steps.
186
225
  emitMock.mockClear();
187
- moduleRuntime.startJourney({ ...baseDef, steps: ['load', 'save'] });
226
+ moduleRuntime.startJourney({ ...baseDef, expected: ['load', 'save'] });
188
227
  emitMock.mockClear();
189
228
  });
190
229
 
@@ -195,7 +234,7 @@ describe('[journey] journeyStep', () => {
195
234
  expect(emitMock).not.toHaveBeenCalled();
196
235
 
197
236
  await journeyStep(baseDef.name, 'save', step => {
198
- step.complete({ done: true });
237
+ step.completeJourney({ done: true });
199
238
  });
200
239
 
201
240
  expect(emitMock).toHaveBeenCalledTimes(1);
@@ -210,8 +249,8 @@ describe('[journey] journeyStep', () => {
210
249
  describe('with a JourneyDef-like object target', () => {
211
250
  const subject = () =>
212
251
  journeyStep(baseDef, 'via-def', step => {
213
- step.complete();
214
- return step.name;
252
+ step.completeJourney();
253
+ return step.journeyName;
215
254
  });
216
255
 
217
256
  test('resolves the def name', async () => {
@@ -241,8 +280,8 @@ describe('[journey] journeyStep', () => {
241
280
 
242
281
  const subject = () =>
243
282
  moduleRuntime.journeyMountStep(['missing', baseDef.name], 'mount-load', step => {
244
- step.complete();
245
- return step.name;
283
+ step.completeJourney();
284
+ return step.journeyName;
246
285
  });
247
286
 
248
287
  test('resolves the open candidate', async () => {
@@ -300,4 +339,61 @@ describe('[journey] journeyStep', () => {
300
339
  await stepPromise;
301
340
  });
302
341
  });
342
+
343
+ describe('when the same name is restarted while a step is in flight', () => {
344
+ test('completeJourney on the old step does not close the new instance', async () => {
345
+ moduleRuntime.startJourney(baseDef);
346
+ let resolveWork!: () => void;
347
+ const hang = new Promise<void>(resolve => {
348
+ resolveWork = resolve;
349
+ });
350
+ const stepPromise = journeyStep(baseDef.name, 'slow', async step => {
351
+ await hang;
352
+ step.completeJourney();
353
+ });
354
+ await Promise.resolve();
355
+ moduleRuntime.startJourney(baseDef);
356
+ expect(lastJourney(emitMock).outcome).toBe('excluded');
357
+ emitMock.mockClear();
358
+
359
+ resolveWork();
360
+ await stepPromise;
361
+ expect(emitMock).not.toHaveBeenCalled();
362
+
363
+ completeJourney(baseDef.name);
364
+ expect(lastJourney(emitMock).outcome).toBe('good');
365
+ expect(lastJourney(emitMock).steps).toHaveLength(0);
366
+ });
367
+ });
368
+
369
+ describe('when maxSteps is exceeded', () => {
370
+ test('stops recording steps and sets steps_truncated', async () => {
371
+ moduleRuntime.startJourney({ ...baseDef, maxSteps: 2 });
372
+ await journeyStep(baseDef.name, 'a', () => {});
373
+ await journeyStep(baseDef.name, 'b', () => {});
374
+ await journeyStep(baseDef.name, 'c', () => {});
375
+ completeJourney(baseDef.name);
376
+
377
+ expect(lastJourney(emitMock).steps.map((s: { name: string }) => s.name)).toEqual([
378
+ 'a',
379
+ 'b',
380
+ ]);
381
+ expect(lastJourney(emitMock).tags?.steps_truncated).toBe(true);
382
+ });
383
+ });
384
+
385
+ describe('when the started config is frozen', () => {
386
+ test('rejects mutation of the snapshot', () => {
387
+ const config = { ...baseDef, name: 'frozen-def' };
388
+ moduleRuntime.startJourney(config);
389
+ const snapshot = moduleRuntime.getOpenJourney('frozen-def')!.config;
390
+ expect(Object.isFrozen(snapshot)).toBe(true);
391
+ expect(snapshot).not.toBe(config);
392
+ expect(() => {
393
+ (snapshot as { name: string }).name = 'hacked';
394
+ }).toThrow();
395
+ expect(snapshot.name).toBe('frozen-def');
396
+ completeJourney('frozen-def');
397
+ });
398
+ });
303
399
  });
@@ -4,7 +4,8 @@ import {
4
4
  lastJourney,
5
5
  useJourneyTestRuntime,
6
6
  } from '../../__test-utils__/test-runtime';
7
- import { completeJourney, journeyStep, resolveJourneyStep } from '../../core';
7
+ import type { JourneyStepStamp } from '../../core';
8
+ import { completeJourney, journeyStep } from '../../core';
8
9
  import { moduleRuntime } from '../../core/runtime';
9
10
 
10
11
  const emitMock = jest.fn();
@@ -52,6 +53,24 @@ describe('[journey] timeouts', () => {
52
53
  });
53
54
  });
54
55
 
56
+ describe('without timeoutMs', () => {
57
+ beforeEach(() => {
58
+ moduleRuntime.startJourney({
59
+ name: 'timeout-idle',
60
+ team: baseDef.team,
61
+ group: baseDef.group,
62
+ service: baseDef.service,
63
+ });
64
+ });
65
+
66
+ test('closes excluded with journey-idle-timeout after default idle window', async () => {
67
+ await jest.advanceTimersByTimeAsync(15 * 60 * 1_000);
68
+
69
+ expect(lastJourney(emitMock).outcome).toBe('excluded');
70
+ expect(lastJourney(emitMock).reason).toBe('journey-idle-timeout');
71
+ });
72
+ });
73
+
55
74
  describe('with a step timeoutMs', () => {
56
75
  let resolveWork!: () => void;
57
76
  let stepPromise: Promise<unknown>;
@@ -154,9 +173,7 @@ describe('[journey] timeouts', () => {
154
173
  resolveWork = resolve;
155
174
  });
156
175
  stepPromise = journeyStep(baseDef.name, 'slow', async step => {
157
- const ref = resolveJourneyStep(
158
- (step.tag() as { journeyStep: unknown }).journeyStep
159
- )!;
176
+ const ref = (step.stamp() as JourneyStepStamp).step;
160
177
  ref.reason = 'pre-existing';
161
178
  ref.attributes.timeout_ms = 999;
162
179
  await hang;
@@ -199,6 +216,34 @@ describe('[journey] timeouts', () => {
199
216
  });
200
217
  });
201
218
 
219
+ describe('when a journey timeout closes a hung step', () => {
220
+ let releaseWork!: () => void;
221
+ let stepPromise: Promise<unknown>;
222
+
223
+ beforeEach(() => {
224
+ moduleRuntime.startJourney({ ...baseDef, timeoutMs: 50 });
225
+ const hang = new Promise<void>(resolve => {
226
+ releaseWork = resolve;
227
+ });
228
+ stepPromise = journeyStep(baseDef.name, 'slow', async () => {
229
+ await hang;
230
+ });
231
+ });
232
+
233
+ afterEach(async () => {
234
+ releaseWork();
235
+ await stepPromise;
236
+ });
237
+
238
+ test('releases inFlight state before the step settles', async () => {
239
+ await jest.advanceTimersByTimeAsync(50);
240
+
241
+ expect(moduleRuntime.activeJourneyStep()).toBeNull();
242
+ expect(lastJourney(emitMock).outcome).toBe('bad');
243
+ expect(lastJourney(emitMock).reason).toBe('journey-timeout');
244
+ });
245
+ });
246
+
202
247
  describe('with stepTimeoutMs 0', () => {
203
248
  let resolveWork!: () => void;
204
249
  let stepPromise: Promise<unknown>;
@@ -228,4 +273,24 @@ describe('[journey] timeouts', () => {
228
273
  expect(lastJourney(emitMock).outcome).toBe('good');
229
274
  });
230
275
  });
276
+
277
+ describe('with non-finite timeoutMs', () => {
278
+ test('Infinity disables the countdown', async () => {
279
+ moduleRuntime.startJourney({ ...baseDef, timeoutMs: Infinity });
280
+ await jest.advanceTimersByTimeAsync(60_000);
281
+
282
+ expect(emitMock).not.toHaveBeenCalled();
283
+ completeJourney(baseDef.name);
284
+ expect(lastJourney(emitMock).outcome).toBe('good');
285
+ });
286
+
287
+ test('NaN disables the countdown', async () => {
288
+ moduleRuntime.startJourney({ ...baseDef, timeoutMs: Number.NaN });
289
+ await jest.advanceTimersByTimeAsync(60_000);
290
+
291
+ expect(emitMock).not.toHaveBeenCalled();
292
+ completeJourney(baseDef.name);
293
+ expect(lastJourney(emitMock).outcome).toBe('good');
294
+ });
295
+ });
231
296
  });
@@ -1,13 +1,16 @@
1
1
  /** @jest-environment jsdom */
2
2
  import '../config';
3
3
  import { defineJourney, resetJourney } from '../core';
4
+ import { moduleRuntime } from '../core/runtime';
4
5
  import {
5
6
  JOURNEY_DEBUG_STORAGE_KEY,
6
7
  JOURNEY_ENABLED_STORAGE_KEY,
7
8
  ST_JOURNEY_GLOBAL_KEY,
9
+ ST_JOURNEY_SCHEMA_VERSION,
8
10
  disableJourney,
9
11
  enableJourney,
10
12
  isJourneyEnabled,
13
+ setJourneyDefaultEnabled,
11
14
  type StJourneyGlobal,
12
15
  } from '../global';
13
16
  import { setJourneyRum } from '../sinks/datadog';
@@ -19,6 +22,8 @@ function getStJourney(): StJourneyGlobal {
19
22
  describe('[journey] __stJourney global', () => {
20
23
  beforeEach(() => {
21
24
  localStorage.clear();
25
+ sessionStorage.clear();
26
+ setJourneyDefaultEnabled(false);
22
27
  delete getStJourney().rum;
23
28
  delete (globalThis as Record<string, unknown>).DD_RUM;
24
29
  resetJourney({ sinks: [] });
@@ -26,6 +31,8 @@ describe('[journey] __stJourney global', () => {
26
31
 
27
32
  afterEach(() => {
28
33
  localStorage.clear();
34
+ sessionStorage.clear();
35
+ setJourneyDefaultEnabled(false);
29
36
  delete getStJourney().rum;
30
37
  delete (globalThis as Record<string, unknown>).DD_RUM;
31
38
  resetJourney({ sinks: [] });
@@ -34,6 +41,7 @@ describe('[journey] __stJourney global', () => {
34
41
  test('installs __stJourney on package load', () => {
35
42
  expect(getStJourney()).toEqual(
36
43
  expect.objectContaining({
44
+ schemaVersion: ST_JOURNEY_SCHEMA_VERSION,
37
45
  enable: expect.any(Function),
38
46
  disable: expect.any(Function),
39
47
  isEnabled: expect.any(Function),
@@ -58,15 +66,47 @@ describe('[journey] __stJourney global', () => {
58
66
  expect(isJourneyEnabled()).toBe(false);
59
67
  });
60
68
 
61
- test('enableDebugging / disableDebugging toggle console-debug localStorage', () => {
69
+ test('isJourneyEnabled is true when host default is on', () => {
70
+ expect(isJourneyEnabled()).toBe(false);
71
+
72
+ setJourneyDefaultEnabled(true);
73
+ expect(isJourneyEnabled()).toBe(true);
74
+ expect(getStJourney().isEnabled()).toBe(true);
75
+
76
+ setJourneyDefaultEnabled(false);
77
+ expect(isJourneyEnabled()).toBe(false);
78
+ });
79
+
80
+ test('isJourneyEnabled is true when either host default or force flag is on', () => {
81
+ setJourneyDefaultEnabled(true);
82
+ enableJourney();
83
+ expect(isJourneyEnabled()).toBe(true);
84
+
85
+ setJourneyDefaultEnabled(false);
86
+ expect(isJourneyEnabled()).toBe(true);
87
+
88
+ disableJourney();
89
+ expect(isJourneyEnabled()).toBe(false);
90
+
91
+ setJourneyDefaultEnabled(true);
92
+ expect(isJourneyEnabled()).toBe(true);
93
+ });
94
+
95
+ test('host default is shared on __stJourney for cross-bundle reads', () => {
96
+ setJourneyDefaultEnabled(true);
97
+ expect(getStJourney().defaultEnabled).toBe(true);
98
+ expect(isJourneyEnabled()).toBe(true);
99
+ });
100
+
101
+ test('enableDebugging / disableDebugging toggle console-debug sessionStorage', () => {
62
102
  const st = getStJourney();
63
103
 
64
104
  st.enableDebugging();
65
- expect(localStorage.getItem(JOURNEY_DEBUG_STORAGE_KEY)).toBe('true');
105
+ expect(sessionStorage.getItem(JOURNEY_DEBUG_STORAGE_KEY)).toBe('true');
66
106
  expect(st.isDebugEnabled()).toBe(true);
67
107
 
68
108
  st.disableDebugging();
69
- expect(localStorage.getItem(JOURNEY_DEBUG_STORAGE_KEY)).toBeNull();
109
+ expect(sessionStorage.getItem(JOURNEY_DEBUG_STORAGE_KEY)).toBeNull();
70
110
  expect(st.isDebugEnabled()).toBe(false);
71
111
  });
72
112
 
@@ -91,6 +131,7 @@ describe('[journey] __stJourney global', () => {
91
131
  getStJourney().enableDebugging();
92
132
  const on = getStJourney().getDebugState();
93
133
  expect(on.debug).toBe(true);
134
+ expect(on.runtimes.some(r => r.id === moduleRuntime.id)).toBe(true);
94
135
  expect(on.runtimes.some(r => r.openJourneys.some(j => j.name === 'Book Job'))).toBe(true);
95
136
 
96
137
  getStJourney().disableDebugging();