@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,539 @@
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 { instrumentJquery } from '../../integrations/jquery/instrument';
7
+
8
+ const emitMock = jest.fn();
9
+
10
+ function createJqueryMock() {
11
+ const prefilters: ((o: any, orig: any, xhr: any) => void)[] = [];
12
+ const completes: ((event: unknown, xhr: any, settings: any) => void)[] = [];
13
+
14
+ const $ = ((_selector: Document) => ({
15
+ ajaxComplete(handler: (event: unknown, xhr: any, settings: any) => void) {
16
+ completes.push(handler);
17
+ },
18
+ })) as any;
19
+
20
+ $.ajaxPrefilter = (handler: (o: any, orig: any, xhr: any) => void) => {
21
+ prefilters.push(handler);
22
+ };
23
+ $.__prefilters = prefilters;
24
+ $.__completes = completes;
25
+ return $;
26
+ }
27
+
28
+ function simulateAjax($: any, url: string, status: number, durationMs = 10) {
29
+ const xhr = { status };
30
+ const settings = { url };
31
+ const start = globalThis.performance.now();
32
+
33
+ for (const prefilter of $.__prefilters) {
34
+ prefilter(settings, settings, xhr);
35
+ }
36
+
37
+ (globalThis.performance.now as jest.Mock).mockReturnValue(start + durationMs);
38
+
39
+ for (const complete of $.__completes) {
40
+ complete({}, xhr, settings);
41
+ }
42
+ }
43
+
44
+ describe('[journey] instrumentJquery', () => {
45
+ useJourneyTestRuntime(emitMock);
46
+
47
+ beforeEach(() => {
48
+ jest.spyOn(globalThis.performance, 'now').mockReturnValue(1_000);
49
+ });
50
+
51
+ afterEach(() => {
52
+ jest.restoreAllMocks();
53
+ });
54
+
55
+ test('is idempotent for the same $ instance', () => {
56
+ const $ = createJqueryMock();
57
+ instrumentJquery($);
58
+ instrumentJquery($);
59
+
60
+ expect($.__prefilters).toHaveLength(1);
61
+ expect($.__completes).toHaveLength(1);
62
+ });
63
+
64
+ test('restore then rewire does not stack prefilters', () => {
65
+ const $ = createJqueryMock();
66
+ const restore = instrumentJquery($);
67
+ expect($.__prefilters).toHaveLength(1);
68
+
69
+ restore();
70
+ instrumentJquery($);
71
+ expect($.__prefilters).toHaveLength(1);
72
+ expect($.__completes).toHaveLength(1);
73
+ });
74
+
75
+ test.each([undefined, null])('warns and soft-skips when $ is missing (%p)', bad => {
76
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
77
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
78
+
79
+ expect(() => instrumentJquery(bad)).not.toThrow();
80
+ expect(warn).toHaveBeenCalledWith(
81
+ expect.stringContaining('[journey] instrumentJquery: skipped — missing')
82
+ );
83
+ expect(error).not.toHaveBeenCalled();
84
+ });
85
+
86
+ test.each([{}, { ajaxPrefilter: 1 }, { ajaxPrefilter: undefined }])(
87
+ 'errors and soft-skips when $ has invalid shape (%p)',
88
+ bad => {
89
+ const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
90
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
91
+
92
+ expect(() => instrumentJquery(bad as never)).not.toThrow();
93
+ expect(error).toHaveBeenCalledWith(
94
+ expect.stringContaining('[journey] instrumentJquery: skipped — invalid shape (')
95
+ );
96
+ expect(error.mock.calls[0]).toHaveLength(1);
97
+ expect(warn).not.toHaveBeenCalled();
98
+ }
99
+ );
100
+
101
+ test('does not re-register handlers after a partial wire failure', () => {
102
+ const $ = createJqueryMock();
103
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
104
+ const throwing = Object.assign(function throwAjaxComplete() {
105
+ throw new Error('ajaxComplete boom');
106
+ }, $) as ReturnType<typeof createJqueryMock>;
107
+ throwing.ajaxPrefilter = $.ajaxPrefilter;
108
+ throwing.__prefilters = $.__prefilters;
109
+ throwing.__completes = $.__completes;
110
+
111
+ instrumentJquery(throwing);
112
+ expect(throwing.__prefilters).toHaveLength(1);
113
+
114
+ instrumentJquery(throwing);
115
+ expect(throwing.__prefilters).toHaveLength(1);
116
+ expect(error).toHaveBeenCalled();
117
+ });
118
+
119
+ test('restore after a partial wire failure allows a later retry', () => {
120
+ const $ = createJqueryMock();
121
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
122
+ let boom = true;
123
+ const flaky = Object.assign(function maybeThrow(selector: Document) {
124
+ if (boom) {
125
+ throw new Error('ajaxComplete boom');
126
+ }
127
+ return $(selector);
128
+ }, $) as ReturnType<typeof createJqueryMock>;
129
+ flaky.ajaxPrefilter = $.ajaxPrefilter;
130
+ flaky.__prefilters = $.__prefilters;
131
+ flaky.__completes = $.__completes;
132
+
133
+ const restore = instrumentJquery(flaky);
134
+ expect(flaky.__prefilters).toHaveLength(1);
135
+ expect(flaky.__completes).toHaveLength(0);
136
+ restore();
137
+
138
+ boom = false;
139
+ instrumentJquery(flaky);
140
+ expect(flaky.__prefilters).toHaveLength(1);
141
+ expect(flaky.__completes).toHaveLength(1);
142
+ expect(error).toHaveBeenCalled();
143
+ });
144
+
145
+ test('ignores ajax outside an active journey step', () => {
146
+ const $ = createJqueryMock();
147
+ instrumentJquery($);
148
+
149
+ moduleRuntime.startJourney({
150
+ name: 'jq-idle',
151
+ team: 'test',
152
+ group: 'test',
153
+ service: 'test',
154
+ timeoutMs: 0,
155
+ slowRequestMs: 50,
156
+ });
157
+
158
+ simulateAjax($, '/CallScreen/BookJob', 500);
159
+ completeJourney('jq-idle');
160
+
161
+ expect(emitMock).toHaveBeenCalledTimes(1);
162
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
163
+ });
164
+
165
+ test('ignore: true on ajax settings skips a 5xx', async () => {
166
+ const $ = createJqueryMock();
167
+ instrumentJquery($);
168
+
169
+ moduleRuntime.startJourney({
170
+ name: 'jq-meta-ignore',
171
+ team: 'test',
172
+ group: 'test',
173
+ service: 'test',
174
+ timeoutMs: 0,
175
+ slowRequestMs: 50,
176
+ });
177
+
178
+ await journeyStep('jq-meta-ignore', 'book-job', () => {
179
+ const xhr = { status: 500 };
180
+ const settings = { url: '/telemetry', ignore: true };
181
+ for (const prefilter of $.__prefilters) {
182
+ prefilter(settings, settings, xhr);
183
+ }
184
+ for (const complete of $.__completes) {
185
+ complete({}, xhr, settings);
186
+ }
187
+ });
188
+ completeJourney('jq-meta-ignore');
189
+
190
+ expect(emitMock).toHaveBeenCalledTimes(1);
191
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
192
+ });
193
+
194
+ test('step.stamp({ ignore: true }) skips a 5xx', async () => {
195
+ const $ = createJqueryMock();
196
+ instrumentJquery($);
197
+
198
+ moduleRuntime.startJourney({
199
+ name: 'jq-stamp-ignore',
200
+ team: 'test',
201
+ group: 'test',
202
+ service: 'test',
203
+ timeoutMs: 0,
204
+ slowRequestMs: 50,
205
+ });
206
+
207
+ await journeyStep('jq-stamp-ignore', 'book-job', step => {
208
+ const tagged = step.stamp({ ignore: true });
209
+ const xhr = { status: 500, stamp: tagged };
210
+ const settings = { url: '/CallScreen/BookJob', stamp: tagged };
211
+ for (const prefilter of $.__prefilters) {
212
+ prefilter(settings, settings, xhr);
213
+ }
214
+ for (const complete of $.__completes) {
215
+ complete({}, xhr, settings);
216
+ }
217
+ });
218
+ completeJourney('jq-stamp-ignore');
219
+
220
+ expect(emitMock).toHaveBeenCalledTimes(1);
221
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
222
+ });
223
+
224
+ test('stamped stamp is JSON-safe on jqXHR and ajax settings', async () => {
225
+ const $ = createJqueryMock();
226
+ instrumentJquery($);
227
+
228
+ moduleRuntime.startJourney({
229
+ name: 'jq-json-safe',
230
+ team: 'test',
231
+ group: 'test',
232
+ service: 'test',
233
+ timeoutMs: 0,
234
+ slowRequestMs: 5_000,
235
+ });
236
+
237
+ await journeyStep('jq-json-safe', 'book-job', step => {
238
+ // Ambient stamp lands on jqXHR — common to serialize for logging.
239
+ const ambientXhr: {
240
+ status: number;
241
+ stamp?: unknown;
242
+ journeyStart?: number;
243
+ } = { status: 200 };
244
+ const ambientSettings = { url: '/CallScreen/BookJob' };
245
+ for (const prefilter of $.__prefilters) {
246
+ prefilter(ambientSettings, ambientSettings, ambientXhr);
247
+ }
248
+ expect(ambientXhr.stamp).toBeUndefined();
249
+ expect(JSON.parse(JSON.stringify(ambientXhr)).stamp).toBeUndefined();
250
+
251
+ // Explicit step.stamp() spread into ajax settings — stripped after capture.
252
+ const tagged = step.stamp();
253
+ const settings = { url: '/CallScreen/BookJob', stamp: tagged };
254
+ for (const prefilter of $.__prefilters) {
255
+ prefilter(settings, settings, { status: 200 });
256
+ }
257
+ expect(settings.stamp).toBeUndefined();
258
+ });
259
+ completeJourney('jq-json-safe');
260
+ });
261
+
262
+ test('marks journey bad on 5xx inside a journey step', async () => {
263
+ const $ = createJqueryMock();
264
+ instrumentJquery($);
265
+
266
+ moduleRuntime.startJourney({
267
+ name: 'jq-5xx',
268
+ team: 'test',
269
+ group: 'test',
270
+ service: 'test',
271
+ timeoutMs: 0,
272
+ slowRequestMs: 5_000,
273
+ });
274
+
275
+ await journeyStep('jq-5xx', 'book-job', () => {
276
+ simulateAjax($, '/CallScreen/BookJob', 500, 20);
277
+ });
278
+
279
+ expect(emitMock).toHaveBeenCalledTimes(1);
280
+ const event = emitMock.mock.calls[0][0].journey;
281
+ expect(event.outcome).toBe('bad');
282
+ expect(event.reason).toBe('request-error');
283
+ });
284
+
285
+ test('marks journey bad on slow request inside a journey step', async () => {
286
+ const $ = createJqueryMock();
287
+ instrumentJquery($);
288
+
289
+ moduleRuntime.startJourney({
290
+ name: 'jq-slow',
291
+ team: 'test',
292
+ group: 'test',
293
+ service: 'test',
294
+ timeoutMs: 0,
295
+ slowRequestMs: 50,
296
+ });
297
+
298
+ await journeyStep('jq-slow', 'book-job', () => {
299
+ simulateAjax($, '/CallScreen/BookJob', 200, 200);
300
+ });
301
+
302
+ expect(emitMock).toHaveBeenCalledTimes(1);
303
+ const event = emitMock.mock.calls[0][0].journey;
304
+ expect(event.outcome).toBe('bad');
305
+ expect(event.reason).toBe('request-latency');
306
+ });
307
+
308
+ test('treats status 0 as a network/request error', async () => {
309
+ const $ = createJqueryMock();
310
+ instrumentJquery($);
311
+
312
+ moduleRuntime.startJourney({
313
+ name: 'jq-network',
314
+ team: 'test',
315
+ group: 'test',
316
+ service: 'test',
317
+ timeoutMs: 0,
318
+ slowRequestMs: 5_000,
319
+ });
320
+
321
+ await journeyStep('jq-network', 'book-job', () => {
322
+ simulateAjax($, '/CallScreen/BookJob', 0, 10);
323
+ });
324
+
325
+ expect(emitMock).toHaveBeenCalledTimes(1);
326
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
327
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
328
+ });
329
+
330
+ test('does not mark bad on 4xx (user/validation)', async () => {
331
+ const $ = createJqueryMock();
332
+ instrumentJquery($);
333
+
334
+ moduleRuntime.startJourney({
335
+ name: 'jq-4xx',
336
+ team: 'test',
337
+ group: 'test',
338
+ service: 'test',
339
+ timeoutMs: 0,
340
+ slowRequestMs: 5_000,
341
+ });
342
+
343
+ await journeyStep('jq-4xx', 'book-job', () => {
344
+ simulateAjax($, '/CallScreen/BookJob', 400, 10);
345
+ });
346
+ completeJourney('jq-4xx');
347
+
348
+ expect(emitMock).toHaveBeenCalledTimes(1);
349
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
350
+ });
351
+
352
+ test('marks request-aborted when jqXHR statusText is abort', async () => {
353
+ getSharedPolicies().httpAbortedRequests = 'bad';
354
+ const $ = createJqueryMock();
355
+ instrumentJquery($);
356
+
357
+ moduleRuntime.startJourney({
358
+ name: 'jq-abort',
359
+ team: 'test',
360
+ group: 'test',
361
+ service: 'test',
362
+ timeoutMs: 0,
363
+ slowRequestMs: 5_000,
364
+ });
365
+
366
+ await journeyStep('jq-abort', 'book-job', () => {
367
+ const xhr = { status: 0, statusText: 'abort' };
368
+ const settings = { url: '/CallScreen/BookJob' };
369
+ const start = globalThis.performance.now();
370
+ for (const prefilter of $.__prefilters) {
371
+ prefilter(settings, settings, xhr);
372
+ }
373
+ (globalThis.performance.now as jest.Mock).mockReturnValue(start + 10);
374
+ for (const complete of $.__completes) {
375
+ complete({}, xhr, settings);
376
+ }
377
+ });
378
+
379
+ expect(emitMock).toHaveBeenCalledTimes(1);
380
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
381
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-aborted');
382
+ });
383
+
384
+ test('honors an explicit stamp already stamped on the XHR', async () => {
385
+ const $ = createJqueryMock();
386
+ instrumentJquery($);
387
+
388
+ moduleRuntime.startJourney({
389
+ name: 'jq-explicit',
390
+ team: 'test',
391
+ group: 'test',
392
+ service: 'test',
393
+ timeoutMs: 0,
394
+ slowRequestMs: 5_000,
395
+ });
396
+
397
+ await journeyStep('jq-explicit', 'book-job', step => {
398
+ const tagged = step.stamp();
399
+ const xhr: {
400
+ status: number;
401
+ stamp: unknown;
402
+ journeyStart?: number;
403
+ } = { status: 500, stamp: tagged };
404
+ const settings = { url: '/CallScreen/BookJob' };
405
+ const start = globalThis.performance.now();
406
+ for (const prefilter of $.__prefilters) {
407
+ prefilter(settings, settings, xhr);
408
+ }
409
+ // Prefilter must not clear the explicit step; it should stamp a start time.
410
+ expect(xhr.stamp).toBeUndefined();
411
+ expect(xhr.journeyStart).toBeDefined();
412
+ (globalThis.performance.now as jest.Mock).mockReturnValue(start + 20);
413
+ for (const complete of $.__completes) {
414
+ complete({}, xhr, settings);
415
+ }
416
+ });
417
+
418
+ expect(emitMock).toHaveBeenCalledTimes(1);
419
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
420
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
421
+ });
422
+
423
+ test('falls back to performance.now when journeyStart is missing', async () => {
424
+ const $ = createJqueryMock();
425
+ instrumentJquery($);
426
+
427
+ moduleRuntime.startJourney({
428
+ name: 'jq-no-start',
429
+ team: 'test',
430
+ group: 'test',
431
+ service: 'test',
432
+ timeoutMs: 0,
433
+ slowRequestMs: 5_000,
434
+ });
435
+
436
+ await journeyStep('jq-no-start', 'book-job', step => {
437
+ const tagged = step.stamp();
438
+ // Bypass prefilter stamping — only fire ajaxComplete with a step and no start.
439
+ const xhr = { status: 200, stamp: tagged };
440
+ for (const complete of $.__completes) {
441
+ complete({}, xhr, { url: '/ok' });
442
+ }
443
+ });
444
+ completeJourney('jq-no-start');
445
+
446
+ expect(emitMock).toHaveBeenCalledTimes(1);
447
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
448
+ });
449
+
450
+ test('honors step.stamp() on ajax settings without deep-merge stack overflow', async () => {
451
+ const $ = createJqueryMock();
452
+ instrumentJquery($);
453
+
454
+ // Mimic jQuery.ajaxSetup: deep-merge plain objects only (class instances by ref).
455
+ function isPlainObject(obj: unknown): obj is Record<string, unknown> {
456
+ return (
457
+ !!obj && typeof obj === 'object' && Object.getPrototypeOf(obj) === Object.prototype
458
+ );
459
+ }
460
+ function deepExtend(target: Record<string, unknown>, ...sources: unknown[]) {
461
+ for (const source of sources) {
462
+ if (!source || typeof source !== 'object') {
463
+ continue;
464
+ }
465
+ for (const key of Object.keys(source as object)) {
466
+ // Block prototype-polluting keys (CodeQL js/prototype-pollution-utility).
467
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
468
+ continue;
469
+ }
470
+ const copy = (source as Record<string, unknown>)[key];
471
+ if (isPlainObject(copy)) {
472
+ const dest = isPlainObject(target[key])
473
+ ? (target[key] as Record<string, unknown>)
474
+ : {};
475
+ target[key] = deepExtend(dest, copy);
476
+ } else {
477
+ target[key] = copy;
478
+ }
479
+ }
480
+ }
481
+ return target;
482
+ }
483
+
484
+ moduleRuntime.startJourney({
485
+ name: 'jq-settings-tag',
486
+ team: 'test',
487
+ group: 'test',
488
+ service: 'test',
489
+ timeoutMs: 0,
490
+ slowRequestMs: 5_000,
491
+ });
492
+
493
+ await journeyStep('jq-settings-tag', 'book-job', step => {
494
+ const tagged = step.stamp();
495
+ expect(() => deepExtend({}, { stamp: tagged })).not.toThrow();
496
+ const settings = deepExtend({ url: '/CallScreen/BookJob' }, { stamp: tagged });
497
+ const xhr: { status: number; stamp?: unknown; journeyStart?: number } = {
498
+ status: 500,
499
+ };
500
+ const start = globalThis.performance.now();
501
+ for (const prefilter of $.__prefilters) {
502
+ prefilter(settings, settings, xhr);
503
+ }
504
+ expect(xhr.stamp).toBeUndefined();
505
+ (globalThis.performance.now as jest.Mock).mockReturnValue(start + 20);
506
+ for (const complete of $.__completes) {
507
+ complete({}, xhr, settings);
508
+ }
509
+ });
510
+
511
+ expect(emitMock).toHaveBeenCalledTimes(1);
512
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad');
513
+ expect(emitMock.mock.calls[0][0].journey.reason).toBe('request-error');
514
+ });
515
+
516
+ test('keys cross-origin ajax urls as host/path', async () => {
517
+ const $ = createJqueryMock();
518
+ instrumentJquery($);
519
+
520
+ moduleRuntime.startJourney({
521
+ name: 'jq-cross',
522
+ team: 'test',
523
+ group: 'test',
524
+ service: 'test',
525
+ timeoutMs: 0,
526
+ slowRequestMs: 5_000,
527
+ endpoints: [{ match: 'pdf.vendor.com/render', ignore: true }],
528
+ });
529
+
530
+ await journeyStep('jq-cross', 'book-job', () => {
531
+ simulateAjax($, 'https://pdf.vendor.com/render', 500, 20);
532
+ });
533
+ completeJourney('jq-cross');
534
+
535
+ // Ignored endpoint — 5xx must not close the journey as bad.
536
+ expect(emitMock).toHaveBeenCalledTimes(1);
537
+ expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
538
+ });
539
+ });
@@ -1,5 +1,5 @@
1
1
  /** @jest-environment jsdom */
2
- import { requestKey } from '../integrations/request-key';
2
+ import { requestKey } from '../../integrations/request-key';
3
3
 
4
4
  describe('[journey] requestKey', () => {
5
5
  // Do not redefine window.location — jsdom's property is non-configurable.