@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,616 @@
1
+ /** @jest-environment jsdom */
2
+ import { useJourneyTestRuntime } from '../../__test-utils__/test-runtime';
3
+ import { completeJourney, journeyStep } from '../../core';
4
+ import { moduleRuntime } from '../../core/runtime';
5
+ import { getSharedPolicies } from '../../global';
6
+ import { instrumentAxios, type AxiosLikeInstance } from '../../integrations/axios';
7
+
8
+ const emitMock = jest.fn();
9
+
10
+ type RequestHandler = (config: any) => any;
11
+ type ResponseOk = (response: any) => any;
12
+ type ResponseErr = (error: any) => any;
13
+
14
+ function createAxiosMock(): AxiosLikeInstance & {
15
+ requestHandlers: RequestHandler[];
16
+ responseOkHandlers: ResponseOk[];
17
+ responseErrHandlers: ResponseErr[];
18
+ } {
19
+ const request: RequestHandler[] = [];
20
+ const responseOk: ResponseOk[] = [];
21
+ const responseErr: ResponseErr[] = [];
22
+
23
+ return {
24
+ defaults: { baseURL: 'https://app.example.com' },
25
+ interceptors: {
26
+ request: {
27
+ use(onFulfilled) {
28
+ request.push(onFulfilled);
29
+ return request.length - 1;
30
+ },
31
+ eject(id) {
32
+ if (typeof id === 'number') {
33
+ request.splice(id, 1);
34
+ }
35
+ },
36
+ },
37
+ response: {
38
+ use(onFulfilled, onRejected) {
39
+ responseOk.push(onFulfilled);
40
+ if (onRejected) {
41
+ responseErr.push(onRejected);
42
+ }
43
+ return responseOk.length - 1;
44
+ },
45
+ eject(id) {
46
+ if (typeof id === 'number') {
47
+ responseOk.splice(id, 1);
48
+ responseErr.splice(id, 1);
49
+ }
50
+ },
51
+ },
52
+ },
53
+ requestHandlers: request,
54
+ responseOkHandlers: responseOk,
55
+ responseErrHandlers: responseErr,
56
+ };
57
+ }
58
+
59
+ describe('[journey] instrumentAxios', () => {
60
+ const def = {
61
+ name: 'axios-journey',
62
+ team: 'test',
63
+ group: 'test',
64
+ service: 'test',
65
+ timeoutMs: 0,
66
+ slowRequestMs: 5_000,
67
+ };
68
+
69
+ useJourneyTestRuntime(emitMock);
70
+
71
+ beforeEach(() => {
72
+ jest.spyOn(globalThis.performance, 'now').mockReturnValue(1_000);
73
+ });
74
+
75
+ afterEach(() => {
76
+ jest.restoreAllMocks();
77
+ });
78
+
79
+ test('is idempotent for the same axios instance', () => {
80
+ const axios = createAxiosMock();
81
+ instrumentAxios(axios);
82
+ instrumentAxios(axios);
83
+
84
+ expect(axios.requestHandlers).toHaveLength(1);
85
+ expect(axios.responseOkHandlers).toHaveLength(1);
86
+ expect(axios.responseErrHandlers).toHaveLength(1);
87
+ });
88
+
89
+ test.each([undefined, null])('warns and soft-skips when axios is missing (%p)', bad => {
90
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
91
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
92
+
93
+ expect(() => instrumentAxios(bad)).not.toThrow();
94
+ expect(warn).toHaveBeenCalledWith(
95
+ expect.stringContaining('[journey] instrumentAxios: skipped — missing')
96
+ );
97
+ expect(error).not.toHaveBeenCalled();
98
+ });
99
+
100
+ test.each([{}, { interceptors: {} }, { interceptors: { request: {}, response: {} } }])(
101
+ 'errors and soft-skips when axios has invalid shape (%p)',
102
+ bad => {
103
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
104
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
105
+
106
+ expect(() => instrumentAxios(bad as never)).not.toThrow();
107
+ expect(error).toHaveBeenCalledWith(
108
+ expect.stringContaining('[journey] instrumentAxios: skipped — invalid shape (')
109
+ );
110
+ expect(error.mock.calls[0]).toHaveLength(1);
111
+ expect(warn).not.toHaveBeenCalled();
112
+ }
113
+ );
114
+
115
+ test('retries instrumentation after a partial wire failure', () => {
116
+ const axios = createAxiosMock();
117
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
118
+ axios.interceptors.response.use = () => {
119
+ throw new Error('response interceptor boom');
120
+ };
121
+
122
+ instrumentAxios(axios);
123
+ expect(axios.requestHandlers).toHaveLength(0);
124
+
125
+ axios.interceptors.response.use = (onFulfilled, onRejected) => {
126
+ axios.responseOkHandlers.push(onFulfilled);
127
+ if (onRejected) {
128
+ axios.responseErrHandlers.push(onRejected);
129
+ }
130
+ return axios.responseOkHandlers.length - 1;
131
+ };
132
+ instrumentAxios(axios);
133
+
134
+ expect(axios.requestHandlers).toHaveLength(1);
135
+ expect(axios.responseOkHandlers).toHaveLength(1);
136
+ expect(error).toHaveBeenCalled();
137
+ });
138
+
139
+ test('restore ejects interceptors so a later wire does not stack', () => {
140
+ const axios = createAxiosMock();
141
+ const restore = instrumentAxios(axios);
142
+ expect(axios.requestHandlers).toHaveLength(1);
143
+ expect(axios.responseOkHandlers).toHaveLength(1);
144
+
145
+ restore();
146
+ expect(axios.requestHandlers).toHaveLength(0);
147
+ expect(axios.responseOkHandlers).toHaveLength(0);
148
+
149
+ instrumentAxios(axios);
150
+ expect(axios.requestHandlers).toHaveLength(1);
151
+ expect(axios.responseOkHandlers).toHaveLength(1);
152
+ });
153
+
154
+ test('reports a successful response against the ambient step', async () => {
155
+ const axios = createAxiosMock();
156
+ instrumentAxios(axios);
157
+
158
+ moduleRuntime.startJourney(def);
159
+ await journeyStep(def.name, 'book-job', () => {
160
+ const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
161
+ expect(config.stamp).toBeUndefined();
162
+ expect(config.journeyStart).toBe(1_000);
163
+
164
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_050);
165
+ const response = axios.responseOkHandlers[0]({ status: 200, config });
166
+ expect(response.status).toBe(200);
167
+ });
168
+ completeJourney(def.name);
169
+
170
+ expect(emitMock).toHaveBeenCalledTimes(1);
171
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
172
+ });
173
+
174
+ test('stamped stamp is JSON-safe (AxiosError.toJSON includes config)', async () => {
175
+ const axios = createAxiosMock();
176
+ instrumentAxios(axios);
177
+
178
+ moduleRuntime.startJourney(def);
179
+ await journeyStep(def.name, 'book-job', step => {
180
+ // Ambient stamp — AxiosError.toJSON() embeds config by reference.
181
+ const ambient = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
182
+ expect(ambient.stamp).toBeUndefined();
183
+ const ambientSerialized = JSON.stringify({
184
+ message: 'Request failed',
185
+ name: 'AxiosError',
186
+ config: ambient,
187
+ status: 500,
188
+ });
189
+ expect(JSON.parse(ambientSerialized).config.stamp).toBeUndefined();
190
+
191
+ // Explicit step.stamp() stays on config, but serializes safely via toJSON.
192
+ const tagged = step.stamp();
193
+ const explicit = axios.requestHandlers[0]({
194
+ url: '/CallScreen/BookJob',
195
+ stamp: tagged,
196
+ });
197
+ expect(explicit.stamp).toBe(tagged);
198
+ expect(JSON.parse(JSON.stringify(explicit)).stamp).toBe('[JourneyStepStamp]');
199
+ });
200
+ completeJourney(def.name);
201
+ });
202
+
203
+ test('marks journey bad on 5xx via the rejection interceptor', async () => {
204
+ const axios = createAxiosMock();
205
+ instrumentAxios(axios);
206
+
207
+ moduleRuntime.startJourney(def);
208
+ await expect(
209
+ journeyStep(def.name, 'book-job', async () => {
210
+ const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
211
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
212
+ await axios.responseErrHandlers[0]({
213
+ config,
214
+ response: { status: 500 },
215
+ });
216
+ })
217
+ ).rejects.toMatchObject({ response: { status: 500 } });
218
+
219
+ expect(emitMock).toHaveBeenCalledTimes(1);
220
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
221
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
222
+ });
223
+
224
+ test('honors an explicit stamp from step.stamp()', async () => {
225
+ const axios = createAxiosMock();
226
+ instrumentAxios(axios);
227
+
228
+ moduleRuntime.startJourney(def);
229
+ await journeyStep(def.name, 'book-job', async step => {
230
+ const tagged = step.stamp();
231
+ const config = axios.requestHandlers[0]({
232
+ url: '/CallScreen/BookJob',
233
+ stamp: tagged,
234
+ });
235
+ expect(config.stamp).toBe(tagged);
236
+
237
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
238
+ await expect(
239
+ axios.responseErrHandlers[0]({
240
+ config,
241
+ response: { status: 503 },
242
+ })
243
+ ).rejects.toBeDefined();
244
+ });
245
+
246
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
247
+ });
248
+
249
+ test('step.stamp score can exclude a 5xx from the response body', async () => {
250
+ const axios = createAxiosMock();
251
+ instrumentAxios(axios);
252
+
253
+ moduleRuntime.startJourney(def);
254
+ await expect(
255
+ journeyStep(def.name, 'book-job', async step => {
256
+ const tagged = step.stamp(ctx => {
257
+ const data = (ctx.response as { data?: { code?: string } } | undefined)?.data;
258
+ return data?.code === 'USER_DATA' ? 'exclude' : undefined;
259
+ });
260
+ const config = axios.requestHandlers[0]({
261
+ url: '/CallScreen/BookJob',
262
+ stamp: tagged,
263
+ });
264
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
265
+ await axios.responseErrHandlers[0]({
266
+ config,
267
+ response: { status: 500, data: { code: 'USER_DATA' } },
268
+ });
269
+ })
270
+ ).rejects.toMatchObject({ response: { status: 500 } });
271
+
272
+ expect(emitMock).toHaveBeenCalledTimes(1);
273
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('excluded');
274
+ });
275
+
276
+ test('ambient auto-attribute does not run a stamp score', async () => {
277
+ const axios = createAxiosMock();
278
+ instrumentAxios(axios);
279
+
280
+ moduleRuntime.startJourney(def);
281
+ await expect(
282
+ journeyStep(def.name, 'book-job', async step => {
283
+ step.stamp(() => 'exclude');
284
+ const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
285
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
286
+ await axios.responseErrHandlers[0]({
287
+ config,
288
+ response: { status: 500, data: { code: 'USER_DATA' } },
289
+ });
290
+ })
291
+ ).rejects.toMatchObject({ response: { status: 500 } });
292
+
293
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
294
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
295
+ });
296
+
297
+ test('step.stamp({ ignore: true }) does not score a 5xx', async () => {
298
+ const axios = createAxiosMock();
299
+ instrumentAxios(axios);
300
+
301
+ moduleRuntime.startJourney(def);
302
+ await journeyStep(def.name, 'book-job', async step => {
303
+ const tagged = step.stamp({ ignore: true });
304
+ const config = axios.requestHandlers[0]({
305
+ url: '/CallScreen/BookJob',
306
+ stamp: tagged,
307
+ });
308
+ expect(config.stamp).toBe(tagged);
309
+ await expect(
310
+ axios.responseErrHandlers[0]({
311
+ config,
312
+ response: { status: 500 },
313
+ })
314
+ ).rejects.toMatchObject({ response: { status: 500 } });
315
+ });
316
+ completeJourney(def.name);
317
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
318
+ });
319
+
320
+ test('ignore: true skips auto-attribute of a 5xx', async () => {
321
+ const axios = createAxiosMock();
322
+ instrumentAxios(axios);
323
+
324
+ moduleRuntime.startJourney(def);
325
+ await journeyStep(def.name, 'book-job', async () => {
326
+ const config = axios.requestHandlers[0]({
327
+ url: '/telemetry',
328
+ ignore: true,
329
+ });
330
+ expect(config.stamp).toBeUndefined();
331
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
332
+ await expect(
333
+ axios.responseErrHandlers[0]({
334
+ config,
335
+ response: { status: 500 },
336
+ })
337
+ ).rejects.toMatchObject({ response: { status: 500 } });
338
+ });
339
+ completeJourney(def.name);
340
+
341
+ expect(emitMock).toHaveBeenCalledTimes(1);
342
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
343
+ });
344
+
345
+ test('soft ambient: overlapping steps do not auto-stamp HTTP', async () => {
346
+ const other = { ...def, name: 'axios-journey-other' };
347
+ const axios = createAxiosMock();
348
+ instrumentAxios(axios);
349
+
350
+ moduleRuntime.startJourney(def);
351
+ moduleRuntime.startJourney(other);
352
+
353
+ let releaseA!: () => void;
354
+ const gateA = new Promise<void>(r => {
355
+ releaseA = r;
356
+ });
357
+
358
+ const stepA = journeyStep(def.name, 'load', async () => {
359
+ await gateA;
360
+ });
361
+
362
+ await journeyStep(other.name, 'book', async () => {
363
+ const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
364
+ expect(config.stamp).toBeUndefined();
365
+
366
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
367
+ await expect(
368
+ axios.responseErrHandlers[0]({
369
+ config,
370
+ response: { status: 500 },
371
+ })
372
+ ).rejects.toMatchObject({ response: { status: 500 } });
373
+ });
374
+
375
+ releaseA();
376
+ await stepA;
377
+
378
+ expect(emitMock).not.toHaveBeenCalled();
379
+ completeJourney(def.name);
380
+ completeJourney(other.name);
381
+ expect(emitMock).toHaveBeenCalledTimes(2);
382
+ expect(emitMock.mock.calls.every(c => c[0].journey.outcome === 'good')).toBe(true);
383
+ });
384
+
385
+ test('soft ambient: step.stamp() still attributes during overlap', async () => {
386
+ const other = { ...def, name: 'axios-journey-tagged' };
387
+ const axios = createAxiosMock();
388
+ instrumentAxios(axios);
389
+
390
+ moduleRuntime.startJourney(def);
391
+ moduleRuntime.startJourney(other);
392
+
393
+ let releaseA!: () => void;
394
+ const gateA = new Promise<void>(r => {
395
+ releaseA = r;
396
+ });
397
+
398
+ const stepA = journeyStep(def.name, 'load', async () => {
399
+ await gateA;
400
+ });
401
+
402
+ await expect(
403
+ journeyStep(other.name, 'book', async step => {
404
+ const tagged = step.stamp();
405
+ const config = axios.requestHandlers[0]({
406
+ url: '/CallScreen/BookJob',
407
+ stamp: tagged,
408
+ });
409
+ expect(config.stamp).toBe(tagged);
410
+
411
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
412
+ await axios.responseErrHandlers[0]({
413
+ config,
414
+ response: { status: 500 },
415
+ });
416
+ })
417
+ ).rejects.toMatchObject({ response: { status: 500 } });
418
+
419
+ releaseA();
420
+ await stepA;
421
+
422
+ expect(emitMock).toHaveBeenCalledTimes(1);
423
+ expect(emitMock.mock.calls[0][0].journey.name).toBe(other.name);
424
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
425
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
426
+
427
+ completeJourney(def.name);
428
+ });
429
+
430
+ test('keeps explicit stamp attribution when a later interceptor clones config', async () => {
431
+ const axios = createAxiosMock();
432
+ instrumentAxios(axios);
433
+
434
+ moduleRuntime.startJourney(def);
435
+ await expect(
436
+ journeyStep(def.name, 'book-job', async step => {
437
+ const tagged = step.stamp();
438
+ const config = axios.requestHandlers[0]({
439
+ url: '/CallScreen/BookJob',
440
+ stamp: tagged,
441
+ });
442
+ const clonedConfig = { ...config };
443
+
444
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
445
+ await axios.responseErrHandlers[0]({
446
+ config: clonedConfig,
447
+ response: { status: 500 },
448
+ });
449
+ })
450
+ ).rejects.toMatchObject({ response: { status: 500 } });
451
+
452
+ expect(emitMock).toHaveBeenCalledTimes(1);
453
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
454
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
455
+ });
456
+
457
+ test('ignores responses with no journey step on the config', () => {
458
+ const axios = createAxiosMock();
459
+ instrumentAxios(axios);
460
+
461
+ // Outside a step — request interceptor finds no ambient step.
462
+ const config = axios.requestHandlers[0]({ url: '/orphan' });
463
+ expect(config.stamp).toBeUndefined();
464
+ axios.responseOkHandlers[0]({ status: 500, config });
465
+
466
+ expect(emitMock).not.toHaveBeenCalled();
467
+ });
468
+
469
+ test('non-axios errors without config are rethrown without reporting', async () => {
470
+ const axios = createAxiosMock();
471
+ instrumentAxios(axios);
472
+
473
+ await expect(axios.responseErrHandlers[0](new Error('boom'))).rejects.toThrow('boom');
474
+ expect(emitMock).not.toHaveBeenCalled();
475
+ });
476
+
477
+ test('falls back to performance.now when journeyStart is missing', async () => {
478
+ const axios = createAxiosMock();
479
+ instrumentAxios(axios);
480
+
481
+ moduleRuntime.startJourney(def);
482
+ await journeyStep(def.name, 'book-job', step => {
483
+ const tagged = step.stamp();
484
+ const config = {
485
+ url: '/CallScreen/BookJob',
486
+ stamp: tagged,
487
+ };
488
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_200);
489
+ axios.responseOkHandlers[0]({ status: 200, config });
490
+ });
491
+ completeJourney(def.name);
492
+
493
+ expect(emitMock).toHaveBeenCalledTimes(1);
494
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
495
+ });
496
+
497
+ test('marks request-latency on a slow successful response', async () => {
498
+ const axios = createAxiosMock();
499
+ instrumentAxios(axios);
500
+
501
+ moduleRuntime.startJourney({ ...def, slowRequestMs: 50 });
502
+ await journeyStep(def.name, 'book-job', () => {
503
+ const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
504
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_200);
505
+ axios.responseOkHandlers[0]({ status: 200, config });
506
+ });
507
+
508
+ expect(emitMock).toHaveBeenCalledTimes(1);
509
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
510
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-latency');
511
+ });
512
+
513
+ test('does not mark bad on 4xx via the rejection interceptor', async () => {
514
+ const axios = createAxiosMock();
515
+ instrumentAxios(axios);
516
+
517
+ moduleRuntime.startJourney(def);
518
+ await journeyStep(def.name, 'book-job', async () => {
519
+ const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
520
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
521
+ await expect(
522
+ axios.responseErrHandlers[0]({
523
+ config,
524
+ response: { status: 400 },
525
+ })
526
+ ).rejects.toMatchObject({ response: { status: 400 } });
527
+ });
528
+ completeJourney(def.name);
529
+
530
+ expect(emitMock).toHaveBeenCalledTimes(1);
531
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
532
+ });
533
+
534
+ test('marks request-aborted when axios reports a cancel', async () => {
535
+ getSharedPolicies().httpAbortedRequests = 'bad';
536
+ const axios = createAxiosMock();
537
+ instrumentAxios(axios);
538
+
539
+ moduleRuntime.startJourney(def);
540
+ await expect(
541
+ journeyStep(def.name, 'book-job', async () => {
542
+ const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
543
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
544
+ const cancelError: any = { config, name: 'CanceledError' };
545
+ cancelError.__CANCEL__ = true;
546
+ await axios.responseErrHandlers[0](cancelError);
547
+ })
548
+ ).rejects.toMatchObject({ name: 'CanceledError' });
549
+
550
+ expect(emitMock).toHaveBeenCalledTimes(1);
551
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
552
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-aborted');
553
+ });
554
+
555
+ test('network errors without a response status mark request-error', async () => {
556
+ const axios = createAxiosMock();
557
+ instrumentAxios(axios);
558
+
559
+ moduleRuntime.startJourney(def);
560
+ await expect(
561
+ journeyStep(def.name, 'book-job', async () => {
562
+ const config = axios.requestHandlers[0]({ url: '/CallScreen/BookJob' });
563
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
564
+ await axios.responseErrHandlers[0]({ config });
565
+ })
566
+ ).rejects.toBeDefined();
567
+
568
+ expect(emitMock).toHaveBeenCalledTimes(1);
569
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
570
+ });
571
+
572
+ test('ok interceptor tolerates a missing response config', () => {
573
+ const axios = createAxiosMock();
574
+ instrumentAxios(axios);
575
+
576
+ expect(() => axios.responseOkHandlers[0]({ status: 200 })).not.toThrow();
577
+ expect(emitMock).not.toHaveBeenCalled();
578
+ });
579
+
580
+ test('rejection interceptor tolerates a missing error config', async () => {
581
+ const axios = createAxiosMock();
582
+ instrumentAxios(axios);
583
+
584
+ await expect(axios.responseErrHandlers[0]({ response: { status: 500 } })).rejects.toEqual({
585
+ response: { status: 500 },
586
+ });
587
+ expect(emitMock).not.toHaveBeenCalled();
588
+ });
589
+
590
+ test('uses config.baseURL when building the request key', async () => {
591
+ const axios = createAxiosMock();
592
+ instrumentAxios(axios);
593
+
594
+ moduleRuntime.startJourney({
595
+ ...def,
596
+ endpoints: [{ match: 'api.example.com/invoices', ignore: true }],
597
+ });
598
+ await journeyStep(def.name, 'book-job', async () => {
599
+ const config = axios.requestHandlers[0]({
600
+ url: '/invoices',
601
+ baseURL: 'https://api.example.com',
602
+ });
603
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_020);
604
+ await expect(
605
+ axios.responseErrHandlers[0]({
606
+ config,
607
+ response: { status: 500 },
608
+ })
609
+ ).rejects.toBeDefined();
610
+ });
611
+ completeJourney(def.name);
612
+
613
+ expect(emitMock).toHaveBeenCalledTimes(1);
614
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
615
+ });
616
+ });
@@ -5,7 +5,7 @@ import {
5
5
  JourneyNameContext,
6
6
  useJourneyName,
7
7
  useOptionalJourneyName,
8
- } from '../integrations/react/context';
8
+ } from '../../integrations/react/context';
9
9
 
10
10
  function OptionalProbe() {
11
11
  const name = useOptionalJourneyName();
@@ -1,6 +1,6 @@
1
1
  /** @jest-environment jsdom */
2
- import { useJourneyTestRuntime } from '../__mocks__/test-runtime';
3
- import { exposeAppJourney, type WindowWithAppJourney } from '../integrations/jquery';
2
+ import { useJourneyTestRuntime } from '../../__test-utils__/test-runtime';
3
+ import { exposeAppJourney, type WindowWithAppJourney } from '../../integrations/jquery';
4
4
 
5
5
  const emitMock = jest.fn();
6
6
 
@@ -20,9 +20,15 @@ describe('[journey] exposeAppJourney', () => {
20
20
 
21
21
  expect(target.App).toBeDefined();
22
22
  expect(target.App.Journey).toBe(api);
23
+ expect(Object.isFrozen(target.App.Journey)).toBe(true);
24
+ expect(() => {
25
+ target.App.Journey.defineJourney = () => {
26
+ throw new Error('swapped');
27
+ };
28
+ }).toThrow();
23
29
  expect(typeof api.defineJourney).toBe('function');
24
30
  expect(typeof api.journeyStep).toBe('function');
25
- expect(typeof api.firstOpenJourney).toBe('function');
31
+ expect(typeof api.firstOpenJourneyName).toBe('function');
26
32
  });
27
33
 
28
34
  test('preserves existing window.App properties', () => {
@@ -104,10 +110,10 @@ describe('[journey] exposeAppJourney', () => {
104
110
  group: 'test',
105
111
  service: 'test',
106
112
  timeoutMs: 0,
107
- steps: ['load'],
113
+ expected: ['load'],
108
114
  });
109
115
  bookJob.start();
110
- expect(j.firstOpenJourney(['missing', 'ko-bridge-tags'])).toBe('ko-bridge-tags');
116
+ expect(j.firstOpenJourneyName(['missing', 'ko-bridge-tags'])).toBe('ko-bridge-tags');
111
117
 
112
118
  await bookJob.step('load', step => {
113
119
  step.setAttribute('phase', 'load');