@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,548 @@
1
+ /** @jest-environment jsdom */
2
+ /**
3
+ * Host + multi-MFE coexistence.
4
+ *
5
+ * On a real page, duplicated `@servicetitan/journey` copies each get their own
6
+ * `moduleRuntime`, while sharing `globalThis.__stJourney` (policies, enable,
7
+ * instrument WeakSets) and the host's `DD_RUM`. Tests simulate that with
8
+ * `createJourneyRuntime` engines registered beside the host `moduleRuntime`.
9
+ */
10
+ import { lastJourney } from '../../__test-utils__/test-runtime';
11
+ import { configureJourney } from '../../config';
12
+ import type { JourneyStepStamp } from '../../core';
13
+ import { createJourneyRuntime, moduleRuntime, type JourneyRuntime } from '../../core/runtime';
14
+ import { JOURNEY_ENGINE, type JourneyDef } from '../../core/types';
15
+ import {
16
+ ST_JOURNEY_GLOBAL_KEY,
17
+ enableJourneyDebugging,
18
+ getSharedPolicies,
19
+ registerJourneyRuntime,
20
+ resetSharedPolicies,
21
+ setJourneyDefaultEnabled,
22
+ teardownJourney,
23
+ type StJourneyGlobal,
24
+ } from '../../global';
25
+ import { instrumentAxios, type AxiosLikeInstance } from '../../integrations/axios';
26
+ import { onHttpComplete } from '../../integrations/http-report';
27
+ import { exposeAppJourney, type WindowWithAppJourney } from '../../integrations/jquery';
28
+ import { sendToConsole } from '../../sinks/console';
29
+ import { sendToDatadog } from '../../sinks/datadog';
30
+
31
+ const DD_RUM_KEY = 'DD_RUM';
32
+
33
+ function journeyDef(name: string, overrides: Partial<JourneyDef> = {}): JourneyDef {
34
+ return {
35
+ name,
36
+ team: 'platform',
37
+ group: 'test',
38
+ service: 'journey',
39
+ timeoutMs: 0,
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ type AxiosMock = AxiosLikeInstance & {
45
+ requestHandlers: ((config: any) => any)[];
46
+ responseOkHandlers: ((response: any) => any)[];
47
+ };
48
+
49
+ function createAxiosMock(): AxiosMock {
50
+ const requestHandlers: AxiosMock['requestHandlers'] = [];
51
+ const responseOkHandlers: AxiosMock['responseOkHandlers'] = [];
52
+
53
+ return {
54
+ defaults: { baseURL: 'https://app.example.com' },
55
+ interceptors: {
56
+ request: {
57
+ use(onFulfilled) {
58
+ requestHandlers.push(onFulfilled);
59
+ return 0;
60
+ },
61
+ },
62
+ response: {
63
+ use(onFulfilled) {
64
+ responseOkHandlers.push(onFulfilled);
65
+ return 0;
66
+ },
67
+ },
68
+ },
69
+ requestHandlers,
70
+ responseOkHandlers,
71
+ };
72
+ }
73
+
74
+ function getStJourney(): StJourneyGlobal {
75
+ return (globalThis as Record<string, unknown>)[ST_JOURNEY_GLOBAL_KEY] as StJourneyGlobal;
76
+ }
77
+
78
+ describe('[journey] host + multi-MFE', () => {
79
+ const hostEmit = jest.fn();
80
+ const mfeAEmit = jest.fn();
81
+ const mfeBEmit = jest.fn();
82
+ const ddRumAddAction = jest.fn();
83
+
84
+ let host: JourneyRuntime;
85
+ let mfeA: JourneyRuntime;
86
+ let mfeB: JourneyRuntime;
87
+ let warnSpy: jest.SpyInstance;
88
+
89
+ beforeEach(() => {
90
+ localStorage.clear();
91
+ setJourneyDefaultEnabled(true);
92
+ resetSharedPolicies();
93
+ teardownJourney();
94
+
95
+ hostEmit.mockClear();
96
+ mfeAEmit.mockClear();
97
+ mfeBEmit.mockClear();
98
+ ddRumAddAction.mockClear();
99
+
100
+ delete (globalThis as Record<string, unknown>)[DD_RUM_KEY];
101
+ delete getStJourney().rum;
102
+
103
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
104
+
105
+ // Host package copy = moduleRuntime (free helpers / KO bridge / configureJourney).
106
+ host = moduleRuntime;
107
+ host.reset({
108
+ sinks: [hostEmit],
109
+ slowRequests: { defaultMs: 4_000, endpoints: [] },
110
+ });
111
+
112
+ // Duplicate package copies for two MFEs loaded by the host.
113
+ mfeA = createJourneyRuntime({ sinks: [mfeAEmit] });
114
+ mfeB = createJourneyRuntime({ sinks: [mfeBEmit] });
115
+ registerJourneyRuntime(mfeA, { teardown: () => mfeA.reset({ sinks: [mfeAEmit] }) });
116
+ registerJourneyRuntime(mfeB, { teardown: () => mfeB.reset({ sinks: [mfeBEmit] }) });
117
+
118
+ (globalThis as Record<string, unknown>)[DD_RUM_KEY] = { addAction: ddRumAddAction };
119
+ });
120
+
121
+ afterEach(() => {
122
+ warnSpy.mockRestore();
123
+ localStorage.clear();
124
+ setJourneyDefaultEnabled(false);
125
+ resetSharedPolicies();
126
+ teardownJourney();
127
+ delete (globalThis as Record<string, unknown>)[DD_RUM_KEY];
128
+ delete getStJourney().rum;
129
+ moduleRuntime.reset({
130
+ sinks: [sendToDatadog, sendToConsole],
131
+ slowRequests: { defaultMs: 4_000, endpoints: [] },
132
+ });
133
+ });
134
+
135
+ describe('host enable gate', () => {
136
+ test('skips MFE configureJourney until the host enables journeys', () => {
137
+ setJourneyDefaultEnabled(false);
138
+ const hostAxios = createAxiosMock();
139
+ const mfeAxios = createAxiosMock();
140
+
141
+ configureJourney({ axios: hostAxios });
142
+ expect(warnSpy).toHaveBeenCalledWith(
143
+ expect.stringContaining('[journey] configureJourney: skipped — disabled')
144
+ );
145
+ expect(hostAxios.requestHandlers).toHaveLength(0);
146
+
147
+ setJourneyDefaultEnabled(true);
148
+ configureJourney({
149
+ slowRequests: { defaultMs: 2_500, endpoints: [] },
150
+ axios: mfeAxios,
151
+ });
152
+
153
+ expect(mfeAxios.requestHandlers).toHaveLength(1);
154
+ expect(getSharedPolicies().slowRequests?.defaultMs).toBe(2_500);
155
+ });
156
+ });
157
+
158
+ describe('shared host policies', () => {
159
+ test('MFEs inherit host policies for ambient and slow-request thresholds', async () => {
160
+ configureJourney({
161
+ slowRequests: {
162
+ defaultMs: 50,
163
+ endpoints: [{ match: '/api/slow', slowRequestMs: 8_000 }],
164
+ },
165
+ autoAttributeRequests: false,
166
+ httpAbortedRequests: 'continue',
167
+ httpClientErrorRequests: 'bad',
168
+ });
169
+
170
+ const shared = getSharedPolicies();
171
+ expect(shared.slowRequests?.defaultMs).toBe(50);
172
+ expect(shared.autoAttributeRequests).toBe(false);
173
+ expect(shared.httpAbortedRequests).toBe('continue');
174
+ expect(shared.httpClientErrorRequests).toBe('bad');
175
+ // Host module engine mirrors configureJourney timeouts locally too.
176
+ expect(host.getSlowRequests().defaultMs).toBe(50);
177
+
178
+ mfeA.startJourney(journeyDef('mfe-a-checkout'));
179
+ await mfeA.journeyStep('mfe-a-checkout', 'pay', step => {
180
+ // Shared autoAttributeRequests:false — ambient is null even with one step.
181
+ expect(mfeA.activeJourneyStep()).toBeNull();
182
+
183
+ const stamp = step.stamp() as JourneyStepStamp;
184
+ // Shared defaultMs=50 applies via effectiveAppTimeouts on every runtime.
185
+ mfeA.reportBackendRequest(stamp.step, 200, 120, '/api/pay');
186
+ });
187
+
188
+ expect(lastJourney(mfeAEmit).outcome).toBe('bad');
189
+ expect(lastJourney(mfeAEmit).reason).toBe('request-latency');
190
+ expect(Object.isFrozen(shared)).toBe(true);
191
+
192
+ mfeA.setPolicyOverlay({
193
+ slowRequests: { defaultMs: 9_999, endpoints: [] },
194
+ });
195
+ expect(getSharedPolicies().slowRequests?.defaultMs).toBe(50);
196
+ expect(mfeA.getSlowRequests().defaultMs).toBe(9_999);
197
+ expect(host.getSlowRequests().defaultMs).toBe(50);
198
+ });
199
+
200
+ test('MFE overlay does not rewrite frozen host policies', async () => {
201
+ configureJourney({ autoAttributeRequests: true });
202
+ expect(getSharedPolicies().autoAttributeRequests).toBe(true);
203
+ expect(Object.isFrozen(getSharedPolicies())).toBe(true);
204
+ expect(() => {
205
+ getSharedPolicies().autoAttributeRequests = false;
206
+ }).toThrow();
207
+
208
+ mfeA.setPolicyOverlay({ autoAttributeRequests: false });
209
+
210
+ host.startJourney(journeyDef('host-ambient'));
211
+ await host.journeyStep('host-ambient', 'load', () => {
212
+ expect(host.activeJourneyStep()).not.toBeNull();
213
+ });
214
+ host.completeJourney('host-ambient');
215
+
216
+ mfeA.startJourney(journeyDef('mfe-overlay'));
217
+ await mfeA.journeyStep('mfe-overlay', 'load', () => {
218
+ expect(mfeA.activeJourneyStep()).toBeNull();
219
+ });
220
+ mfeA.completeJourney('mfe-overlay');
221
+
222
+ expect(getSharedPolicies().autoAttributeRequests).toBe(true);
223
+ expect(host.getAutoAttributeRequests()).toBe(true);
224
+ expect(mfeA.getAutoAttributeRequests()).toBe(false);
225
+ expect(host.getDebugSnapshot().policyOverlay.autoAttributeRequests).toBe(true);
226
+ expect(mfeA.getDebugSnapshot().policyOverlay.autoAttributeRequests).toBe(true);
227
+ });
228
+ });
229
+
230
+ describe('isolated open journeys', () => {
231
+ beforeEach(() => {
232
+ host.startJourney(journeyDef('host-shell'));
233
+ mfeA.startJourney(journeyDef('checkout'));
234
+ mfeB.startJourney(journeyDef('book-job'));
235
+ });
236
+
237
+ test('each runtime only sees its own open journeys', () => {
238
+ expect(host.getOpenJourney('host-shell')).toBeDefined();
239
+ expect(host.getOpenJourney('checkout')).toBeUndefined();
240
+ expect(host.getOpenJourney('book-job')).toBeUndefined();
241
+
242
+ expect(mfeA.getOpenJourney('checkout')).toBeDefined();
243
+ expect(mfeA.getOpenJourney('book-job')).toBeUndefined();
244
+ expect(mfeA.getOpenJourney('host-shell')).toBeUndefined();
245
+
246
+ expect(mfeB.getOpenJourney('book-job')).toBeDefined();
247
+ expect(mfeB.getOpenJourney('checkout')).toBeUndefined();
248
+ });
249
+
250
+ test('same journey name can be open independently on two MFEs', () => {
251
+ mfeA.startJourney(journeyDef('shared-name', { service: 'mfe-a' }));
252
+ mfeB.startJourney(journeyDef('shared-name', { service: 'mfe-b' }));
253
+
254
+ expect(mfeA.getOpenJourney('shared-name')?.service).toBe('mfe-a');
255
+ expect(mfeB.getOpenJourney('shared-name')?.service).toBe('mfe-b');
256
+
257
+ mfeA.completeJourney('shared-name');
258
+ mfeB.failJourney(mfeB.getOpenJourney('shared-name')!, 'booking-rejected');
259
+
260
+ expect(lastJourney(mfeAEmit).outcome).toBe('good');
261
+ expect(lastJourney(mfeAEmit).service).toBe('mfe-a');
262
+ expect(lastJourney(mfeBEmit).outcome).toBe('bad');
263
+ expect(lastJourney(mfeBEmit).service).toBe('mfe-b');
264
+ expect(hostEmit).not.toHaveBeenCalled();
265
+ });
266
+ });
267
+
268
+ describe('isolated ambient attribution', () => {
269
+ test('in-flight step on MFE A does not become ambient on MFE B or the host', async () => {
270
+ mfeA.startJourney(journeyDef('checkout'));
271
+ mfeB.startJourney(journeyDef('book-job'));
272
+ host.startJourney(journeyDef('host-shell'));
273
+
274
+ let release!: () => void;
275
+ const gate = new Promise<void>(resolve => {
276
+ release = resolve;
277
+ });
278
+ const stepA = mfeA.journeyStep('checkout', 'pay', async () => {
279
+ await gate;
280
+ });
281
+
282
+ expect(mfeA.activeJourneyStep()?.name).toBe('pay');
283
+ expect(mfeB.activeJourneyStep()).toBeNull();
284
+ expect(host.activeJourneyStep()).toBeNull();
285
+
286
+ release();
287
+ await stepA;
288
+ });
289
+
290
+ test('overlap nulls ambient only inside that MFE runtime', async () => {
291
+ mfeA.startJourney(journeyDef('checkout'));
292
+ mfeA.startJourney(journeyDef('upsell'));
293
+ mfeB.startJourney(journeyDef('book-job'));
294
+
295
+ let releasePay!: () => void;
296
+ let releaseUpsell!: () => void;
297
+ const payGate = new Promise<void>(r => {
298
+ releasePay = r;
299
+ });
300
+ const upsellGate = new Promise<void>(r => {
301
+ releaseUpsell = r;
302
+ });
303
+
304
+ const pay = mfeA.journeyStep('checkout', 'pay', async () => {
305
+ await payGate;
306
+ });
307
+ const upsell = mfeA.journeyStep('upsell', 'offer', async () => {
308
+ await upsellGate;
309
+ });
310
+ const book = mfeB.journeyStep('book-job', 'search', () => {
311
+ expect(mfeA.activeJourneyStep()).toBeNull();
312
+ expect(mfeB.activeJourneyStep()?.name).toBe('search');
313
+ });
314
+
315
+ await book;
316
+ releasePay();
317
+ releaseUpsell();
318
+ await Promise.all([pay, upsell]);
319
+ });
320
+
321
+ test('explicit step.stamp on one MFE is unaffected by a concurrent step on another', async () => {
322
+ mfeA.startJourney(journeyDef('checkout'));
323
+ mfeB.startJourney(journeyDef('book-job'));
324
+
325
+ let releaseB!: () => void;
326
+ const gateB = new Promise<void>(r => {
327
+ releaseB = r;
328
+ });
329
+ const stepB = mfeB.journeyStep('book-job', 'search', async () => {
330
+ await gateB;
331
+ });
332
+
333
+ await mfeA.journeyStep('checkout', 'pay', step => {
334
+ const tagged = step.stamp();
335
+ expect(tagged).toBeDefined();
336
+ // Sibling MFE ambient is independent — still one in-flight on B, but A tags itself.
337
+ expect(mfeB.activeJourneyStep()?.name).toBe('search');
338
+ expect(mfeA.activeJourneyStep()?.name).toBe('pay');
339
+ });
340
+
341
+ releaseB();
342
+ await stepB;
343
+ });
344
+ });
345
+
346
+ describe('shared host RUM session', () => {
347
+ test('host and both MFEs emit user_journey into the same DD_RUM', () => {
348
+ host.setSinks([sendToDatadog]);
349
+ mfeA.setSinks([sendToDatadog]);
350
+ mfeB.setSinks([sendToDatadog]);
351
+
352
+ host.startJourney(journeyDef('host-shell'));
353
+ mfeA.startJourney(journeyDef('checkout'));
354
+ mfeB.startJourney(journeyDef('book-job'));
355
+
356
+ host.completeJourney('host-shell');
357
+ mfeA.completeJourney('checkout');
358
+ mfeB.failJourney(mfeB.getOpenJourney('book-job')!, 'timeout');
359
+
360
+ expect(ddRumAddAction).toHaveBeenCalledTimes(3);
361
+ const names = ddRumAddAction.mock.calls.map(
362
+ ([action, payload]: [string, { journey: { name: string; outcome: string } }]) => {
363
+ expect(action).toBe('user_journey');
364
+ return `${payload.journey.name}:${payload.journey.outcome}`;
365
+ }
366
+ );
367
+ expect(names).toEqual(['host-shell:good', 'checkout:good', 'book-job:bad']);
368
+ });
369
+ });
370
+
371
+ describe('per-bundle axios wiring', () => {
372
+ test('host and each MFE can instrument distinct axios instances once', () => {
373
+ const hostAxios = createAxiosMock();
374
+ const mfeAAxios = createAxiosMock();
375
+ const mfeBAxios = createAxiosMock();
376
+
377
+ configureJourney({ axios: hostAxios, fetch: true });
378
+ instrumentAxios(mfeAAxios);
379
+ instrumentAxios(mfeBAxios);
380
+
381
+ // Second wire of the same instance is a no-op (shared WeakSet).
382
+ instrumentAxios(hostAxios);
383
+ instrumentAxios(mfeAAxios);
384
+
385
+ expect(hostAxios.requestHandlers).toHaveLength(1);
386
+ expect(mfeAAxios.requestHandlers).toHaveLength(1);
387
+ expect(mfeBAxios.requestHandlers).toHaveLength(1);
388
+ });
389
+ });
390
+
391
+ describe('host KO bridge + MFE engines', () => {
392
+ test('exposeAppJourney only reflects host-runtime open journeys', () => {
393
+ const target = { App: {} } as WindowWithAppJourney;
394
+ const api = exposeAppJourney(target);
395
+
396
+ host.startJourney(journeyDef('host-ko'));
397
+ mfeA.startJourney(journeyDef('mfe-checkout'));
398
+
399
+ expect(api.getJourneyHandle('host-ko')).not.toBeNull();
400
+ expect(api.getJourneyHandle('mfe-checkout')).toBeNull();
401
+ expect(api.firstOpenJourneyName(['mfe-checkout', 'host-ko'])).toBe('host-ko');
402
+ expect(target.App?.Journey).toBe(api);
403
+ });
404
+ });
405
+
406
+ describe('DevTools multi-runtime listing', () => {
407
+ test('lists host and registered MFE engines when debugging is on', () => {
408
+ enableJourneyDebugging();
409
+
410
+ host.startJourney(journeyDef('host-shell'));
411
+ mfeA.startJourney(journeyDef('checkout'));
412
+ mfeB.startJourney(journeyDef('book-job'));
413
+
414
+ const state = getStJourney().getDebugState();
415
+ expect(state.debug).toBe(true);
416
+ expect(state.hasRum).toBe(true);
417
+ expect(state.runtimes.length).toBeGreaterThanOrEqual(3);
418
+
419
+ const openNames = state.runtimes.flatMap(r => r.openJourneys.map(j => j.name));
420
+ expect(openNames).toEqual(
421
+ expect.arrayContaining(['host-shell', 'checkout', 'book-job'])
422
+ );
423
+ });
424
+
425
+ test('teardownJourney drops runtimes that registered a teardown', () => {
426
+ enableJourneyDebugging();
427
+ expect(getStJourney().getDebugState().runtimes.length).toBeGreaterThanOrEqual(3);
428
+
429
+ teardownJourney();
430
+
431
+ const remaining = getStJourney().getDebugState().runtimes;
432
+ expect(remaining.some(r => r.id === host.id)).toBe(true);
433
+ expect(remaining.some(r => r.id === mfeA.id)).toBe(false);
434
+ expect(remaining.some(r => r.id === mfeB.id)).toBe(false);
435
+ });
436
+ });
437
+
438
+ describe('HTTP breach isolation', () => {
439
+ test('a slow request on MFE A does not fail MFE B', async () => {
440
+ configureJourney({
441
+ slowRequests: { defaultMs: 50, endpoints: [] },
442
+ });
443
+
444
+ mfeA.startJourney(journeyDef('checkout'));
445
+ mfeB.startJourney(journeyDef('book-job'));
446
+
447
+ await mfeA.journeyStep('checkout', 'pay', step => {
448
+ const stamp = step.stamp() as JourneyStepStamp;
449
+ // Owning package copy reports against its own engine.
450
+ mfeA.reportBackendRequest(stamp.step, 200, 200, '/api/pay');
451
+ });
452
+
453
+ expect(lastJourney(mfeAEmit).outcome).toBe('bad');
454
+ expect(lastJourney(mfeAEmit).reason).toBe('request-latency');
455
+ expect(mfeB.getOpenJourney('book-job')).toBeDefined();
456
+ expect(mfeBEmit).not.toHaveBeenCalled();
457
+ });
458
+ });
459
+
460
+ describe('when a host transport reports an MFE step.stamp()', () => {
461
+ test('abort fails the MFE journey and leaves a same-named host journey open', async () => {
462
+ configureJourney({ httpAbortedRequests: 'bad' });
463
+ host.startJourney(journeyDef('checkout', { service: 'host' }));
464
+ mfeA.startJourney(journeyDef('checkout', { service: 'mfe-a' }));
465
+
466
+ await mfeA.journeyStep('checkout', 'pay', step => {
467
+ const stamp = step.stamp() as JourneyStepStamp;
468
+ onHttpComplete({
469
+ stamp,
470
+ requestKey: '/api/pay',
471
+ durationMs: 10,
472
+ aborted: true,
473
+ });
474
+ });
475
+
476
+ expect(lastJourney(mfeAEmit).outcome).toBe('bad');
477
+ expect(lastJourney(mfeAEmit).reason).toBe('request-aborted');
478
+ expect(lastJourney(mfeAEmit).service).toBe('mfe-a');
479
+ expect(host.getOpenJourney('checkout')?.service).toBe('host');
480
+ expect(hostEmit).not.toHaveBeenCalled();
481
+ });
482
+
483
+ test('unstamped foreign abort does not poison onHttpAborted on the MFE step', async () => {
484
+ configureJourney({ httpAbortedRequests: 'bad' });
485
+ host.startJourney(journeyDef('checkout', { service: 'host' }));
486
+ mfeA.startJourney(journeyDef('checkout', { service: 'mfe-a' }));
487
+ const aborted = Object.assign(new Error('aborted'), { name: 'AbortError' });
488
+
489
+ await expect(
490
+ mfeA.journeyStep('checkout', 'pay', step => {
491
+ const stamp = step.stamp() as JourneyStepStamp;
492
+ const ref = stamp.step;
493
+ delete ref.journey[JOURNEY_ENGINE];
494
+ onHttpComplete({
495
+ stamp,
496
+ requestKey: '/api/pay',
497
+ durationMs: 10,
498
+ aborted: true,
499
+ });
500
+ expect(ref.onHttpAborted).toBeUndefined();
501
+ throw aborted;
502
+ })
503
+ ).rejects.toBe(aborted);
504
+
505
+ mfeA.completeJourney('checkout');
506
+ expect(lastJourney(mfeAEmit).outcome).toBe('good');
507
+ expect(host.getOpenJourney('checkout')?.service).toBe('host');
508
+ expect(hostEmit).not.toHaveBeenCalled();
509
+ });
510
+
511
+ test('4xx exclude closes the MFE journey', async () => {
512
+ configureJourney({ httpClientErrorRequests: 'exclude' });
513
+ mfeA.startJourney(journeyDef('checkout'));
514
+
515
+ await mfeA.journeyStep('checkout', 'pay', step => {
516
+ const stamp = step.stamp() as JourneyStepStamp;
517
+ onHttpComplete({
518
+ stamp,
519
+ status: 404,
520
+ requestKey: '/api/pay',
521
+ durationMs: 10,
522
+ });
523
+ });
524
+
525
+ expect(lastJourney(mfeAEmit).outcome).toBe('excluded');
526
+ expect(hostEmit).not.toHaveBeenCalled();
527
+ expect(mfeA.getOpenJourney('checkout')).toBeUndefined();
528
+ });
529
+
530
+ test('5xx fails the MFE journey', async () => {
531
+ mfeA.startJourney(journeyDef('checkout'));
532
+
533
+ await mfeA.journeyStep('checkout', 'pay', step => {
534
+ const stamp = step.stamp() as JourneyStepStamp;
535
+ onHttpComplete({
536
+ stamp,
537
+ status: 500,
538
+ requestKey: '/api/pay',
539
+ durationMs: 10,
540
+ });
541
+ });
542
+
543
+ expect(lastJourney(mfeAEmit).outcome).toBe('bad');
544
+ expect(lastJourney(mfeAEmit).reason).toBe('request-error');
545
+ expect(hostEmit).not.toHaveBeenCalled();
546
+ });
547
+ });
548
+ });