@servicetitan/journey 1.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 (136) hide show
  1. package/README.md +7 -0
  2. package/dist/__mocks__/test-runtime.d.ts +16 -0
  3. package/dist/__mocks__/test-runtime.d.ts.map +1 -0
  4. package/dist/__tests__/ambient.test.d.ts +2 -0
  5. package/dist/__tests__/ambient.test.d.ts.map +1 -0
  6. package/dist/__tests__/axios.test.d.ts +2 -0
  7. package/dist/__tests__/axios.test.d.ts.map +1 -0
  8. package/dist/__tests__/config.test.d.ts +2 -0
  9. package/dist/__tests__/config.test.d.ts.map +1 -0
  10. package/dist/__tests__/context.test.d.ts +2 -0
  11. package/dist/__tests__/context.test.d.ts.map +1 -0
  12. package/dist/__tests__/datadog.test.d.ts +2 -0
  13. package/dist/__tests__/datadog.test.d.ts.map +1 -0
  14. package/dist/__tests__/define-journey.test.d.ts +2 -0
  15. package/dist/__tests__/define-journey.test.d.ts.map +1 -0
  16. package/dist/__tests__/expose-app-journey.test.d.ts +2 -0
  17. package/dist/__tests__/expose-app-journey.test.d.ts.map +1 -0
  18. package/dist/__tests__/fetch.test.d.ts +2 -0
  19. package/dist/__tests__/fetch.test.d.ts.map +1 -0
  20. package/dist/__tests__/jquery.test.d.ts +2 -0
  21. package/dist/__tests__/jquery.test.d.ts.map +1 -0
  22. package/dist/__tests__/registry.test.d.ts +2 -0
  23. package/dist/__tests__/registry.test.d.ts.map +1 -0
  24. package/dist/__tests__/request-key.test.d.ts +2 -0
  25. package/dist/__tests__/request-key.test.d.ts.map +1 -0
  26. package/dist/__tests__/requests.test.d.ts +2 -0
  27. package/dist/__tests__/requests.test.d.ts.map +1 -0
  28. package/dist/__tests__/runtime.test.d.ts +2 -0
  29. package/dist/__tests__/runtime.test.d.ts.map +1 -0
  30. package/dist/__tests__/scope.test.d.ts +2 -0
  31. package/dist/__tests__/scope.test.d.ts.map +1 -0
  32. package/dist/__tests__/sinks.test.d.ts +2 -0
  33. package/dist/__tests__/sinks.test.d.ts.map +1 -0
  34. package/dist/__tests__/step.test.d.ts +2 -0
  35. package/dist/__tests__/step.test.d.ts.map +1 -0
  36. package/dist/__tests__/timeouts.test.d.ts +2 -0
  37. package/dist/__tests__/timeouts.test.d.ts.map +1 -0
  38. package/dist/config.d.ts +42 -0
  39. package/dist/config.d.ts.map +1 -0
  40. package/dist/config.js +46 -0
  41. package/dist/config.js.map +1 -0
  42. package/dist/core/index.d.ts +6 -0
  43. package/dist/core/index.d.ts.map +1 -0
  44. package/dist/core/index.js +5 -0
  45. package/dist/core/index.js.map +1 -0
  46. package/dist/core/registry.d.ts +50 -0
  47. package/dist/core/registry.d.ts.map +1 -0
  48. package/dist/core/registry.js +102 -0
  49. package/dist/core/registry.js.map +1 -0
  50. package/dist/core/runtime.d.ts +51 -0
  51. package/dist/core/runtime.d.ts.map +1 -0
  52. package/dist/core/runtime.js +401 -0
  53. package/dist/core/runtime.js.map +1 -0
  54. package/dist/core/step-tag.d.ts +16 -0
  55. package/dist/core/step-tag.d.ts.map +1 -0
  56. package/dist/core/step-tag.js +35 -0
  57. package/dist/core/step-tag.js.map +1 -0
  58. package/dist/core/types.d.ts +143 -0
  59. package/dist/core/types.d.ts.map +1 -0
  60. package/dist/core/types.js +3 -0
  61. package/dist/core/types.js.map +1 -0
  62. package/dist/endpoint-policy.d.ts +29 -0
  63. package/dist/endpoint-policy.d.ts.map +1 -0
  64. package/dist/endpoint-policy.js +39 -0
  65. package/dist/endpoint-policy.js.map +1 -0
  66. package/dist/index.d.ts +15 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.js +19 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/integrations/axios.d.ts +23 -0
  71. package/dist/integrations/axios.d.ts.map +1 -0
  72. package/dist/integrations/axios.js +51 -0
  73. package/dist/integrations/axios.js.map +1 -0
  74. package/dist/integrations/fetch.d.ts +21 -0
  75. package/dist/integrations/fetch.d.ts.map +1 -0
  76. package/dist/integrations/fetch.js +63 -0
  77. package/dist/integrations/fetch.js.map +1 -0
  78. package/dist/integrations/jquery/index.d.ts +22 -0
  79. package/dist/integrations/jquery/index.d.ts.map +1 -0
  80. package/dist/integrations/jquery/index.js +27 -0
  81. package/dist/integrations/jquery/index.js.map +1 -0
  82. package/dist/integrations/jquery/instrument.d.ts +18 -0
  83. package/dist/integrations/jquery/instrument.d.ts.map +1 -0
  84. package/dist/integrations/jquery/instrument.js +44 -0
  85. package/dist/integrations/jquery/instrument.js.map +1 -0
  86. package/dist/integrations/react/context.d.ts +6 -0
  87. package/dist/integrations/react/context.d.ts.map +1 -0
  88. package/dist/integrations/react/context.js +14 -0
  89. package/dist/integrations/react/context.js.map +1 -0
  90. package/dist/integrations/react/scope.d.ts +13 -0
  91. package/dist/integrations/react/scope.d.ts.map +1 -0
  92. package/dist/integrations/react/scope.js +27 -0
  93. package/dist/integrations/react/scope.js.map +1 -0
  94. package/dist/integrations/request-key.d.ts +17 -0
  95. package/dist/integrations/request-key.d.ts.map +1 -0
  96. package/dist/integrations/request-key.js +39 -0
  97. package/dist/integrations/request-key.js.map +1 -0
  98. package/dist/sinks/datadog.d.ts +6 -0
  99. package/dist/sinks/datadog.d.ts.map +1 -0
  100. package/dist/sinks/datadog.js +64 -0
  101. package/dist/sinks/datadog.js.map +1 -0
  102. package/package.json +44 -0
  103. package/src/__mocks__/test-runtime.ts +37 -0
  104. package/src/__tests__/ambient.test.ts +101 -0
  105. package/src/__tests__/axios.test.ts +365 -0
  106. package/src/__tests__/config.test.ts +261 -0
  107. package/src/__tests__/context.test.tsx +84 -0
  108. package/src/__tests__/datadog.test.ts +160 -0
  109. package/src/__tests__/define-journey.test.ts +176 -0
  110. package/src/__tests__/expose-app-journey.test.ts +149 -0
  111. package/src/__tests__/fetch.test.ts +264 -0
  112. package/src/__tests__/jquery.test.ts +327 -0
  113. package/src/__tests__/registry.test.ts +265 -0
  114. package/src/__tests__/request-key.test.ts +81 -0
  115. package/src/__tests__/requests.test.ts +337 -0
  116. package/src/__tests__/runtime.test.ts +273 -0
  117. package/src/__tests__/scope.test.tsx +128 -0
  118. package/src/__tests__/sinks.test.ts +99 -0
  119. package/src/__tests__/step.test.ts +305 -0
  120. package/src/__tests__/timeouts.test.ts +226 -0
  121. package/src/config.ts +87 -0
  122. package/src/core/index.ts +39 -0
  123. package/src/core/registry.ts +162 -0
  124. package/src/core/runtime.ts +489 -0
  125. package/src/core/step-tag.ts +24 -0
  126. package/src/core/types.ts +160 -0
  127. package/src/endpoint-policy.ts +66 -0
  128. package/src/index.ts +51 -0
  129. package/src/integrations/axios.ts +85 -0
  130. package/src/integrations/fetch.ts +97 -0
  131. package/src/integrations/jquery/index.ts +62 -0
  132. package/src/integrations/jquery/instrument.ts +84 -0
  133. package/src/integrations/react/context.ts +17 -0
  134. package/src/integrations/react/scope.tsx +32 -0
  135. package/src/integrations/request-key.ts +46 -0
  136. package/src/sinks/datadog.ts +71 -0
@@ -0,0 +1,265 @@
1
+ /** @jest-environment jsdom */
2
+ import { baseJourneyDef, lastJourney, useJourneyTestRuntime } from '../__mocks__/test-runtime';
3
+ import {
4
+ completeJourney,
5
+ excludeJourney,
6
+ failJourney,
7
+ firstOpenJourney,
8
+ journeyStep,
9
+ setJourneyTags,
10
+ startJourney,
11
+ } from '../core';
12
+
13
+ const emitMock = jest.fn();
14
+ const baseDef = { ...baseJourneyDef, name: 'registry-api' };
15
+ const otherDef = { ...baseJourneyDef, name: 'registry-other' };
16
+
17
+ describe('[journey] registry', () => {
18
+ useJourneyTestRuntime(emitMock);
19
+
20
+ describe('firstOpenJourney', () => {
21
+ const names = [baseDef.name, otherDef.name];
22
+
23
+ const subject = () => firstOpenJourney(names);
24
+
25
+ describe('when none are open', () => {
26
+ test('returns null', () => {
27
+ expect(subject()).toBeNull();
28
+ });
29
+ });
30
+
31
+ describe('when only the second name is open', () => {
32
+ beforeEach(() => {
33
+ startJourney(otherDef);
34
+ });
35
+
36
+ test('returns that name', () => {
37
+ expect(subject()).toBe(otherDef.name);
38
+ });
39
+ });
40
+
41
+ describe('when both are open', () => {
42
+ beforeEach(() => {
43
+ startJourney(otherDef);
44
+ startJourney(baseDef);
45
+ });
46
+
47
+ test('returns the first match in order', () => {
48
+ expect(subject()).toBe(baseDef.name);
49
+ expect(firstOpenJourney([otherDef.name, baseDef.name])).toBe(otherDef.name);
50
+ });
51
+ });
52
+ });
53
+
54
+ describe('completeJourney', () => {
55
+ describe('with tags', () => {
56
+ beforeEach(() => {
57
+ startJourney(baseDef);
58
+ });
59
+
60
+ const subject = () => completeJourney(baseDef.name, { 'lead.id': 7 });
61
+
62
+ test('emits good with tags', () => {
63
+ subject();
64
+
65
+ expect(lastJourney(emitMock).outcome).toBe('good');
66
+ expect(lastJourney(emitMock).tags['lead.id']).toBe(7);
67
+ });
68
+ });
69
+
70
+ describe('with null name', () => {
71
+ const subject = () => completeJourney(null, { a: 1 });
72
+
73
+ test('is a no-op', () => {
74
+ expect(() => subject()).not.toThrow();
75
+ expect(emitMock).not.toHaveBeenCalled();
76
+ });
77
+ });
78
+
79
+ describe('when already closed', () => {
80
+ beforeEach(async () => {
81
+ startJourney(baseDef);
82
+ await journeyStep(baseDef.name, 'do-work', step => {
83
+ step.complete();
84
+ });
85
+ });
86
+
87
+ const subject = () => completeJourney(baseDef.name, { ignored: true });
88
+
89
+ test('does not emit again', () => {
90
+ subject();
91
+
92
+ expect(emitMock).toHaveBeenCalledTimes(1);
93
+ });
94
+ });
95
+
96
+ describe('after a fail', () => {
97
+ beforeEach(async () => {
98
+ startJourney(baseDef);
99
+ await journeyStep(baseDef.name, 'do-work', step => {
100
+ step.fail('validation');
101
+ });
102
+ });
103
+
104
+ const subject = () => completeJourney(baseDef.name, { ignored: true });
105
+
106
+ test('does not flip the outcome', () => {
107
+ subject();
108
+
109
+ expect(emitMock).toHaveBeenCalledTimes(1);
110
+ expect(lastJourney(emitMock).outcome).toBe('bad');
111
+ });
112
+ });
113
+ });
114
+
115
+ describe('startJourney', () => {
116
+ describe('when restarting the same name', () => {
117
+ beforeEach(() => {
118
+ startJourney(baseDef);
119
+ });
120
+
121
+ const subject = () => startJourney(baseDef);
122
+
123
+ test('excludes the prior open journey', () => {
124
+ subject();
125
+
126
+ expect(lastJourney(emitMock).outcome).toBe('excluded');
127
+ });
128
+ });
129
+
130
+ describe('when restarting after a bad close', () => {
131
+ beforeEach(async () => {
132
+ startJourney(baseDef);
133
+ await journeyStep(baseDef.name, 'do-work', step => {
134
+ step.fail('validation');
135
+ });
136
+ });
137
+
138
+ const subject = () => startJourney(baseDef);
139
+
140
+ test('opens fresh without a second exclude', () => {
141
+ subject();
142
+
143
+ expect(emitMock).toHaveBeenCalledTimes(1);
144
+ completeJourney(baseDef.name);
145
+ expect(lastJourney(emitMock).outcome).toBe('good');
146
+ });
147
+ });
148
+
149
+ describe('with static def tags', () => {
150
+ const subject = () => {
151
+ startJourney({ ...baseDef, tags: { plan: 'pro' } });
152
+ completeJourney(baseDef.name, { 'job.id': 5 });
153
+ };
154
+
155
+ test('merges into the event', () => {
156
+ subject();
157
+
158
+ expect(lastJourney(emitMock).tags).toEqual({ 'plan': 'pro', 'job.id': 5 });
159
+ });
160
+ });
161
+ });
162
+
163
+ describe('failJourney', () => {
164
+ beforeEach(() => {
165
+ startJourney(baseDef);
166
+ });
167
+
168
+ const subject = () => failJourney(baseDef.name, 'manual-bad');
169
+
170
+ test('marks bad', () => {
171
+ subject();
172
+
173
+ expect(lastJourney(emitMock).outcome).toBe('bad');
174
+ expect(lastJourney(emitMock).reason).toBe('manual-bad');
175
+ });
176
+
177
+ describe('when called twice', () => {
178
+ test('keeps the first reason', () => {
179
+ failJourney(baseDef.name, 'first-breach');
180
+ failJourney(baseDef.name, 'second-breach');
181
+
182
+ expect(emitMock).toHaveBeenCalledTimes(1);
183
+ expect(lastJourney(emitMock).reason).toBe('first-breach');
184
+ });
185
+ });
186
+ });
187
+
188
+ describe('excludeJourney', () => {
189
+ describe('with a clean journey', () => {
190
+ beforeEach(() => {
191
+ startJourney(otherDef);
192
+ });
193
+
194
+ const subject = () => excludeJourney(otherDef.name);
195
+
196
+ test('marks excluded', () => {
197
+ subject();
198
+
199
+ expect(lastJourney(emitMock).outcome).toBe('excluded');
200
+ });
201
+ });
202
+
203
+ describe('after a breach', () => {
204
+ beforeEach(async () => {
205
+ startJourney(baseDef);
206
+ await journeyStep(baseDef.name, 'do-work', step => {
207
+ step.fail('validation');
208
+ });
209
+ emitMock.mockClear();
210
+ });
211
+
212
+ const subject = () => excludeJourney(baseDef.name);
213
+
214
+ test('does not emit again', () => {
215
+ subject();
216
+
217
+ expect(emitMock).not.toHaveBeenCalled();
218
+ });
219
+ });
220
+
221
+ describe('when past the journey budget', () => {
222
+ beforeEach(() => {
223
+ jest.spyOn(globalThis.performance, 'now').mockReturnValue(1_000);
224
+ startJourney({ ...baseDef, timeoutMs: 50 });
225
+ (globalThis.performance.now as jest.Mock).mockReturnValue(1_100);
226
+ });
227
+
228
+ afterEach(() => {
229
+ jest.restoreAllMocks();
230
+ });
231
+
232
+ const subject = () => excludeJourney(baseDef.name);
233
+
234
+ test('forces bad with journey-timeout', () => {
235
+ subject();
236
+
237
+ expect(lastJourney(emitMock).outcome).toBe('bad');
238
+ expect(lastJourney(emitMock).reason).toBe('journey-timeout');
239
+ });
240
+ });
241
+ });
242
+
243
+ describe('setJourneyTags', () => {
244
+ describe('with null name', () => {
245
+ const subject = () => setJourneyTags(null, { a: 1 });
246
+
247
+ test('is a no-op', () => {
248
+ expect(() => subject()).not.toThrow();
249
+ });
250
+ });
251
+
252
+ describe('with a closed journey', () => {
253
+ beforeEach(() => {
254
+ startJourney(baseDef);
255
+ completeJourney(baseDef.name);
256
+ });
257
+
258
+ const subject = () => setJourneyTags(baseDef.name, { a: 1 });
259
+
260
+ test('is a no-op', () => {
261
+ expect(() => subject()).not.toThrow();
262
+ });
263
+ });
264
+ });
265
+ });
@@ -0,0 +1,81 @@
1
+ /** @jest-environment jsdom */
2
+ import { requestKey } from '../integrations/request-key';
3
+
4
+ describe('[journey] requestKey', () => {
5
+ // Do not redefine window.location — jsdom's property is non-configurable.
6
+ const pageOrigin = window.location.origin;
7
+
8
+ describe('with a same-origin absolute url', () => {
9
+ const subject = () => requestKey(`${pageOrigin}/api/jobs`, undefined, undefined);
10
+
11
+ test('returns the pathname', () => {
12
+ expect(subject()).toBe('/api/jobs');
13
+ });
14
+ });
15
+
16
+ describe('with a relative url', () => {
17
+ const subject = () => requestKey('/CallScreen/BookJob', undefined, undefined);
18
+
19
+ test('returns the pathname against the page origin', () => {
20
+ expect(subject()).toBe('/CallScreen/BookJob');
21
+ });
22
+ });
23
+
24
+ describe('with a cross-origin url', () => {
25
+ const subject = () => requestKey('https://pdf.vendor.com/render', undefined, undefined);
26
+
27
+ test('returns host/path', () => {
28
+ expect(subject()).toBe('pdf.vendor.com/render');
29
+ });
30
+ });
31
+
32
+ describe('with configBaseURL and instanceBaseURL', () => {
33
+ const subject = () =>
34
+ requestKey('/invoices', 'https://api.example.com', 'https://other.example.com');
35
+
36
+ test('prefers configBaseURL for resolution', () => {
37
+ expect(subject()).toBe('api.example.com/invoices');
38
+ });
39
+ });
40
+
41
+ describe('with only instanceBaseURL', () => {
42
+ const subject = () => requestKey('/invoices', undefined, 'https://api.example.com');
43
+
44
+ test('uses the instance home', () => {
45
+ expect(subject()).toBe('/invoices');
46
+ });
47
+ });
48
+
49
+ describe('with a cross-origin url against a different instance home', () => {
50
+ const subject = () =>
51
+ requestKey('https://pdf.vendor.com/render', undefined, 'https://api.example.com');
52
+
53
+ test('returns host/path', () => {
54
+ expect(subject()).toBe('pdf.vendor.com/render');
55
+ });
56
+ });
57
+
58
+ describe('when URL construction fails', () => {
59
+ const subject = () => requestKey('http://[', undefined, undefined);
60
+
61
+ test('returns the raw url', () => {
62
+ expect(subject()).toBe('http://[');
63
+ });
64
+ });
65
+
66
+ describe('when url is undefined and construction fails', () => {
67
+ const subject = () => requestKey(undefined, '::not-a-url::', undefined);
68
+
69
+ test('returns an empty string', () => {
70
+ expect(subject()).toBe('');
71
+ });
72
+ });
73
+
74
+ describe('when instanceBaseURL is invalid', () => {
75
+ const subject = () => requestKey(`${pageOrigin}/ok`, pageOrigin, '::bad::');
76
+
77
+ test('falls back to the page origin', () => {
78
+ expect(subject()).toBe('/ok');
79
+ });
80
+ });
81
+ });
@@ -0,0 +1,337 @@
1
+ /** @jest-environment jsdom */
2
+ import { baseJourneyDef, lastJourney, useJourneyTestRuntime } from '../__mocks__/test-runtime';
3
+ import { configureJourney } from '../config';
4
+ import {
5
+ completeJourney,
6
+ journeyStep,
7
+ reportBackendRequest,
8
+ resolveJourneyStep,
9
+ startJourney,
10
+ type JourneyDef,
11
+ type JourneyStepRef,
12
+ } from '../core';
13
+
14
+ const emitMock = jest.fn();
15
+ const baseDef = { ...baseJourneyDef, name: 'requests-journey' };
16
+
17
+ describe('[journey] reportBackendRequest', () => {
18
+ let stepRef: JourneyStepRef | undefined;
19
+ let status: number | undefined;
20
+ let durationMs: number;
21
+ let key: string;
22
+
23
+ useJourneyTestRuntime(emitMock);
24
+
25
+ beforeEach(() => {
26
+ stepRef = undefined;
27
+ status = 500;
28
+ durationMs = 10;
29
+ key = '/api';
30
+ });
31
+
32
+ const subject = () => reportBackendRequest(stepRef, status, durationMs, key);
33
+
34
+ async function withOpenStep(
35
+ run: (ref: JourneyStepRef) => void | Promise<void>,
36
+ def: JourneyDef = { ...baseDef, requestTimeoutMs: 5_000 }
37
+ ) {
38
+ startJourney(def);
39
+ await journeyStep(baseDef.name, 'do-work', async step => {
40
+ stepRef = resolveJourneyStep((step.tag() as { journeyStep: unknown }).journeyStep);
41
+ await run(stepRef!);
42
+ });
43
+ }
44
+
45
+ describe('without a step ref', () => {
46
+ beforeEach(() => {
47
+ stepRef = undefined;
48
+ });
49
+
50
+ test('is a no-op', () => {
51
+ expect(() => subject()).not.toThrow();
52
+ expect(emitMock).not.toHaveBeenCalled();
53
+ });
54
+ });
55
+
56
+ describe('when status is a 4xx', () => {
57
+ beforeEach(() => {
58
+ status = 404;
59
+ });
60
+
61
+ test('does not mark the journey bad', async () => {
62
+ await withOpenStep(() => {
63
+ subject();
64
+ });
65
+ completeJourney(baseDef.name);
66
+
67
+ expect(lastJourney(emitMock).outcome).toBe('good');
68
+ });
69
+ });
70
+
71
+ describe('when the request is under the latency threshold', () => {
72
+ beforeEach(() => {
73
+ status = 200;
74
+ durationMs = 10;
75
+ });
76
+
77
+ test('does not mark the journey bad', async () => {
78
+ await withOpenStep(() => {
79
+ subject();
80
+ });
81
+ completeJourney(baseDef.name);
82
+
83
+ expect(lastJourney(emitMock).outcome).toBe('good');
84
+ });
85
+ });
86
+
87
+ describe('when the request is slow', () => {
88
+ beforeEach(() => {
89
+ status = 200;
90
+ durationMs = 200;
91
+ key = '/slow';
92
+ });
93
+
94
+ test('marks request-latency', async () => {
95
+ await withOpenStep(
96
+ () => {
97
+ subject();
98
+ },
99
+ { ...baseDef, requestTimeoutMs: 50 }
100
+ );
101
+
102
+ expect(lastJourney(emitMock).reason).toBe('request-latency');
103
+ });
104
+ });
105
+
106
+ describe('when status is a 5xx', () => {
107
+ beforeEach(() => {
108
+ status = 502;
109
+ });
110
+
111
+ test('records httpStatus on the step', async () => {
112
+ await withOpenStep(() => {
113
+ subject();
114
+ });
115
+
116
+ expect(lastJourney(emitMock).steps[0].httpStatus).toBe(502);
117
+ expect(lastJourney(emitMock).steps[0].reason).toBe('request-error');
118
+ });
119
+ });
120
+
121
+ describe('when the step already has a reason', () => {
122
+ test('preserves the first reason', async () => {
123
+ await withOpenStep(ref => {
124
+ ref.reason = 'step-error';
125
+ status = 502;
126
+ subject();
127
+ });
128
+
129
+ expect(lastJourney(emitMock).steps[0].reason).toBe('step-error');
130
+ expect(lastJourney(emitMock).reason).toBe('request-error');
131
+ });
132
+ });
133
+
134
+ describe('when status is undefined', () => {
135
+ beforeEach(() => {
136
+ status = undefined;
137
+ });
138
+
139
+ test('marks request-error without httpStatus', async () => {
140
+ await withOpenStep(() => {
141
+ subject();
142
+ });
143
+
144
+ expect(lastJourney(emitMock).reason).toBe('request-error');
145
+ expect(lastJourney(emitMock).steps[0].httpStatus).toBeUndefined();
146
+ });
147
+ });
148
+
149
+ describe('when the journey endpoint is ignored', () => {
150
+ test('never marks the journey bad', async () => {
151
+ await withOpenStep(
152
+ () => {
153
+ status = 500;
154
+ key = '/flaky/thing';
155
+ subject();
156
+ },
157
+ {
158
+ ...baseDef,
159
+ requestTimeoutMs: 5_000,
160
+ endpoints: [{ match: '/flaky', ignore: true }],
161
+ }
162
+ );
163
+ completeJourney(baseDef.name);
164
+
165
+ expect(lastJourney(emitMock).outcome).toBe('good');
166
+ });
167
+ });
168
+
169
+ describe('when a journey endpoint timeoutMs is 0', () => {
170
+ test('falls through to the journey requestTimeoutMs', async () => {
171
+ startJourney({
172
+ ...baseDef,
173
+ requestTimeoutMs: 30,
174
+ endpoints: [{ match: '/special', timeoutMs: 0 }],
175
+ });
176
+
177
+ await journeyStep(baseDef.name, 'do-work', step => {
178
+ stepRef = resolveJourneyStep((step.tag() as { journeyStep: unknown }).journeyStep);
179
+ status = 200;
180
+ durationMs = 100;
181
+ key = '/special/path';
182
+ subject();
183
+ });
184
+
185
+ expect(lastJourney(emitMock).reason).toBe('request-latency');
186
+ });
187
+ });
188
+
189
+ describe('when journey endpoint timeoutMs is set', () => {
190
+ test('wins over the journey requestTimeoutMs', async () => {
191
+ await withOpenStep(
192
+ () => {
193
+ status = 200;
194
+ durationMs = 50;
195
+ key = '/special/path';
196
+ subject();
197
+ },
198
+ {
199
+ ...baseDef,
200
+ requestTimeoutMs: 5_000,
201
+ endpoints: [{ match: '/special', timeoutMs: 20 }],
202
+ }
203
+ );
204
+
205
+ expect(lastJourney(emitMock).reason).toBe('request-latency');
206
+ });
207
+ });
208
+
209
+ describe('when the journey endpoint ignore overrides the app-wide policy', () => {
210
+ beforeEach(() => {
211
+ configureJourney({
212
+ requestTimeouts: {
213
+ defaultMs: 4_000,
214
+ endpoints: [{ match: '/flaky', ignore: true }],
215
+ },
216
+ });
217
+ });
218
+
219
+ test('journey ignore:false still counts errors', async () => {
220
+ await withOpenStep(
221
+ () => {
222
+ status = 500;
223
+ key = '/flaky/x';
224
+ subject();
225
+ },
226
+ {
227
+ ...baseDef,
228
+ requestTimeoutMs: 5_000,
229
+ endpoints: [{ match: '/flaky', ignore: false }],
230
+ }
231
+ );
232
+
233
+ expect(lastJourney(emitMock).reason).toBe('request-error');
234
+ });
235
+ });
236
+
237
+ describe('when only app-wide endpoint policies apply', () => {
238
+ beforeEach(() => {
239
+ configureJourney({
240
+ requestTimeouts: {
241
+ defaultMs: 4_000,
242
+ endpoints: [
243
+ { match: '/ignored', ignore: true },
244
+ { match: '/tight', timeoutMs: 30 },
245
+ ],
246
+ },
247
+ });
248
+ });
249
+
250
+ test('honors app ignore', async () => {
251
+ await withOpenStep(() => {
252
+ status = 500;
253
+ key = '/ignored/x';
254
+ subject();
255
+ });
256
+ completeJourney(baseDef.name);
257
+
258
+ expect(lastJourney(emitMock).outcome).toBe('good');
259
+ });
260
+
261
+ test('honors app timeoutMs', async () => {
262
+ await withOpenStep(() => {
263
+ status = 200;
264
+ durationMs = 100;
265
+ key = '/tight/x';
266
+ subject();
267
+ });
268
+
269
+ expect(lastJourney(emitMock).reason).toBe('request-latency');
270
+ });
271
+ });
272
+
273
+ describe('when app endpoint timeoutMs is 0', () => {
274
+ beforeEach(() => {
275
+ configureJourney({
276
+ requestTimeouts: {
277
+ defaultMs: 40,
278
+ endpoints: [{ match: '/loose', timeoutMs: 0 }],
279
+ },
280
+ });
281
+ });
282
+
283
+ test('falls through to defaultMs', async () => {
284
+ startJourney(baseDef);
285
+
286
+ await journeyStep(baseDef.name, 'do-work', step => {
287
+ stepRef = resolveJourneyStep((step.tag() as { journeyStep: unknown }).journeyStep);
288
+ status = 200;
289
+ durationMs = 100;
290
+ key = '/loose/x';
291
+ subject();
292
+ });
293
+
294
+ expect(lastJourney(emitMock).reason).toBe('request-latency');
295
+ });
296
+ });
297
+
298
+ describe('when the journey has no requestTimeoutMs', () => {
299
+ beforeEach(() => {
300
+ configureJourney({ requestTimeouts: { defaultMs: 40, endpoints: [] } });
301
+ });
302
+
303
+ test('uses the app defaultMs', async () => {
304
+ startJourney(baseDef);
305
+
306
+ await journeyStep(baseDef.name, 'do-work', step => {
307
+ stepRef = resolveJourneyStep((step.tag() as { journeyStep: unknown }).journeyStep);
308
+ status = 200;
309
+ durationMs = 100;
310
+ key = '/any';
311
+ subject();
312
+ });
313
+
314
+ expect(lastJourney(emitMock).reason).toBe('request-latency');
315
+ });
316
+ });
317
+
318
+ describe('against a closed journey step ref', () => {
319
+ let closedRef: JourneyStepRef | undefined;
320
+
321
+ beforeEach(async () => {
322
+ await withOpenStep(ref => {
323
+ closedRef = ref;
324
+ });
325
+ completeJourney(baseDef.name);
326
+ emitMock.mockClear();
327
+ stepRef = closedRef;
328
+ status = 500;
329
+ });
330
+
331
+ test('is a no-op', () => {
332
+ subject();
333
+
334
+ expect(emitMock).not.toHaveBeenCalled();
335
+ });
336
+ });
337
+ });