@servicetitan/journey 2.0.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 (233) hide show
  1. package/README.md +1 -1
  2. package/dist/__test-utils__/test-runtime.d.ts.map +1 -0
  3. package/dist/__test-utils__/test-runtime.js +54 -0
  4. package/dist/__test-utils__/test-runtime.js.map +1 -0
  5. package/dist/__tests__/core/ambient.test.d.ts.map +1 -0
  6. package/dist/__tests__/core/api.test.d.ts +2 -0
  7. package/dist/__tests__/core/api.test.d.ts.map +1 -0
  8. package/dist/__tests__/core/define-journey.test.d.ts.map +1 -0
  9. package/dist/__tests__/core/requests.test.d.ts.map +1 -0
  10. package/dist/__tests__/core/runtime.test.d.ts.map +1 -0
  11. package/dist/__tests__/core/sanitize.test.d.ts +2 -0
  12. package/dist/__tests__/core/sanitize.test.d.ts.map +1 -0
  13. package/dist/__tests__/core/step.test.d.ts.map +1 -0
  14. package/dist/__tests__/core/timeouts.test.d.ts.map +1 -0
  15. package/dist/__tests__/global.test.d.ts +3 -0
  16. package/dist/__tests__/global.test.d.ts.map +1 -0
  17. package/dist/__tests__/hardening.test.d.ts +2 -0
  18. package/dist/__tests__/hardening.test.d.ts.map +1 -0
  19. package/dist/__tests__/integrations/axios.test.d.ts.map +1 -0
  20. package/dist/__tests__/integrations/context.test.d.ts.map +1 -0
  21. package/dist/__tests__/integrations/expose-app-journey.test.d.ts.map +1 -0
  22. package/dist/__tests__/integrations/fetch.test.d.ts.map +1 -0
  23. package/dist/__tests__/integrations/host-mfe.test.d.ts +2 -0
  24. package/dist/__tests__/integrations/host-mfe.test.d.ts.map +1 -0
  25. package/dist/__tests__/integrations/jquery.test.d.ts.map +1 -0
  26. package/dist/__tests__/integrations/request-key.test.d.ts.map +1 -0
  27. package/dist/__tests__/integrations/scope.test.d.ts.map +1 -0
  28. package/dist/__tests__/integrations/use-journey-step.test.d.ts +2 -0
  29. package/dist/__tests__/integrations/use-journey-step.test.d.ts.map +1 -0
  30. package/dist/__tests__/sinks/console.test.d.ts +2 -0
  31. package/dist/__tests__/sinks/console.test.d.ts.map +1 -0
  32. package/dist/__tests__/sinks/datadog.test.d.ts.map +1 -0
  33. package/dist/__tests__/sinks/sinks.test.d.ts.map +1 -0
  34. package/dist/config.d.ts +61 -13
  35. package/dist/config.d.ts.map +1 -1
  36. package/dist/config.js +98 -29
  37. package/dist/config.js.map +1 -1
  38. package/dist/core/abort-error.d.ts +4 -0
  39. package/dist/core/abort-error.d.ts.map +1 -0
  40. package/dist/core/abort-error.js +22 -0
  41. package/dist/core/abort-error.js.map +1 -0
  42. package/dist/core/api.d.ts +43 -0
  43. package/dist/core/api.d.ts.map +1 -0
  44. package/dist/core/api.js +122 -0
  45. package/dist/core/api.js.map +1 -0
  46. package/dist/{endpoint-policy.d.ts → core/endpoint-policy.d.ts} +14 -4
  47. package/dist/core/endpoint-policy.d.ts.map +1 -0
  48. package/dist/core/endpoint-policy.js +70 -0
  49. package/dist/core/endpoint-policy.js.map +1 -0
  50. package/dist/core/index.d.ts +6 -5
  51. package/dist/core/index.d.ts.map +1 -1
  52. package/dist/core/index.js +4 -3
  53. package/dist/core/index.js.map +1 -1
  54. package/dist/core/limits.d.ts +29 -0
  55. package/dist/core/limits.d.ts.map +1 -0
  56. package/dist/core/limits.js +63 -0
  57. package/dist/core/limits.js.map +1 -0
  58. package/dist/core/request-policy.d.ts +39 -0
  59. package/dist/core/request-policy.d.ts.map +1 -0
  60. package/dist/core/request-policy.js +18 -0
  61. package/dist/core/request-policy.js.map +1 -0
  62. package/dist/core/runtime.d.ts +95 -17
  63. package/dist/core/runtime.d.ts.map +1 -1
  64. package/dist/core/runtime.js +435 -124
  65. package/dist/core/runtime.js.map +1 -1
  66. package/dist/core/sanitize.d.ts +23 -0
  67. package/dist/core/sanitize.d.ts.map +1 -0
  68. package/dist/core/sanitize.js +143 -0
  69. package/dist/core/sanitize.js.map +1 -0
  70. package/dist/core/step-tag.d.ts +31 -6
  71. package/dist/core/step-tag.d.ts.map +1 -1
  72. package/dist/core/step-tag.js +62 -9
  73. package/dist/core/step-tag.js.map +1 -1
  74. package/dist/core/types.d.ts +76 -26
  75. package/dist/core/types.d.ts.map +1 -1
  76. package/dist/core/types.js +4 -1
  77. package/dist/core/types.js.map +1 -1
  78. package/dist/global.d.ts +145 -0
  79. package/dist/global.d.ts.map +1 -0
  80. package/dist/global.js +326 -0
  81. package/dist/global.js.map +1 -0
  82. package/dist/index.d.ts +9 -5
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +9 -5
  85. package/dist/index.js.map +1 -1
  86. package/dist/integrations/axios.d.ts +5 -1
  87. package/dist/integrations/axios.d.ts.map +1 -1
  88. package/dist/integrations/axios.js +146 -34
  89. package/dist/integrations/axios.js.map +1 -1
  90. package/dist/integrations/fetch.d.ts +6 -4
  91. package/dist/integrations/fetch.d.ts.map +1 -1
  92. package/dist/integrations/fetch.js +105 -26
  93. package/dist/integrations/fetch.js.map +1 -1
  94. package/dist/integrations/http-report.d.ts +27 -0
  95. package/dist/integrations/http-report.d.ts.map +1 -0
  96. package/dist/integrations/http-report.js +123 -0
  97. package/dist/integrations/http-report.js.map +1 -0
  98. package/dist/integrations/jquery/index.d.ts +10 -6
  99. package/dist/integrations/jquery/index.d.ts.map +1 -1
  100. package/dist/integrations/jquery/index.js +28 -9
  101. package/dist/integrations/jquery/index.js.map +1 -1
  102. package/dist/integrations/jquery/instrument.d.ts +14 -5
  103. package/dist/integrations/jquery/instrument.d.ts.map +1 -1
  104. package/dist/integrations/jquery/instrument.js +111 -29
  105. package/dist/integrations/jquery/instrument.js.map +1 -1
  106. package/dist/integrations/react/context.d.ts +4 -1
  107. package/dist/integrations/react/context.d.ts.map +1 -1
  108. package/dist/integrations/react/context.js +4 -1
  109. package/dist/integrations/react/context.js.map +1 -1
  110. package/dist/integrations/react/scope.d.ts +14 -3
  111. package/dist/integrations/react/scope.d.ts.map +1 -1
  112. package/dist/integrations/react/scope.js +48 -9
  113. package/dist/integrations/react/scope.js.map +1 -1
  114. package/dist/integrations/react/stable-config-keys.d.ts +6 -0
  115. package/dist/integrations/react/stable-config-keys.d.ts.map +1 -0
  116. package/dist/integrations/react/stable-config-keys.js +21 -0
  117. package/dist/integrations/react/stable-config-keys.js.map +1 -0
  118. package/dist/integrations/react/use-journey-step.d.ts +11 -0
  119. package/dist/integrations/react/use-journey-step.d.ts.map +1 -0
  120. package/dist/integrations/react/use-journey-step.js +19 -0
  121. package/dist/integrations/react/use-journey-step.js.map +1 -0
  122. package/dist/integrations/request-key.d.ts +8 -3
  123. package/dist/integrations/request-key.d.ts.map +1 -1
  124. package/dist/integrations/request-key.js +10 -9
  125. package/dist/integrations/request-key.js.map +1 -1
  126. package/dist/integrations/soft-skip.d.ts +7 -0
  127. package/dist/integrations/soft-skip.d.ts.map +1 -0
  128. package/dist/integrations/soft-skip.js +30 -0
  129. package/dist/integrations/soft-skip.js.map +1 -0
  130. package/dist/sinks/console.d.ts +11 -0
  131. package/dist/sinks/console.d.ts.map +1 -0
  132. package/dist/sinks/console.js +19 -0
  133. package/dist/sinks/console.js.map +1 -0
  134. package/dist/sinks/datadog.d.ts +12 -1
  135. package/dist/sinks/datadog.d.ts.map +1 -1
  136. package/dist/sinks/datadog.js +42 -25
  137. package/dist/sinks/datadog.js.map +1 -1
  138. package/package.json +2 -2
  139. package/src/{__mocks__ → __test-utils__}/test-runtime.ts +15 -4
  140. package/src/__tests__/config.test.ts +158 -42
  141. package/src/__tests__/{ambient.test.ts → core/ambient.test.ts} +23 -16
  142. package/src/__tests__/{registry.test.ts → core/api.test.ts} +35 -30
  143. package/src/__tests__/{define-journey.test.ts → core/define-journey.test.ts} +33 -15
  144. package/src/__tests__/{requests.test.ts → core/requests.test.ts} +61 -39
  145. package/src/__tests__/{runtime.test.ts → core/runtime.test.ts} +84 -37
  146. package/src/__tests__/core/sanitize.test.ts +69 -0
  147. package/src/__tests__/{step.test.ts → core/step.test.ts} +146 -52
  148. package/src/__tests__/{timeouts.test.ts → core/timeouts.test.ts} +83 -13
  149. package/src/__tests__/global.test.ts +154 -0
  150. package/src/__tests__/hardening.test.ts +613 -0
  151. package/src/__tests__/integrations/axios.test.ts +616 -0
  152. package/src/__tests__/{context.test.tsx → integrations/context.test.tsx} +1 -1
  153. package/src/__tests__/{expose-app-journey.test.ts → integrations/expose-app-journey.test.ts} +11 -5
  154. package/src/__tests__/{fetch.test.ts → integrations/fetch.test.ts} +159 -25
  155. package/src/__tests__/integrations/host-mfe.test.ts +548 -0
  156. package/src/__tests__/integrations/jquery.test.ts +539 -0
  157. package/src/__tests__/{request-key.test.ts → integrations/request-key.test.ts} +1 -1
  158. package/src/__tests__/integrations/scope.test.tsx +286 -0
  159. package/src/__tests__/integrations/use-journey-step.test.tsx +43 -0
  160. package/src/__tests__/sinks/console.test.ts +103 -0
  161. package/src/__tests__/{datadog.test.ts → sinks/datadog.test.ts} +109 -5
  162. package/src/__tests__/{sinks.test.ts → sinks/sinks.test.ts} +13 -12
  163. package/src/config.ts +173 -41
  164. package/src/core/abort-error.ts +24 -0
  165. package/src/core/api.ts +159 -0
  166. package/src/{endpoint-policy.ts → core/endpoint-policy.ts} +46 -4
  167. package/src/core/index.ts +16 -17
  168. package/src/core/limits.ts +73 -0
  169. package/src/core/request-policy.ts +66 -0
  170. package/src/core/runtime.ts +560 -137
  171. package/src/core/sanitize.ts +177 -0
  172. package/src/core/step-tag.ts +90 -11
  173. package/src/core/types.ts +82 -26
  174. package/src/global.ts +466 -0
  175. package/src/index.ts +31 -18
  176. package/src/integrations/axios.ts +183 -45
  177. package/src/integrations/fetch.ts +127 -41
  178. package/src/integrations/http-report.ts +184 -0
  179. package/src/integrations/jquery/index.ts +37 -18
  180. package/src/integrations/jquery/instrument.ts +126 -42
  181. package/src/integrations/react/context.ts +4 -1
  182. package/src/integrations/react/scope.tsx +61 -10
  183. package/src/integrations/react/stable-config-keys.ts +28 -0
  184. package/src/integrations/react/use-journey-step.ts +31 -0
  185. package/src/integrations/request-key.ts +18 -10
  186. package/src/integrations/soft-skip.ts +35 -0
  187. package/src/sinks/console.ts +21 -0
  188. package/src/sinks/datadog.ts +52 -33
  189. package/dist/__mocks__/test-runtime.d.ts.map +0 -1
  190. package/dist/__tests__/ambient.test.d.ts.map +0 -1
  191. package/dist/__tests__/axios.test.d.ts.map +0 -1
  192. package/dist/__tests__/context.test.d.ts.map +0 -1
  193. package/dist/__tests__/datadog.test.d.ts.map +0 -1
  194. package/dist/__tests__/define-journey.test.d.ts.map +0 -1
  195. package/dist/__tests__/expose-app-journey.test.d.ts.map +0 -1
  196. package/dist/__tests__/fetch.test.d.ts.map +0 -1
  197. package/dist/__tests__/jquery.test.d.ts.map +0 -1
  198. package/dist/__tests__/registry.test.d.ts +0 -2
  199. package/dist/__tests__/registry.test.d.ts.map +0 -1
  200. package/dist/__tests__/request-key.test.d.ts.map +0 -1
  201. package/dist/__tests__/requests.test.d.ts.map +0 -1
  202. package/dist/__tests__/runtime.test.d.ts.map +0 -1
  203. package/dist/__tests__/scope.test.d.ts.map +0 -1
  204. package/dist/__tests__/sinks.test.d.ts.map +0 -1
  205. package/dist/__tests__/step.test.d.ts.map +0 -1
  206. package/dist/__tests__/timeouts.test.d.ts.map +0 -1
  207. package/dist/core/registry.d.ts +0 -50
  208. package/dist/core/registry.d.ts.map +0 -1
  209. package/dist/core/registry.js +0 -102
  210. package/dist/core/registry.js.map +0 -1
  211. package/dist/endpoint-policy.d.ts.map +0 -1
  212. package/dist/endpoint-policy.js +0 -39
  213. package/dist/endpoint-policy.js.map +0 -1
  214. package/src/__tests__/axios.test.ts +0 -365
  215. package/src/__tests__/jquery.test.ts +0 -327
  216. package/src/__tests__/scope.test.tsx +0 -128
  217. package/src/core/registry.ts +0 -162
  218. /package/dist/{__mocks__ → __test-utils__}/test-runtime.d.ts +0 -0
  219. /package/dist/__tests__/{ambient.test.d.ts → core/ambient.test.d.ts} +0 -0
  220. /package/dist/__tests__/{define-journey.test.d.ts → core/define-journey.test.d.ts} +0 -0
  221. /package/dist/__tests__/{requests.test.d.ts → core/requests.test.d.ts} +0 -0
  222. /package/dist/__tests__/{runtime.test.d.ts → core/runtime.test.d.ts} +0 -0
  223. /package/dist/__tests__/{step.test.d.ts → core/step.test.d.ts} +0 -0
  224. /package/dist/__tests__/{timeouts.test.d.ts → core/timeouts.test.d.ts} +0 -0
  225. /package/dist/__tests__/{axios.test.d.ts → integrations/axios.test.d.ts} +0 -0
  226. /package/dist/__tests__/{context.test.d.ts → integrations/context.test.d.ts} +0 -0
  227. /package/dist/__tests__/{expose-app-journey.test.d.ts → integrations/expose-app-journey.test.d.ts} +0 -0
  228. /package/dist/__tests__/{fetch.test.d.ts → integrations/fetch.test.d.ts} +0 -0
  229. /package/dist/__tests__/{jquery.test.d.ts → integrations/jquery.test.d.ts} +0 -0
  230. /package/dist/__tests__/{request-key.test.d.ts → integrations/request-key.test.d.ts} +0 -0
  231. /package/dist/__tests__/{scope.test.d.ts → integrations/scope.test.d.ts} +0 -0
  232. /package/dist/__tests__/{datadog.test.d.ts → sinks/datadog.test.d.ts} +0 -0
  233. /package/dist/__tests__/{sinks.test.d.ts → sinks/sinks.test.d.ts} +0 -0
@@ -0,0 +1,286 @@
1
+ /** @jest-environment jsdom */
2
+ import { render, screen } from '@testing-library/react';
3
+ import { useContext, useEffect } from 'react';
4
+
5
+ import { useJourneyTestRuntime } from '../../__test-utils__/test-runtime';
6
+ import { completeJourney, failJourney, journeyStep } from '../../core';
7
+ import { onHttpComplete } from '../../integrations/http-report';
8
+ import { JourneyNameContext } from '../../integrations/react/context';
9
+ import { JourneyScope } from '../../integrations/react/scope';
10
+
11
+ const emitMock = jest.fn();
12
+
13
+ const def = {
14
+ name: 'scope-journey',
15
+ team: 'platform',
16
+ group: 'test',
17
+ service: 'journey',
18
+ timeoutMs: 0,
19
+ };
20
+
21
+ function NameProbe() {
22
+ const name = useContext(JourneyNameContext);
23
+ return <span data-testid="journey-name">{name}</span>;
24
+ }
25
+
26
+ describe('[journey] JourneyScope', () => {
27
+ useJourneyTestRuntime(emitMock);
28
+
29
+ beforeEach(() => {
30
+ jest.useFakeTimers();
31
+ });
32
+
33
+ afterEach(() => {
34
+ jest.useRealTimers();
35
+ });
36
+
37
+ test('opens a journey on mount and publishes the name via context', () => {
38
+ render(
39
+ <JourneyScope {...def}>
40
+ <NameProbe />
41
+ </JourneyScope>
42
+ );
43
+
44
+ expect(screen.getByTestId('journey-name').textContent).toBe(def.name);
45
+ completeJourney(def.name);
46
+ expect(emitMock).toHaveBeenCalledTimes(1);
47
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
48
+ });
49
+
50
+ test('applies tags from props', () => {
51
+ render(
52
+ <JourneyScope {...def} tags={{ plan: 'pro' }}>
53
+ <NameProbe />
54
+ </JourneyScope>
55
+ );
56
+
57
+ completeJourney(def.name);
58
+ expect(emitMock.mock.calls[0][0].journey.tags).toEqual({ plan: 'pro' });
59
+ });
60
+
61
+ test('excludes the journey on unmount', () => {
62
+ const { unmount } = render(
63
+ <JourneyScope {...def}>
64
+ <NameProbe />
65
+ </JourneyScope>
66
+ );
67
+
68
+ unmount();
69
+
70
+ expect(emitMock).toHaveBeenCalledTimes(1);
71
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('excluded');
72
+ });
73
+
74
+ describe('when the journey already completed before unmount', () => {
75
+ test('unmount does not emit again', () => {
76
+ const { unmount } = render(
77
+ <JourneyScope {...def}>
78
+ <NameProbe />
79
+ </JourneyScope>
80
+ );
81
+
82
+ completeJourney(def.name);
83
+ expect(emitMock).toHaveBeenCalledTimes(1);
84
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
85
+
86
+ emitMock.mockClear();
87
+ unmount();
88
+
89
+ expect(emitMock).not.toHaveBeenCalled();
90
+ });
91
+ });
92
+
93
+ describe('when the name prop changes', () => {
94
+ const next = { ...def, name: 'scope-journey-next' };
95
+
96
+ test('excludes the prior journey and opens the new one', () => {
97
+ const { rerender } = render(
98
+ <JourneyScope {...def}>
99
+ <NameProbe />
100
+ </JourneyScope>
101
+ );
102
+
103
+ rerender(
104
+ <JourneyScope {...next}>
105
+ <NameProbe />
106
+ </JourneyScope>
107
+ );
108
+
109
+ expect(screen.getByTestId('journey-name').textContent).toBe(next.name);
110
+ expect(emitMock).toHaveBeenCalledTimes(1);
111
+ expect(emitMock.mock.calls[0][0].journey.name).toBe(def.name);
112
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('excluded');
113
+
114
+ completeJourney(next.name);
115
+ expect(emitMock.mock.calls[1][0].journey.name).toBe(next.name);
116
+ expect(emitMock.mock.calls[1][0].journey.outcome).toBe('good');
117
+ });
118
+ });
119
+
120
+ describe('when a breach already occurred before unmount', () => {
121
+ test('unmount stays bad (breach-anchored)', () => {
122
+ const { unmount } = render(
123
+ <JourneyScope {...def}>
124
+ <NameProbe />
125
+ </JourneyScope>
126
+ );
127
+
128
+ failJourney(def.name, 'validation');
129
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
130
+
131
+ emitMock.mockClear();
132
+ unmount();
133
+
134
+ expect(emitMock).not.toHaveBeenCalled();
135
+ });
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
+ });
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
+ });
@@ -0,0 +1,103 @@
1
+ /** @jest-environment jsdom */
2
+ import type { JourneyEvent } from '../../core/types';
3
+ import { JOURNEY_DEBUG_STORAGE_KEY, isJourneyDebugEnabled } from '../../global';
4
+ import { sendToConsole } from '../../sinks/console';
5
+
6
+ function sampleEvent(overrides: Partial<JourneyEvent['journey']> = {}): JourneyEvent {
7
+ return {
8
+ journey: {
9
+ name: 'Book Job',
10
+ team: 'jbce',
11
+ group: 'call-booking',
12
+ service: 'call-screen',
13
+ outcome: 'good',
14
+ durationMs: 120,
15
+ steps: [],
16
+ ...overrides,
17
+ },
18
+ };
19
+ }
20
+
21
+ describe('[journey] sendToConsole / isJourneyDebugEnabled', () => {
22
+ let debugSpy: jest.SpyInstance;
23
+
24
+ beforeEach(() => {
25
+ localStorage.clear();
26
+ sessionStorage.clear();
27
+ debugSpy = jest.spyOn(console, 'debug').mockImplementation(() => undefined);
28
+ });
29
+
30
+ afterEach(() => {
31
+ debugSpy.mockRestore();
32
+ localStorage.clear();
33
+ sessionStorage.clear();
34
+ });
35
+
36
+ describe('when st:journey:debug is not set', () => {
37
+ test('isJourneyDebugEnabled is false', () => {
38
+ expect(isJourneyDebugEnabled()).toBe(false);
39
+ });
40
+
41
+ test('does not log', () => {
42
+ sendToConsole(sampleEvent());
43
+
44
+ expect(debugSpy).not.toHaveBeenCalled();
45
+ });
46
+ });
47
+
48
+ describe('when st:journey:debug is a non-true value', () => {
49
+ beforeEach(() => {
50
+ sessionStorage.setItem(JOURNEY_DEBUG_STORAGE_KEY, '1');
51
+ });
52
+
53
+ test('isJourneyDebugEnabled is false', () => {
54
+ expect(isJourneyDebugEnabled()).toBe(false);
55
+ });
56
+
57
+ test('does not log', () => {
58
+ sendToConsole(sampleEvent());
59
+
60
+ expect(debugSpy).not.toHaveBeenCalled();
61
+ });
62
+ });
63
+
64
+ describe('when st:journey:debug is true', () => {
65
+ beforeEach(() => {
66
+ sessionStorage.setItem(JOURNEY_DEBUG_STORAGE_KEY, 'true');
67
+ });
68
+
69
+ test('isJourneyDebugEnabled is true', () => {
70
+ expect(isJourneyDebugEnabled()).toBe(true);
71
+ });
72
+
73
+ test('logs the journey name, outcome, and payload', () => {
74
+ const event = sampleEvent({ outcome: 'bad', reason: 'request-error' });
75
+
76
+ sendToConsole(event);
77
+
78
+ expect(debugSpy).toHaveBeenCalledTimes(1);
79
+ expect(debugSpy).toHaveBeenCalledWith(
80
+ '[journey] book_job → bad',
81
+ expect.objectContaining({ name: 'book_job', outcome: 'bad' })
82
+ );
83
+ });
84
+ });
85
+
86
+ describe('when sessionStorage throws', () => {
87
+ beforeEach(() => {
88
+ jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
89
+ throw new Error('denied');
90
+ });
91
+ });
92
+
93
+ afterEach(() => {
94
+ jest.restoreAllMocks();
95
+ });
96
+
97
+ test('treats debug as disabled and does not throw', () => {
98
+ expect(isJourneyDebugEnabled()).toBe(false);
99
+ expect(() => sendToConsole(sampleEvent())).not.toThrow();
100
+ expect(debugSpy).not.toHaveBeenCalled();
101
+ });
102
+ });
103
+ });
@@ -1,6 +1,7 @@
1
1
  /** @jest-environment jsdom */
2
2
  const addAction = jest.fn();
3
3
  const importedAddAction = jest.fn();
4
+ const ddRumAddAction = jest.fn();
4
5
 
5
6
  jest.mock('@datadog/browser-rum', () => ({
6
7
  datadogRum: {
@@ -8,10 +9,19 @@ jest.mock('@datadog/browser-rum', () => ({
8
9
  },
9
10
  }));
10
11
 
11
- import type { JourneyEvent } from '../core/types';
12
- import { sendToDatadog, setJourneyRum } from '../sinks/datadog';
12
+ import type { JourneyEvent } from '../../core/types';
13
+ import { ST_JOURNEY_GLOBAL_KEY, type StJourneyGlobal } from '../../global';
14
+ import { normalizeTagValue, sendToDatadog, setJourneyRum } from '../../sinks/datadog';
13
15
 
14
- const RUM_GLOBAL_KEY = '__stJourneyRum';
16
+ const DD_RUM_KEY = 'DD_RUM';
17
+
18
+ function clearJourneyRumFallback(): void {
19
+ const st = (globalThis as Record<string, unknown>)[ST_JOURNEY_GLOBAL_KEY] as
20
+ StJourneyGlobal | undefined;
21
+ if (st) {
22
+ delete st.rum;
23
+ }
24
+ }
15
25
 
16
26
  function sampleEvent(overrides: Partial<JourneyEvent['journey']> = {}): JourneyEvent {
17
27
  return {
@@ -29,10 +39,21 @@ function sampleEvent(overrides: Partial<JourneyEvent['journey']> = {}): JourneyE
29
39
  }
30
40
 
31
41
  describe('[journey] setJourneyRum / sendToDatadog', () => {
42
+ let warnSpy: jest.SpyInstance;
43
+
32
44
  beforeEach(() => {
33
45
  addAction.mockClear();
34
46
  importedAddAction.mockClear();
35
- delete (globalThis as Record<string, unknown>)[RUM_GLOBAL_KEY];
47
+ ddRumAddAction.mockClear();
48
+ clearJourneyRumFallback();
49
+ delete (globalThis as Record<string, unknown>)[DD_RUM_KEY];
50
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
51
+ });
52
+
53
+ afterEach(() => {
54
+ warnSpy.mockRestore();
55
+ clearJourneyRumFallback();
56
+ delete (globalThis as Record<string, unknown>)[DD_RUM_KEY];
36
57
  });
37
58
 
38
59
  describe('without a shared RUM instance', () => {
@@ -47,7 +68,47 @@ describe('[journey] setJourneyRum / sendToDatadog', () => {
47
68
  });
48
69
  });
49
70
 
50
- describe('with setJourneyRum', () => {
71
+ describe('with globalThis.DD_RUM', () => {
72
+ beforeEach(() => {
73
+ (globalThis as Record<string, unknown>)[DD_RUM_KEY] = { addAction: ddRumAddAction };
74
+ });
75
+
76
+ test('prefers DD_RUM over setJourneyRum and the import', () => {
77
+ setJourneyRum({ addAction });
78
+ warnSpy.mockClear();
79
+ sendToDatadog(sampleEvent());
80
+
81
+ expect(ddRumAddAction).toHaveBeenCalledTimes(1);
82
+ expect(addAction).not.toHaveBeenCalled();
83
+ expect(importedAddAction).not.toHaveBeenCalled();
84
+ });
85
+ });
86
+
87
+ describe('when the resolved RUM stub has no addAction', () => {
88
+ test('is a no-op', () => {
89
+ (globalThis as Record<string, unknown>)[DD_RUM_KEY] = {};
90
+
91
+ expect(() => sendToDatadog(sampleEvent())).not.toThrow();
92
+ expect(importedAddAction).not.toHaveBeenCalled();
93
+ expect(addAction).not.toHaveBeenCalled();
94
+ });
95
+ });
96
+
97
+ describe('when setJourneyRum is called while globalThis.DD_RUM is set', () => {
98
+ test('logs a redundancy warning', () => {
99
+ (globalThis as Record<string, unknown>)[DD_RUM_KEY] = { addAction: ddRumAddAction };
100
+
101
+ setJourneyRum({ addAction });
102
+
103
+ expect(warnSpy).toHaveBeenCalledWith(
104
+ expect.stringContaining(
105
+ 'setJourneyRum() is unnecessary when globalThis.DD_RUM is set'
106
+ )
107
+ );
108
+ });
109
+ });
110
+
111
+ describe('with setJourneyRum (no DD_RUM)', () => {
51
112
  beforeEach(() => {
52
113
  setJourneyRum({ addAction });
53
114
  });
@@ -60,6 +121,7 @@ describe('[journey] setJourneyRum / sendToDatadog', () => {
60
121
 
61
122
  expect(addAction).toHaveBeenCalledTimes(1);
62
123
  expect(importedAddAction).not.toHaveBeenCalled();
124
+ expect(warnSpy).not.toHaveBeenCalled();
63
125
  });
64
126
 
65
127
  test('normalizes identity fields', () => {
@@ -127,6 +189,48 @@ describe('[journey] setJourneyRum / sendToDatadog', () => {
127
189
  expect(addAction.mock.calls[0][1].journey.reason).toBe('step_error');
128
190
  });
129
191
 
192
+ test('normalizeTagValue matches Datadog metric-tag rules', () => {
193
+ expect(normalizeTagValue('Tenant A!')).toBe('tenant_a');
194
+ expect(normalizeTagValue('Foo@@@Bar___')).toBe('foo_bar');
195
+ });
196
+
197
+ test('drops denied tag keys and normalizes tag keys on the wire', () => {
198
+ setJourneyRum({ addAction });
199
+ sendToDatadog(
200
+ sampleEvent({
201
+ tags: {
202
+ 'token': 'secret',
203
+ 'User Email': 'Pat!',
204
+ },
205
+ steps: [
206
+ {
207
+ name: 'Load Draft!!',
208
+ startMs: 0,
209
+ durationMs: 3,
210
+ outcome: 'good',
211
+ attributes: { token: 'nope', phase: 'Load' },
212
+ },
213
+ ],
214
+ })
215
+ );
216
+
217
+ const ctx = addAction.mock.calls[0][1];
218
+ expect(ctx.journey.tags.token).toBeUndefined();
219
+ expect(ctx.journey.tags.user_email).toBe('pat');
220
+ expect(ctx.journey.steps[0].name).toBe('load_draft');
221
+ expect(ctx.journey.steps[0].attributes.token).toBeUndefined();
222
+ expect(ctx.journey.steps[0].attributes.phase).toBe('load');
223
+ });
224
+
225
+ test('truncates long tag values on the wire', () => {
226
+ setJourneyRum({ addAction });
227
+ const long = `x${'a'.repeat(250)}`;
228
+ sendToDatadog(sampleEvent({ tags: { note: long } }));
229
+
230
+ const ctx = addAction.mock.calls[0][1];
231
+ expect(ctx.journey.tags.note.length).toBeLessThanOrEqual(200);
232
+ });
233
+
130
234
  test('maps camelCase fields to Datadog snake_case wire keys', () => {
131
235
  setJourneyRum({ addAction });
132
236
  sendToDatadog(
@@ -1,14 +1,15 @@
1
1
  /** @jest-environment jsdom */
2
- jest.mock('../sinks/datadog', () => ({
2
+ jest.mock('../../sinks/datadog', () => ({
3
3
  sendToDatadog: jest.fn(),
4
4
  }));
5
5
 
6
- import { resetJourney } from '../core';
7
- import { addJourneySink, emitJourneyEvent, setJourneySinks } from '../core/registry';
8
- import type { JourneyEvent } from '../core/types';
9
- import { sendToDatadog } from '../sinks/datadog';
6
+ import { resetJourney } from '../../core';
7
+ import { moduleRuntime } from '../../core/runtime';
8
+ import type { JourneyEvent } from '../../core/types';
9
+ import { sendToConsole } from '../../sinks/console';
10
+ import { sendToDatadog } from '../../sinks/datadog';
10
11
 
11
- describe('[journey] emitJourneyEvent', () => {
12
+ describe('[journey] moduleRuntime sinks', () => {
12
13
  let payload: JourneyEvent;
13
14
 
14
15
  beforeEach(() => {
@@ -28,10 +29,10 @@ describe('[journey] emitJourneyEvent', () => {
28
29
  });
29
30
 
30
31
  afterEach(() => {
31
- resetJourney({ sinks: [sendToDatadog] });
32
+ resetJourney({ sinks: [sendToDatadog, sendToConsole] });
32
33
  });
33
34
 
34
- const subject = () => emitJourneyEvent(payload);
35
+ const subject = () => moduleRuntime.emit(payload);
35
36
 
36
37
  test('forwards the payload to each configured sink', () => {
37
38
  subject();
@@ -44,7 +45,7 @@ describe('[journey] emitJourneyEvent', () => {
44
45
 
45
46
  beforeEach(() => {
46
47
  extra = jest.fn();
47
- addJourneySink(extra);
48
+ moduleRuntime.addSink(extra);
48
49
  });
49
50
 
50
51
  test('invokes every configured sink', () => {
@@ -55,7 +56,7 @@ describe('[journey] emitJourneyEvent', () => {
55
56
  });
56
57
 
57
58
  test('continues to later sinks when one throws', () => {
58
- setJourneySinks([
59
+ moduleRuntime.setSinks([
59
60
  () => {
60
61
  throw new Error('boom');
61
62
  },
@@ -74,7 +75,7 @@ describe('[journey] emitJourneyEvent', () => {
74
75
 
75
76
  beforeEach(() => {
76
77
  only = jest.fn();
77
- setJourneySinks([only]);
78
+ moduleRuntime.setSinks([only]);
78
79
  });
79
80
 
80
81
  test('does not call the previous sinks', () => {
@@ -87,7 +88,7 @@ describe('[journey] emitJourneyEvent', () => {
87
88
 
88
89
  describe('with an empty sink list', () => {
89
90
  beforeEach(() => {
90
- setJourneySinks([]);
91
+ moduleRuntime.setSinks([]);
91
92
  });
92
93
 
93
94
  test('emits nowhere', () => {