@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,613 @@
1
+ /** @jest-environment jsdom */
2
+ import { useJourneyTestRuntime, lastJourney, baseJourneyDef } from '../__test-utils__/test-runtime';
3
+ import { configureJourney } from '../config';
4
+ import {
5
+ completeJourney,
6
+ journeyStep,
7
+ resolveStamp,
8
+ singleInFlightJourneyStep,
9
+ JourneyStepStamp,
10
+ type StepHandle,
11
+ } from '../core';
12
+ import { moduleRuntime } from '../core/runtime';
13
+ import type { JourneyHttpPolicyContext } from '../core/request-policy';
14
+ import { onHttpComplete } from '../integrations/http-report';
15
+ import { getSharedPolicies, resetSharedPolicies } from '../global';
16
+ import { exposeAppJourney } from '../integrations/jquery';
17
+
18
+ const emitMock = jest.fn();
19
+
20
+ describe('[journey] hardening', () => {
21
+ useJourneyTestRuntime(emitMock);
22
+
23
+ describe('resolveStamp realm-safe adopt', () => {
24
+ test('adopts JourneyStepStamp and branded foreign copies', async () => {
25
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'tag-journey' });
26
+ await journeyStep('tag-journey', 'load', (step: StepHandle) => {
27
+ const tagged = step.stamp() as JourneyStepStamp;
28
+ expect(resolveStamp(tagged)).toBe(tagged);
29
+ expect(tagged.step.name).toBe('load');
30
+
31
+ const foreign = {
32
+ [Symbol.for('st.journey.stamp')]: true,
33
+ step: tagged.step,
34
+ };
35
+ expect(resolveStamp(foreign)?.step.name).toBe('load');
36
+ });
37
+ completeJourney('tag-journey');
38
+ });
39
+
40
+ test('does not treat a plain tag wrapper without a StepRecord as a step', () => {
41
+ expect(resolveStamp({ [Symbol.for('st.journey.stamp')]: true })).toBeUndefined();
42
+ expect(resolveStamp({ name: 'no-journey' })).toBeUndefined();
43
+ });
44
+ });
45
+
46
+ describe('autoAttributeRequests', () => {
47
+ test('returns null from singleInFlightJourneyStep when disabled', async () => {
48
+ getSharedPolicies().autoAttributeRequests = false;
49
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'aa-off' });
50
+ await journeyStep('aa-off', 'load', () => {
51
+ expect(singleInFlightJourneyStep()).toBeNull();
52
+ });
53
+ completeJourney('aa-off');
54
+ resetSharedPolicies();
55
+ });
56
+ });
57
+
58
+ describe('httpAbortedRequests / httpClientErrorRequests policies', () => {
59
+ test('httpAbortedRequests good does not fail the journey', async () => {
60
+ getSharedPolicies().httpAbortedRequests = 'continue';
61
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'abort-good' });
62
+ await journeyStep('abort-good', 'load', (step: StepHandle) => {
63
+ const stamp = step.stamp() as JourneyStepStamp;
64
+ onHttpComplete({
65
+ stamp,
66
+ requestKey: '/api',
67
+ durationMs: 10,
68
+ aborted: true,
69
+ });
70
+ });
71
+ completeJourney('abort-good');
72
+ expect(lastJourney(emitMock).outcome).toBe('good');
73
+ });
74
+
75
+ test('abort throw inside journeyStep follows httpAbortedRequests continue', async () => {
76
+ getSharedPolicies().httpAbortedRequests = 'continue';
77
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'abort-step' });
78
+ const aborted = Object.assign(new Error('aborted'), { name: 'AbortError' });
79
+ await expect(
80
+ journeyStep('abort-step', 'load', () => {
81
+ throw aborted;
82
+ })
83
+ ).rejects.toBe(aborted);
84
+ completeJourney('abort-step');
85
+ expect(lastJourney(emitMock).outcome).toBe('good');
86
+ });
87
+
88
+ test('abort throw inside journeyStep does not score unattributed abort', async () => {
89
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'abort-bare' });
90
+ const aborted = Object.assign(new Error('aborted'), { name: 'AbortError' });
91
+ await expect(
92
+ journeyStep('abort-bare', 'load', () => {
93
+ throw aborted;
94
+ })
95
+ ).rejects.toBe(aborted);
96
+ completeJourney('abort-bare');
97
+ expect(lastJourney(emitMock).outcome).toBe('good');
98
+ });
99
+
100
+ test('default attributed abort continue does not fail the journey', async () => {
101
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'abort-default' });
102
+ await journeyStep('abort-default', 'load', (step: StepHandle) => {
103
+ const stamp = step.stamp() as JourneyStepStamp;
104
+ onHttpComplete({
105
+ stamp,
106
+ requestKey: '/api',
107
+ durationMs: 10,
108
+ aborted: true,
109
+ });
110
+ });
111
+ completeJourney('abort-default');
112
+ expect(lastJourney(emitMock).outcome).toBe('good');
113
+ });
114
+
115
+ test('ignored stamp abort throw does not fail the journey', async () => {
116
+ getSharedPolicies().httpAbortedRequests = 'bad';
117
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'stamp-ignore-abort' });
118
+ const aborted = Object.assign(new Error('aborted'), { name: 'AbortError' });
119
+ await expect(
120
+ journeyStep('stamp-ignore-abort', 'load', (step: StepHandle) => {
121
+ const stamp = step.stamp({ ignore: true }) as JourneyStepStamp;
122
+ onHttpComplete({
123
+ stamp,
124
+ requestKey: '/telemetry',
125
+ durationMs: 10,
126
+ aborted: true,
127
+ });
128
+ throw aborted;
129
+ })
130
+ ).rejects.toBe(aborted);
131
+ completeJourney('stamp-ignore-abort');
132
+ expect(lastJourney(emitMock).outcome).toBe('good');
133
+ });
134
+
135
+ test('Error canceled message is step-error not request-aborted', async () => {
136
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'canceled-msg' });
137
+ await expect(
138
+ journeyStep('canceled-msg', 'load', () => {
139
+ throw new Error('canceled');
140
+ })
141
+ ).rejects.toThrow('canceled');
142
+ expect(lastJourney(emitMock).outcome).toBe('bad');
143
+ expect(lastJourney(emitMock).reason).toBe('step-error');
144
+ });
145
+
146
+ test('numeric code 20 is step-error not request-aborted', async () => {
147
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'code-20' });
148
+ const err = Object.assign(new Error('boom'), { code: 20 });
149
+ await expect(
150
+ journeyStep('code-20', 'load', () => {
151
+ throw err;
152
+ })
153
+ ).rejects.toBe(err);
154
+ expect(lastJourney(emitMock).outcome).toBe('bad');
155
+ expect(lastJourney(emitMock).reason).toBe('step-error');
156
+ });
157
+
158
+ test('ignored abort then cancel throw does not re-apply shared abort policy', async () => {
159
+ getSharedPolicies().httpAbortedRequests = 'bad';
160
+ moduleRuntime.startJourney({
161
+ ...baseJourneyDef,
162
+ name: 'ignored-abort-throw',
163
+ endpoints: [{ match: '/poll', ignore: true }],
164
+ });
165
+ const aborted = Object.assign(new Error('aborted'), { name: 'AbortError' });
166
+ await expect(
167
+ journeyStep('ignored-abort-throw', 'load', (step: StepHandle) => {
168
+ const stamp = step.stamp() as JourneyStepStamp;
169
+ onHttpComplete({
170
+ stamp,
171
+ requestKey: '/poll/tick',
172
+ durationMs: 10,
173
+ aborted: true,
174
+ });
175
+ throw aborted;
176
+ })
177
+ ).rejects.toBe(aborted);
178
+ completeJourney('ignored-abort-throw');
179
+ expect(lastJourney(emitMock).outcome).toBe('good');
180
+ });
181
+
182
+ test('per-request abort good then cancel throw does not re-apply shared policy', async () => {
183
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'meta-abort-throw' });
184
+ const aborted = Object.assign(new Error('aborted'), { name: 'AbortError' });
185
+ await expect(
186
+ journeyStep('meta-abort-throw', 'load', (step: StepHandle) => {
187
+ const stamp = step.stamp() as JourneyStepStamp;
188
+ onHttpComplete({
189
+ stamp,
190
+ requestKey: '/api',
191
+ durationMs: 10,
192
+ aborted: true,
193
+ meta: { httpAbortedRequests: 'continue' },
194
+ });
195
+ throw aborted;
196
+ })
197
+ ).rejects.toBe(aborted);
198
+ completeJourney('meta-abort-throw');
199
+ expect(lastJourney(emitMock).outcome).toBe('good');
200
+ });
201
+
202
+ test('httpClientErrorRequests bad fails on 4xx', async () => {
203
+ getSharedPolicies().httpClientErrorRequests = 'bad';
204
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'four-xx' });
205
+ await journeyStep('four-xx', 'load', (step: StepHandle) => {
206
+ const stamp = step.stamp() as JourneyStepStamp;
207
+ onHttpComplete({
208
+ stamp,
209
+ status: 404,
210
+ requestKey: '/api',
211
+ durationMs: 10,
212
+ });
213
+ });
214
+ expect(lastJourney(emitMock).outcome).toBe('bad');
215
+ expect(lastJourney(emitMock).reason).toBe('request-client-error');
216
+ });
217
+
218
+ test('default httpClientErrorRequests still marks latency on a slow 4xx', async () => {
219
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'slow-4xx' });
220
+ await journeyStep('slow-4xx', 'load', (step: StepHandle) => {
221
+ const stamp = step.stamp() as JourneyStepStamp;
222
+ onHttpComplete({
223
+ stamp,
224
+ status: 404,
225
+ requestKey: '/api',
226
+ durationMs: 5_000,
227
+ });
228
+ });
229
+ expect(lastJourney(emitMock).outcome).toBe('bad');
230
+ expect(lastJourney(emitMock).reason).toBe('request-latency');
231
+ });
232
+
233
+ test('fast 4xx stays good when httpClientErrorRequests is continue', async () => {
234
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'fast-4xx' });
235
+ await journeyStep('fast-4xx', 'load', (step: StepHandle) => {
236
+ const stamp = step.stamp() as JourneyStepStamp;
237
+ onHttpComplete({
238
+ stamp,
239
+ status: 404,
240
+ requestKey: '/api',
241
+ durationMs: 10,
242
+ });
243
+ });
244
+ completeJourney('fast-4xx');
245
+ expect(lastJourney(emitMock).outcome).toBe('good');
246
+ });
247
+
248
+ test('ignored endpoints skip abort and 4xx policies', async () => {
249
+ const policies = getSharedPolicies();
250
+ policies.httpAbortedRequests = 'bad';
251
+ policies.httpClientErrorRequests = 'bad';
252
+ moduleRuntime.startJourney({
253
+ ...baseJourneyDef,
254
+ name: 'ignored-http',
255
+ endpoints: [{ match: '/poll', ignore: true }],
256
+ });
257
+ await journeyStep('ignored-http', 'load', (step: StepHandle) => {
258
+ const stamp = step.stamp() as JourneyStepStamp;
259
+ onHttpComplete({
260
+ stamp,
261
+ requestKey: '/poll/tick',
262
+ durationMs: 10,
263
+ aborted: true,
264
+ });
265
+ onHttpComplete({
266
+ stamp,
267
+ status: 404,
268
+ requestKey: '/poll/tick',
269
+ durationMs: 10,
270
+ });
271
+ });
272
+ completeJourney('ignored-http');
273
+ expect(lastJourney(emitMock).outcome).toBe('good');
274
+ });
275
+
276
+ test('ignored endpoints skip httpClientErrorRequests exclude', async () => {
277
+ getSharedPolicies().httpClientErrorRequests = () => 'exclude';
278
+ moduleRuntime.startJourney({
279
+ ...baseJourneyDef,
280
+ name: 'ignored-exclude',
281
+ endpoints: [{ match: '/auth', ignore: true }],
282
+ });
283
+ await journeyStep('ignored-exclude', 'load', (step: StepHandle) => {
284
+ const stamp = step.stamp() as JourneyStepStamp;
285
+ onHttpComplete({
286
+ stamp,
287
+ status: 401,
288
+ requestKey: '/auth/session',
289
+ durationMs: 10,
290
+ });
291
+ });
292
+ completeJourney('ignored-exclude');
293
+ expect(lastJourney(emitMock).outcome).toBe('good');
294
+ });
295
+
296
+ test('httpClientErrorRequests resolver can exclude on 401', async () => {
297
+ getSharedPolicies().httpClientErrorRequests = (ctx: JourneyHttpPolicyContext) =>
298
+ ctx.status === 401 ? 'exclude' : 'continue';
299
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'auth' });
300
+ await journeyStep('auth', 'load', (step: StepHandle) => {
301
+ const stamp = step.stamp() as JourneyStepStamp;
302
+ onHttpComplete({
303
+ stamp,
304
+ status: 401,
305
+ requestKey: '/api',
306
+ durationMs: 10,
307
+ });
308
+ });
309
+ expect(lastJourney(emitMock).outcome).toBe('excluded');
310
+ });
311
+ });
312
+
313
+ describe('step.stamp score', () => {
314
+ test('excludes on 5xx when the stamp decide reads the response body', async () => {
315
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'decide-exclude' });
316
+ await journeyStep('decide-exclude', 'submit', (step: StepHandle) => {
317
+ const tagged = step.stamp(ctx => {
318
+ const body = (ctx.response as { data?: { code?: string } } | undefined)?.data;
319
+ return body?.code === 'USER_DATA' ? 'exclude' : undefined;
320
+ });
321
+ onHttpComplete({
322
+ stamp: tagged,
323
+ status: 500,
324
+ requestKey: '/api/checkout',
325
+ durationMs: 10,
326
+ meta: tagged,
327
+ response: { data: { code: 'USER_DATA', message: 'bad customer' } },
328
+ });
329
+ });
330
+ expect(lastJourney(emitMock).outcome).toBe('excluded');
331
+ });
332
+
333
+ test('keeps 5xx from failing when decide returns good', async () => {
334
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'decide-good' });
335
+ await journeyStep('decide-good', 'submit', (step: StepHandle) => {
336
+ const tagged = step.stamp({ score: () => 'continue' });
337
+ onHttpComplete({
338
+ stamp: tagged,
339
+ status: 500,
340
+ requestKey: '/api',
341
+ durationMs: 10,
342
+ meta: tagged,
343
+ });
344
+ });
345
+ completeJourney('decide-good');
346
+ expect(lastJourney(emitMock).outcome).toBe('good');
347
+ });
348
+
349
+ test('fails a 200 when decide returns bad', async () => {
350
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'decide-bad' });
351
+ await journeyStep('decide-bad', 'submit', (step: StepHandle) => {
352
+ const tagged = step.stamp(() => 'bad');
353
+ onHttpComplete({
354
+ stamp: tagged,
355
+ status: 200,
356
+ requestKey: '/api',
357
+ durationMs: 10,
358
+ meta: tagged,
359
+ request: { url: '/api' },
360
+ response: { data: { success: false } },
361
+ });
362
+ });
363
+ expect(lastJourney(emitMock).outcome).toBe('bad');
364
+ expect(lastJourney(emitMock).reason).toBe('request-error');
365
+ });
366
+
367
+ test('falls through to default 5xx scoring when decide returns void', async () => {
368
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'decide-void' });
369
+ await journeyStep('decide-void', 'submit', (step: StepHandle) => {
370
+ const tagged = step.stamp(() => undefined);
371
+ onHttpComplete({
372
+ stamp: tagged,
373
+ status: 500,
374
+ requestKey: '/api',
375
+ durationMs: 10,
376
+ meta: tagged,
377
+ });
378
+ });
379
+ expect(lastJourney(emitMock).outcome).toBe('bad');
380
+ expect(lastJourney(emitMock).reason).toBe('request-error');
381
+ });
382
+
383
+ test('falls through when decide throws', async () => {
384
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'decide-throw' });
385
+ await journeyStep('decide-throw', 'submit', (step: StepHandle) => {
386
+ const tagged = step.stamp(() => {
387
+ throw new Error('decide boom');
388
+ });
389
+ onHttpComplete({
390
+ stamp: tagged,
391
+ status: 500,
392
+ requestKey: '/api',
393
+ durationMs: 10,
394
+ meta: tagged,
395
+ });
396
+ });
397
+ expect(lastJourney(emitMock).outcome).toBe('bad');
398
+ expect(lastJourney(emitMock).reason).toBe('request-error');
399
+ });
400
+
401
+ test('does not run decide on an unstamped stamp() used only to unwrap the step', async () => {
402
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'decide-unstamped' });
403
+ await journeyStep('decide-unstamped', 'submit', (step: StepHandle) => {
404
+ const stamp = step.stamp() as JourneyStepStamp;
405
+ onHttpComplete({
406
+ stamp,
407
+ status: 500,
408
+ requestKey: '/api',
409
+ durationMs: 10,
410
+ });
411
+ });
412
+ expect(lastJourney(emitMock).outcome).toBe('bad');
413
+ });
414
+
415
+ test('ignored endpoints skip stamp decide', async () => {
416
+ moduleRuntime.startJourney({
417
+ ...baseJourneyDef,
418
+ name: 'decide-ignore',
419
+ endpoints: [{ match: '/poll', ignore: true }],
420
+ });
421
+ await journeyStep('decide-ignore', 'load', (step: StepHandle) => {
422
+ const tagged = step.stamp(() => 'exclude');
423
+ onHttpComplete({
424
+ stamp: tagged,
425
+ status: 500,
426
+ requestKey: '/poll/tick',
427
+ durationMs: 10,
428
+ meta: tagged,
429
+ });
430
+ });
431
+ completeJourney('decide-ignore');
432
+ expect(lastJourney(emitMock).outcome).toBe('good');
433
+ });
434
+
435
+ test('reads decide off a branded foreign tag copy', async () => {
436
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'decide-brand' });
437
+ await journeyStep('decide-brand', 'load', (step: StepHandle) => {
438
+ const tagged = step.stamp(() => 'exclude') as JourneyStepStamp;
439
+ const foreign = {
440
+ [Symbol.for('st.journey.stamp')]: true,
441
+ step: tagged.step,
442
+ score: tagged.score,
443
+ };
444
+ onHttpComplete({
445
+ stamp: resolveStamp(foreign),
446
+ status: 500,
447
+ requestKey: '/api',
448
+ durationMs: 10,
449
+ meta: { stamp: foreign },
450
+ });
451
+ });
452
+ expect(lastJourney(emitMock).outcome).toBe('excluded');
453
+ });
454
+ });
455
+
456
+ describe('stamp / meta ignore', () => {
457
+ test('meta.ignore skips scoring while a step is in flight', async () => {
458
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'meta-ignore' });
459
+ await journeyStep('meta-ignore', 'load', (step: StepHandle) => {
460
+ const stamp = step.stamp() as JourneyStepStamp;
461
+ onHttpComplete({
462
+ stamp,
463
+ status: 500,
464
+ requestKey: '/telemetry',
465
+ durationMs: 10,
466
+ meta: { ignore: true },
467
+ });
468
+ });
469
+ completeJourney('meta-ignore');
470
+ expect(lastJourney(emitMock).outcome).toBe('good');
471
+ });
472
+
473
+ test('step.stamp({ ignore: true }) never changes the verdict', async () => {
474
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'stamp-ignore' });
475
+ await journeyStep('stamp-ignore', 'load', (step: StepHandle) => {
476
+ const tagged = step.stamp({ ignore: true });
477
+ onHttpComplete({
478
+ stamp: tagged,
479
+ status: 500,
480
+ requestKey: '/api',
481
+ durationMs: 10,
482
+ meta: tagged,
483
+ });
484
+ });
485
+ completeJourney('stamp-ignore');
486
+ expect(lastJourney(emitMock).outcome).toBe('good');
487
+ });
488
+
489
+ test('a later stamped request on the same step still scores', async () => {
490
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'ignore-then-score' });
491
+ await journeyStep('ignore-then-score', 'load', (step: StepHandle) => {
492
+ onHttpComplete({
493
+ stamp: step.stamp({ ignore: true }) as JourneyStepStamp,
494
+ status: 500,
495
+ requestKey: '/telemetry',
496
+ durationMs: 10,
497
+ });
498
+ onHttpComplete({
499
+ stamp: step.stamp() as JourneyStepStamp,
500
+ status: 500,
501
+ requestKey: '/api',
502
+ durationMs: 10,
503
+ });
504
+ });
505
+ expect(lastJourney(emitMock).outcome).toBe('bad');
506
+ });
507
+
508
+ test('decide on an ignored stamp is not applied', async () => {
509
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'ignore-decide' });
510
+ await journeyStep('ignore-decide', 'load', (step: StepHandle) => {
511
+ const tagged = step.stamp({ ignore: true, score: () => 'exclude' });
512
+ onHttpComplete({
513
+ stamp: tagged,
514
+ status: 200,
515
+ requestKey: '/api',
516
+ durationMs: 10,
517
+ meta: tagged,
518
+ });
519
+ });
520
+ completeJourney('ignore-decide');
521
+ expect(lastJourney(emitMock).outcome).toBe('good');
522
+ });
523
+
524
+ test('ambient singleInFlightJourneyStep is a stamp without score or ignore', async () => {
525
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'ambient-tag' });
526
+ await journeyStep('ambient-tag', 'load', () => {
527
+ const stamp = singleInFlightJourneyStep();
528
+ expect(stamp).toBeInstanceOf(JourneyStepStamp);
529
+ expect(stamp?.score).toBeUndefined();
530
+ expect(stamp?.ignore).toBeUndefined();
531
+ });
532
+ completeJourney('ambient-tag');
533
+ });
534
+ });
535
+
536
+ describe('getJourneyHandle', () => {
537
+ test('shares the original config object', () => {
538
+ const target: { App?: { Journey?: unknown } } = {};
539
+ const api = exposeAppJourney(target as never);
540
+ const config = { ...baseJourneyDef, name: 'ko-open' };
541
+ const original = api.defineJourney(config);
542
+ original.start();
543
+ const lookedUp = api.getJourneyHandle('ko-open');
544
+ expect(lookedUp?.config).not.toBe(config);
545
+ expect(lookedUp?.config.name).toBe(config.name);
546
+ expect(Object.isFrozen(lookedUp?.config)).toBe(true);
547
+ original.complete();
548
+ expect(lastJourney(emitMock).outcome).toBe('good');
549
+ expect(api.getJourneyHandle('ko-open')).toBeNull();
550
+ });
551
+
552
+ test('start on a lookup handle restarts the open journey', () => {
553
+ const target: { App?: { Journey?: unknown } } = {};
554
+ const api = exposeAppJourney(target as never);
555
+ const config = { ...baseJourneyDef, name: 'ko-start' };
556
+ api.defineJourney(config).start();
557
+ const handle = api.getJourneyHandle('ko-start');
558
+ expect(handle?.config.name).toBe(config.name);
559
+ expect(Object.isFrozen(handle?.config)).toBe(true);
560
+ handle!.start();
561
+ expect(lastJourney(emitMock).outcome).toBe('excluded');
562
+ expect(api.getJourneyHandle('ko-start')?.config.name).toBe(config.name);
563
+ handle!.complete();
564
+ expect(lastJourney(emitMock).outcome).toBe('good');
565
+ expect(emitMock).toHaveBeenCalledTimes(2);
566
+ });
567
+
568
+ test('wraps a config that was started without defineJourney', () => {
569
+ const target: { App?: { Journey?: unknown } } = {};
570
+ const api = exposeAppJourney(target as never);
571
+ const config = {
572
+ ...baseJourneyDef,
573
+ name: 'ko-config',
574
+ expected: ['load'],
575
+ tags: { plan: 'pro' },
576
+ };
577
+ moduleRuntime.startJourney(config);
578
+ const handle = api.getJourneyHandle('ko-config');
579
+ expect(handle?.config).not.toBe(config);
580
+ expect(handle?.config.name).toBe('ko-config');
581
+ expect(handle?.config.expected).toEqual(['load']);
582
+ expect(handle?.config.tags?.plan).toBe('pro');
583
+ expect(Object.isFrozen(handle?.config)).toBe(true);
584
+ handle!.complete();
585
+ });
586
+
587
+ test('step on a lookup handle reopens a closed journey', async () => {
588
+ const target: { App?: { Journey?: unknown } } = {};
589
+ const api = exposeAppJourney(target as never);
590
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'ko-step' });
591
+ const handle = api.getJourneyHandle('ko-step')!;
592
+ handle.complete();
593
+ expect(emitMock).toHaveBeenCalledTimes(1);
594
+
595
+ await handle.step('late', () => {});
596
+ expect(api.getJourneyHandle('ko-step')).not.toBeNull();
597
+ handle.complete();
598
+ expect(emitMock).toHaveBeenCalledTimes(2);
599
+ });
600
+ });
601
+
602
+ describe('configureJourney shared policies', () => {
603
+ test('applies autoAttributeRequests via configureJourney', async () => {
604
+ configureJourney({ autoAttributeRequests: false });
605
+ moduleRuntime.startJourney({ ...baseJourneyDef, name: 'cfg-aa' });
606
+ await journeyStep('cfg-aa', 'load', () => {
607
+ expect(singleInFlightJourneyStep()).toBeNull();
608
+ });
609
+ completeJourney('cfg-aa');
610
+ resetSharedPolicies();
611
+ });
612
+ });
613
+ });