@servicetitan/journey 3.0.1 → 4.1.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.
- package/README.md +35 -0
- package/dist/__tests__/core/persist.test.d.ts +2 -0
- package/dist/__tests__/core/persist.test.d.ts.map +1 -0
- package/dist/__tests__/integrations/request-correlation.test.d.ts +2 -0
- package/dist/__tests__/integrations/request-correlation.test.d.ts.map +1 -0
- package/dist/__tests__/sinks/kibana.test.d.ts +2 -0
- package/dist/__tests__/sinks/kibana.test.d.ts.map +1 -0
- package/dist/core/api.d.ts +13 -1
- package/dist/core/api.d.ts.map +1 -1
- package/dist/core/api.js +14 -0
- package/dist/core/api.js.map +1 -1
- package/dist/core/index.d.ts +2 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -1
- package/dist/core/index.js.map +1 -1
- package/dist/core/persist.d.ts +19 -0
- package/dist/core/persist.d.ts.map +1 -0
- package/dist/core/persist.js +111 -0
- package/dist/core/persist.js.map +1 -0
- package/dist/core/request-correlation.d.ts +6 -0
- package/dist/core/request-correlation.d.ts.map +1 -0
- package/dist/core/request-correlation.js +15 -0
- package/dist/core/request-correlation.js.map +1 -0
- package/dist/core/runtime.d.ts +16 -1
- package/dist/core/runtime.d.ts.map +1 -1
- package/dist/core/runtime.js +74 -23
- package/dist/core/runtime.js.map +1 -1
- package/dist/core/sanitize.d.ts.map +1 -1
- package/dist/core/sanitize.js +36 -3
- package/dist/core/sanitize.js.map +1 -1
- package/dist/core/types.d.ts +42 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/integrations/axios.d.ts.map +1 -1
- package/dist/integrations/axios.js +2 -1
- package/dist/integrations/axios.js.map +1 -1
- package/dist/integrations/http-report.d.ts +2 -0
- package/dist/integrations/http-report.d.ts.map +1 -1
- package/dist/integrations/http-report.js +17 -2
- package/dist/integrations/http-report.js.map +1 -1
- package/dist/integrations/jquery/instrument.d.ts +2 -0
- package/dist/integrations/jquery/instrument.d.ts.map +1 -1
- package/dist/integrations/jquery/instrument.js +31 -4
- package/dist/integrations/jquery/instrument.js.map +1 -1
- package/dist/integrations/request-correlation.d.ts +5 -0
- package/dist/integrations/request-correlation.d.ts.map +1 -0
- package/dist/integrations/request-correlation.js +65 -0
- package/dist/integrations/request-correlation.js.map +1 -0
- package/dist/sinks/datadog.d.ts.map +1 -1
- package/dist/sinks/datadog.js +18 -0
- package/dist/sinks/datadog.js.map +1 -1
- package/dist/sinks/kibana.d.ts +4 -0
- package/dist/sinks/kibana.d.ts.map +1 -0
- package/dist/sinks/kibana.js +20 -0
- package/dist/sinks/kibana.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/core/persist.test.ts +496 -0
- package/src/__tests__/integrations/axios.test.ts +38 -1
- package/src/__tests__/integrations/jquery.test.ts +94 -1
- package/src/__tests__/integrations/request-correlation.test.ts +315 -0
- package/src/__tests__/sinks/kibana.test.ts +110 -0
- package/src/core/api.ts +19 -0
- package/src/core/index.ts +4 -0
- package/src/core/persist.ts +131 -0
- package/src/core/request-correlation.ts +32 -0
- package/src/core/runtime.ts +69 -2
- package/src/core/sanitize.ts +41 -3
- package/src/core/types.ts +44 -0
- package/src/index.ts +4 -1
- package/src/integrations/axios.ts +1 -0
- package/src/integrations/http-report.ts +33 -14
- package/src/integrations/jquery/instrument.ts +37 -5
- package/src/integrations/request-correlation.ts +90 -0
- package/src/sinks/datadog.ts +19 -0
- package/src/sinks/kibana.ts +24 -0
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
/* eslint-disable @typescript-eslint/naming-convention -- st_journey_id is the deliberate snake_case correlation tag/cookie name */
|
|
3
|
+
import {
|
|
4
|
+
baseJourneyDef,
|
|
5
|
+
lastJourney,
|
|
6
|
+
useJourneyTestRuntime,
|
|
7
|
+
} from '../../__test-utils__/test-runtime';
|
|
8
|
+
import {
|
|
9
|
+
completeJourney,
|
|
10
|
+
defineJourney,
|
|
11
|
+
failJourney,
|
|
12
|
+
journeyStep,
|
|
13
|
+
restoreJourney,
|
|
14
|
+
serializeJourneyState,
|
|
15
|
+
} from '../../core';
|
|
16
|
+
import { moduleRuntime } from '../../core/runtime';
|
|
17
|
+
import { JourneyStepStamp } from '../../core/step-tag';
|
|
18
|
+
|
|
19
|
+
type SerializedJourneyState = NonNullable<ReturnType<typeof serializeJourneyState>>;
|
|
20
|
+
|
|
21
|
+
const emitMock = jest.fn();
|
|
22
|
+
const baseDef = {
|
|
23
|
+
...baseJourneyDef,
|
|
24
|
+
name: 'persist-api',
|
|
25
|
+
timeoutMs: 1_000,
|
|
26
|
+
expected: ['password', 'mfa', 'client-ready'] as const,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
describe('[journey] persist (serialize / restore)', () => {
|
|
30
|
+
useJourneyTestRuntime(emitMock);
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
jest.useFakeTimers();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
jest.useRealTimers();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('serializeJourneyState', () => {
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
moduleRuntime.startJourney({ ...baseDef, tags: { st_journey_id: 'abc-123' } });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const subject = () => {
|
|
46
|
+
let snapshot: SerializedJourneyState | null = null;
|
|
47
|
+
return journeyStep(baseDef.name, 'password', step => {
|
|
48
|
+
step.setAttribute('channel', 'web');
|
|
49
|
+
snapshot = serializeJourneyState(baseDef.name);
|
|
50
|
+
return snapshot;
|
|
51
|
+
}).then(() => snapshot as SerializedJourneyState);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
test('projects steps to the finish() shape (name/startMs/durationMs/outcome)', async () => {
|
|
55
|
+
const snapshot = await subject();
|
|
56
|
+
|
|
57
|
+
expect(snapshot.steps).toHaveLength(1);
|
|
58
|
+
const step = snapshot.steps[0];
|
|
59
|
+
expect(step.name).toBe('password');
|
|
60
|
+
expect(step.startMs).toBe(0);
|
|
61
|
+
expect(step.durationMs).toBeGreaterThanOrEqual(0);
|
|
62
|
+
expect(step.outcome).toBe('good');
|
|
63
|
+
expect(step.attributes).toEqual({ channel: 'web' });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('carries stJourneyId, startedAtEpoch, verdict, reason, tags', async () => {
|
|
67
|
+
const snapshot = await subject();
|
|
68
|
+
|
|
69
|
+
expect(snapshot.stJourneyId).toBe('abc-123');
|
|
70
|
+
expect(typeof snapshot.startedAtEpoch).toBe('number');
|
|
71
|
+
expect(snapshot.verdict).toBe('good');
|
|
72
|
+
expect(snapshot.reason).toBeNull();
|
|
73
|
+
expect(snapshot.tags.st_journey_id).toBe('abc-123');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('does not close the journey', async () => {
|
|
77
|
+
await subject();
|
|
78
|
+
|
|
79
|
+
expect(moduleRuntime.getOpenJourney(baseDef.name)).toBeDefined();
|
|
80
|
+
expect(emitMock).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('returns null when no journey is open', () => {
|
|
84
|
+
expect(serializeJourneyState('not-open')).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('strips non-serializable fields (no timer / engine / config / circular journey)', async () => {
|
|
88
|
+
const snapshot = await subject();
|
|
89
|
+
const json = JSON.stringify(snapshot);
|
|
90
|
+
|
|
91
|
+
expect(json).toContain('"steps"');
|
|
92
|
+
// The serialized shape is a plain projection — no engine/config/timer keys.
|
|
93
|
+
expect(snapshot).not.toHaveProperty('timer');
|
|
94
|
+
expect(snapshot).not.toHaveProperty('config');
|
|
95
|
+
expect(snapshot).not.toHaveProperty('JOURNEY_ENGINE');
|
|
96
|
+
expect(snapshot.steps[0]).not.toHaveProperty('journey');
|
|
97
|
+
expect(snapshot.steps[0]).not.toHaveProperty('attributedRequestKeys');
|
|
98
|
+
expect(snapshot.steps[0]).not.toHaveProperty('onHttpAborted');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('snapshots a real elapsed duration for in-flight steps (durationMs is 0 until finally)', async () => {
|
|
102
|
+
moduleRuntime.startJourney({ ...baseDef, tags: { st_journey_id: 'inflight' } });
|
|
103
|
+
let snapshot: SerializedJourneyState | null = null;
|
|
104
|
+
await journeyStep(baseDef.name, 'password', () => {
|
|
105
|
+
/*
|
|
106
|
+
* Advance the clock while the step is still open — its durationMs is 0
|
|
107
|
+
* until journeyStep's finally block runs, so serialize must compute it.
|
|
108
|
+
*/
|
|
109
|
+
jest.advanceTimersByTime(50);
|
|
110
|
+
snapshot = serializeJourneyState(baseDef.name);
|
|
111
|
+
});
|
|
112
|
+
expect(snapshot).not.toBeNull();
|
|
113
|
+
expect(snapshot!.steps).toHaveLength(1);
|
|
114
|
+
expect(snapshot!.steps[0].durationMs).toBeGreaterThanOrEqual(50);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('restoreJourney', () => {
|
|
119
|
+
const buildSnapshot = async (
|
|
120
|
+
overrides: Partial<SerializedJourneyState> = {}
|
|
121
|
+
): Promise<SerializedJourneyState> => {
|
|
122
|
+
moduleRuntime.startJourney({ ...baseDef, tags: { st_journey_id: 'xyz' } });
|
|
123
|
+
await journeyStep(baseDef.name, 'password', step => {
|
|
124
|
+
step.setAttribute('channel', 'web');
|
|
125
|
+
});
|
|
126
|
+
const snap = serializeJourneyState(baseDef.name) as SerializedJourneyState;
|
|
127
|
+
// Close the original so the runtime is clean for restore.
|
|
128
|
+
moduleRuntime.reset({
|
|
129
|
+
sinks: [emitMock],
|
|
130
|
+
slowRequests: { defaultMs: 4_000, endpoints: [] },
|
|
131
|
+
});
|
|
132
|
+
emitMock.mockClear();
|
|
133
|
+
return { ...snap, ...overrides };
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
test('rebases startedAt to this page-load timeline', async () => {
|
|
137
|
+
const snapshot = await buildSnapshot();
|
|
138
|
+
const before = globalThis.performance.now();
|
|
139
|
+
const restored = restoreJourney(snapshot, baseDef);
|
|
140
|
+
const after = globalThis.performance.now();
|
|
141
|
+
|
|
142
|
+
expect(restored).toBe(true);
|
|
143
|
+
const journey = moduleRuntime.getOpenJourney(baseDef.name);
|
|
144
|
+
expect(journey).toBeDefined();
|
|
145
|
+
expect(journey!.startedAt).toBeGreaterThanOrEqual(before);
|
|
146
|
+
expect(journey!.startedAt).toBeLessThanOrEqual(after);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('re-links step.journey to the restored journey', async () => {
|
|
150
|
+
const snapshot = await buildSnapshot();
|
|
151
|
+
restoreJourney(snapshot, baseDef);
|
|
152
|
+
|
|
153
|
+
const journey = moduleRuntime.getOpenJourney(baseDef.name);
|
|
154
|
+
expect(journey!.steps).toHaveLength(1);
|
|
155
|
+
expect(journey!.steps[0].journey).toBe(journey);
|
|
156
|
+
expect(journey!.steps[0].name).toBe('password');
|
|
157
|
+
expect(journey!.steps[0].attributes.channel).toBe('web');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('re-arms the countdown with the remaining budget', async () => {
|
|
161
|
+
/*
|
|
162
|
+
* Build a snapshot whose startedAtEpoch is 200ms in the past, with a
|
|
163
|
+
* 1_000ms journey budget → ~800ms remaining.
|
|
164
|
+
*/
|
|
165
|
+
const snapshot = await buildSnapshot({
|
|
166
|
+
startedAtEpoch: Date.now() - 200,
|
|
167
|
+
});
|
|
168
|
+
restoreJourney(snapshot, { ...baseDef, timeoutMs: 1_000 });
|
|
169
|
+
|
|
170
|
+
// Advance past the remaining budget.
|
|
171
|
+
await jest.advanceTimersByTimeAsync(900);
|
|
172
|
+
|
|
173
|
+
expect(emitMock).toHaveBeenCalledTimes(1);
|
|
174
|
+
expect(lastJourney(emitMock).outcome).toBe('bad');
|
|
175
|
+
expect(lastJourney(emitMock).reason).toBe('journey-timeout');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('preserves st_journey_id tag across round-trip', async () => {
|
|
179
|
+
const snapshot = await buildSnapshot();
|
|
180
|
+
restoreJourney(snapshot, baseDef);
|
|
181
|
+
|
|
182
|
+
const journey = moduleRuntime.getOpenJourney(baseDef.name);
|
|
183
|
+
expect(journey!.tags.st_journey_id).toBe('xyz');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('restores a failed step with reason and httpStatus', async () => {
|
|
187
|
+
const snapshot = await buildSnapshot({
|
|
188
|
+
steps: [
|
|
189
|
+
{
|
|
190
|
+
name: 'password',
|
|
191
|
+
startMs: 0,
|
|
192
|
+
durationMs: 23,
|
|
193
|
+
outcome: 'bad',
|
|
194
|
+
reason: 'request-error',
|
|
195
|
+
httpStatus: 500,
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
restoreJourney(snapshot, baseDef);
|
|
201
|
+
|
|
202
|
+
const step = moduleRuntime.getOpenJourney(baseDef.name)!.steps[0];
|
|
203
|
+
expect(step.outcome).toBe('bad');
|
|
204
|
+
expect(step.reason).toBe('request-error');
|
|
205
|
+
expect(step.httpStatus).toBe(500);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('falls back to stJourneyId when tags.st_journey_id is absent', async () => {
|
|
209
|
+
const snapshot = await buildSnapshot({
|
|
210
|
+
stJourneyId: 'orphan-id',
|
|
211
|
+
tags: {},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
restoreJourney(snapshot, baseDef);
|
|
215
|
+
|
|
216
|
+
const journey = moduleRuntime.getOpenJourney(baseDef.name);
|
|
217
|
+
expect(journey!.tags.st_journey_id).toBe('orphan-id');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('returns false and emits bad when budget exhausted in transit (explicit timeout)', async () => {
|
|
221
|
+
const snapshot = await buildSnapshot({
|
|
222
|
+
startedAtEpoch: Date.now() - 2_000,
|
|
223
|
+
});
|
|
224
|
+
const restored = restoreJourney(snapshot, { ...baseDef, timeoutMs: 1_000 });
|
|
225
|
+
|
|
226
|
+
expect(restored).toBe(false);
|
|
227
|
+
expect(emitMock).toHaveBeenCalledTimes(1);
|
|
228
|
+
expect(lastJourney(emitMock).outcome).toBe('bad');
|
|
229
|
+
expect(lastJourney(emitMock).reason).toBe('journey-timeout');
|
|
230
|
+
expect(moduleRuntime.getOpenJourney(baseDef.name)).toBeUndefined();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('returns false when a journey under that name is already open', async () => {
|
|
234
|
+
const snapshot = await buildSnapshot();
|
|
235
|
+
moduleRuntime.startJourney(baseDef);
|
|
236
|
+
emitMock.mockClear();
|
|
237
|
+
|
|
238
|
+
const restored = restoreJourney(snapshot, baseDef);
|
|
239
|
+
|
|
240
|
+
expect(restored).toBe(false);
|
|
241
|
+
expect(emitMock).not.toHaveBeenCalled();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('emits excluded with journey-idle-timeout when idle budget exhausted in transit (non-explicit timeout)', async () => {
|
|
245
|
+
/*
|
|
246
|
+
* Idle timeout = timeoutMs omitted → non-explicit, ms = defaultJourneyIdleMs (15m).
|
|
247
|
+
* The buildSnapshot uses baseDef (timeoutMs: 1_000, explicit) — override the
|
|
248
|
+
* serialized startedAtEpoch so the restore sees the journey as ~15m old,
|
|
249
|
+
* and restore with a config that OMITS timeoutMs so the in-transit check
|
|
250
|
+
* takes the non-explicit (excluded / journey-idle-timeout) branch.
|
|
251
|
+
*/
|
|
252
|
+
const snapshot = await buildSnapshot({
|
|
253
|
+
startedAtEpoch: Date.now() - moduleRuntime.getDefaultJourneyIdleMs() - 1_000,
|
|
254
|
+
});
|
|
255
|
+
const idleDef = {
|
|
256
|
+
name: baseDef.name,
|
|
257
|
+
team: baseDef.team,
|
|
258
|
+
group: baseDef.group,
|
|
259
|
+
service: baseDef.service,
|
|
260
|
+
expected: baseDef.expected,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const restored = restoreJourney(snapshot, idleDef);
|
|
264
|
+
|
|
265
|
+
expect(restored).toBe(false);
|
|
266
|
+
expect(emitMock).toHaveBeenCalledTimes(1);
|
|
267
|
+
expect(lastJourney(emitMock).outcome).toBe('excluded');
|
|
268
|
+
expect(lastJourney(emitMock).reason).toBe('journey-idle-timeout');
|
|
269
|
+
expect(moduleRuntime.getOpenJourney(baseDef.name)).toBeUndefined();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('skips the in-transit timeout check when timeoutMs is 0 (countdown disabled)', async () => {
|
|
273
|
+
/*
|
|
274
|
+
* timeoutMs: 0 is explicit and disables the countdown — restoreJourney must
|
|
275
|
+
* register the journey without arming a timer or emitting.
|
|
276
|
+
*/
|
|
277
|
+
const snapshot = await buildSnapshot({
|
|
278
|
+
startedAtEpoch: Date.now() - 999_999,
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
const restored = restoreJourney(snapshot, { ...baseDef, timeoutMs: 0 });
|
|
282
|
+
|
|
283
|
+
expect(restored).toBe(true);
|
|
284
|
+
expect(emitMock).not.toHaveBeenCalled();
|
|
285
|
+
const journey = moduleRuntime.getOpenJourney(baseDef.name);
|
|
286
|
+
expect(journey).toBeDefined();
|
|
287
|
+
expect(journey!.timeoutMs).toBe(0);
|
|
288
|
+
expect(journey!.timeoutExplicit).toBe(true);
|
|
289
|
+
expect(journey!.timer).toBeNull();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('survives a JSON round-trip (JSON.parse(JSON.stringify(snapshot)))', async () => {
|
|
293
|
+
const snapshot = await buildSnapshot();
|
|
294
|
+
const jsonRoundTripped = JSON.parse(JSON.stringify(snapshot)) as SerializedJourneyState;
|
|
295
|
+
|
|
296
|
+
const restored = restoreJourney(jsonRoundTripped, baseDef);
|
|
297
|
+
|
|
298
|
+
expect(restored).toBe(true);
|
|
299
|
+
const journey = moduleRuntime.getOpenJourney(baseDef.name);
|
|
300
|
+
expect(journey).toBeDefined();
|
|
301
|
+
expect(journey!.tags.st_journey_id).toBe('xyz');
|
|
302
|
+
expect(journey!.steps).toHaveLength(1);
|
|
303
|
+
expect(journey!.steps[0].attributes.channel).toBe('web');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('second concurrent restoreJourney with the same snapshot returns false (already open)', async () => {
|
|
307
|
+
const snapshot = await buildSnapshot();
|
|
308
|
+
|
|
309
|
+
const first = restoreJourney(snapshot, baseDef);
|
|
310
|
+
emitMock.mockClear();
|
|
311
|
+
const second = restoreJourney(snapshot, baseDef);
|
|
312
|
+
|
|
313
|
+
expect(first).toBe(true);
|
|
314
|
+
expect(second).toBe(false);
|
|
315
|
+
expect(emitMock).not.toHaveBeenCalled();
|
|
316
|
+
expect(moduleRuntime.getOpenJourney(baseDef.name)).toBeDefined();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('caller config tags override serialized tags (precedence)', async () => {
|
|
320
|
+
const snapshot = await buildSnapshot({
|
|
321
|
+
tags: { st_journey_id: 'xyz', env: 'dev', region: 'us' },
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
restoreJourney(snapshot, {
|
|
325
|
+
...baseDef,
|
|
326
|
+
tags: { st_journey_id: 'xyz', env: 'prod', owner: 'team-a' },
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
const journey = moduleRuntime.getOpenJourney(baseDef.name);
|
|
330
|
+
expect(journey).toBeDefined();
|
|
331
|
+
// config tag wins over serialized tag
|
|
332
|
+
expect(journey!.tags.env).toBe('prod');
|
|
333
|
+
// config-only tag is added
|
|
334
|
+
expect(journey!.tags.owner).toBe('team-a');
|
|
335
|
+
// serialized-only tag is preserved
|
|
336
|
+
expect(journey!.tags.region).toBe('us');
|
|
337
|
+
// st_journey_id preserved
|
|
338
|
+
expect(journey!.tags.st_journey_id).toBe('xyz');
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('restores up to MAX_ATTRIBUTE_KEYS (64) step attributes, dropping excess', async () => {
|
|
342
|
+
const attrs: Record<string, string> = {};
|
|
343
|
+
for (let i = 0; i < 70; i++) {
|
|
344
|
+
attrs[`k${i}`] = 'v';
|
|
345
|
+
}
|
|
346
|
+
const snapshot = await buildSnapshot({
|
|
347
|
+
steps: [
|
|
348
|
+
{
|
|
349
|
+
name: 'password',
|
|
350
|
+
startMs: 0,
|
|
351
|
+
durationMs: 23,
|
|
352
|
+
outcome: 'good',
|
|
353
|
+
attributes: attrs,
|
|
354
|
+
},
|
|
355
|
+
],
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
restoreJourney(snapshot, baseDef);
|
|
359
|
+
|
|
360
|
+
const step = moduleRuntime.getOpenJourney(baseDef.name)!.steps[0];
|
|
361
|
+
// MAX_ATTRIBUTE_KEYS (64), not MAX_TAG_KEYS (32) — matches setSafeAttribute.
|
|
362
|
+
expect(Object.keys(step.attributes)).toHaveLength(64);
|
|
363
|
+
expect(step.attributes.k0).toBe('v');
|
|
364
|
+
expect(step.attributes.k63).toBe('v');
|
|
365
|
+
expect(step.attributes.k64).toBeUndefined();
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test('serializeJourneyState returns null after the original journey has completed', async () => {
|
|
369
|
+
moduleRuntime.startJourney({ ...baseDef, tags: { st_journey_id: 'done' } });
|
|
370
|
+
await journeyStep(baseDef.name, 'password', () => {});
|
|
371
|
+
completeJourney(baseDef.name);
|
|
372
|
+
emitMock.mockClear();
|
|
373
|
+
|
|
374
|
+
expect(serializeJourneyState(baseDef.name)).toBeNull();
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe('defineJourney().start() after restore', () => {
|
|
379
|
+
test('excludes the restored journey and starts fresh (consumer must not call start() after a successful restore)', async () => {
|
|
380
|
+
moduleRuntime.startJourney({ ...baseDef, tags: { st_journey_id: 'rs-1' } });
|
|
381
|
+
await journeyStep(baseDef.name, 'password', () => {});
|
|
382
|
+
const snapshot = serializeJourneyState(baseDef.name) as SerializedJourneyState;
|
|
383
|
+
moduleRuntime.reset({
|
|
384
|
+
sinks: [emitMock],
|
|
385
|
+
slowRequests: { defaultMs: 4_000, endpoints: [] },
|
|
386
|
+
});
|
|
387
|
+
emitMock.mockClear();
|
|
388
|
+
|
|
389
|
+
const restored = restoreJourney(snapshot, baseDef);
|
|
390
|
+
expect(restored).toBe(true);
|
|
391
|
+
|
|
392
|
+
// defineJourney().start() excludes the open restored journey and starts fresh.
|
|
393
|
+
defineJourney(baseDef).start({ extra: 'tag' });
|
|
394
|
+
|
|
395
|
+
expect(emitMock).toHaveBeenCalledTimes(1);
|
|
396
|
+
expect(lastJourney(emitMock).outcome).toBe('excluded');
|
|
397
|
+
const journey = moduleRuntime.getOpenJourney(baseDef.name);
|
|
398
|
+
expect(journey).toBeDefined();
|
|
399
|
+
expect(journey!.closed).toBe(false);
|
|
400
|
+
expect(journey!.steps).toHaveLength(0);
|
|
401
|
+
expect(journey!.tags.extra).toBe('tag');
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe('round-trip: serialize → restore → add step → complete', () => {
|
|
406
|
+
test('all steps (original + new) appear in the final emission', async () => {
|
|
407
|
+
// Page 1: start + password step + serialize.
|
|
408
|
+
moduleRuntime.startJourney({ ...baseDef, tags: { st_journey_id: 'rt-1' } });
|
|
409
|
+
await journeyStep(baseDef.name, 'password', step => {
|
|
410
|
+
step.setAttribute('channel', 'web');
|
|
411
|
+
});
|
|
412
|
+
const snapshot = serializeJourneyState(baseDef.name) as SerializedJourneyState;
|
|
413
|
+
expect(snapshot).not.toBeNull();
|
|
414
|
+
// Simulate page navigation: reset the runtime.
|
|
415
|
+
moduleRuntime.reset({
|
|
416
|
+
sinks: [emitMock],
|
|
417
|
+
slowRequests: { defaultMs: 4_000, endpoints: [] },
|
|
418
|
+
});
|
|
419
|
+
emitMock.mockClear();
|
|
420
|
+
|
|
421
|
+
// Page 2: restore + mfa step + complete.
|
|
422
|
+
const restored = restoreJourney(snapshot, baseDef);
|
|
423
|
+
expect(restored).toBe(true);
|
|
424
|
+
await journeyStep(baseDef.name, 'mfa', step => {
|
|
425
|
+
step.setAttribute('method', 'sms');
|
|
426
|
+
});
|
|
427
|
+
completeJourney(baseDef.name);
|
|
428
|
+
|
|
429
|
+
expect(emitMock).toHaveBeenCalledTimes(1);
|
|
430
|
+
const event = lastJourney(emitMock);
|
|
431
|
+
expect(event.outcome).toBe('good');
|
|
432
|
+
expect(event.steps).toHaveLength(2);
|
|
433
|
+
expect(event.steps[0].name).toBe('password');
|
|
434
|
+
expect(event.steps[0].attributes).toEqual({ channel: 'web' });
|
|
435
|
+
expect(event.steps[1].name).toBe('mfa');
|
|
436
|
+
expect(event.steps[1].attributes).toEqual({ method: 'sms' });
|
|
437
|
+
expect(event.tags.st_journey_id).toBe('rt-1');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test('a restored journey can be failed', async () => {
|
|
441
|
+
moduleRuntime.startJourney({ ...baseDef, tags: { st_journey_id: 'rt-2' } });
|
|
442
|
+
await journeyStep(baseDef.name, 'password', () => {});
|
|
443
|
+
const snapshot = serializeJourneyState(baseDef.name) as SerializedJourneyState;
|
|
444
|
+
moduleRuntime.reset({
|
|
445
|
+
sinks: [emitMock],
|
|
446
|
+
slowRequests: { defaultMs: 4_000, endpoints: [] },
|
|
447
|
+
});
|
|
448
|
+
emitMock.mockClear();
|
|
449
|
+
|
|
450
|
+
const restored = restoreJourney(snapshot, baseDef);
|
|
451
|
+
expect(restored).toBe(true);
|
|
452
|
+
failJourney(baseDef.name, 'mfa-rejected');
|
|
453
|
+
|
|
454
|
+
expect(emitMock).toHaveBeenCalledTimes(1);
|
|
455
|
+
expect(lastJourney(emitMock).outcome).toBe('bad');
|
|
456
|
+
expect(lastJourney(emitMock).reason).toBe('mfa-rejected');
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test('a restored journey can record a stamped step for HTTP attribution', async () => {
|
|
460
|
+
moduleRuntime.startJourney({ ...baseDef, tags: { st_journey_id: 'rt-3' } });
|
|
461
|
+
await journeyStep(baseDef.name, 'password', () => {});
|
|
462
|
+
const snapshot = serializeJourneyState(baseDef.name) as SerializedJourneyState;
|
|
463
|
+
moduleRuntime.reset({
|
|
464
|
+
sinks: [emitMock],
|
|
465
|
+
slowRequests: { defaultMs: 4_000, endpoints: [] },
|
|
466
|
+
});
|
|
467
|
+
emitMock.mockClear();
|
|
468
|
+
|
|
469
|
+
restoreJourney(snapshot, baseDef);
|
|
470
|
+
let stamp: JourneyStepStamp | undefined;
|
|
471
|
+
await journeyStep(baseDef.name, 'mfa', step => {
|
|
472
|
+
stamp = step.stamp();
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
expect(stamp).toBeInstanceOf(JourneyStepStamp);
|
|
476
|
+
// The stamp's step should belong to the restored journey.
|
|
477
|
+
const journey = moduleRuntime.getOpenJourney(baseDef.name);
|
|
478
|
+
expect(journey).toBeDefined();
|
|
479
|
+
expect(journey!.steps[1].name).toBe('mfa');
|
|
480
|
+
completeJourney(baseDef.name);
|
|
481
|
+
expect(lastJourney(emitMock).steps).toHaveLength(2);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
describe('defineJourney + serializeJourneyState interop', () => {
|
|
486
|
+
test('a handle-started journey can be serialized', async () => {
|
|
487
|
+
const handle = defineJourney({ ...baseDef, tags: { st_journey_id: 'h-1' } });
|
|
488
|
+
handle.start();
|
|
489
|
+
await journeyStep(baseDef.name, 'password', () => {});
|
|
490
|
+
const snapshot = serializeJourneyState(baseDef.name);
|
|
491
|
+
expect(snapshot).not.toBeNull();
|
|
492
|
+
expect(snapshot!.stJourneyId).toBe('h-1');
|
|
493
|
+
expect(snapshot!.steps).toHaveLength(1);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** @jest-environment jsdom */
|
|
2
|
-
import { useJourneyTestRuntime } from '../../__test-utils__/test-runtime';
|
|
2
|
+
import { baseJourneyDef, useJourneyTestRuntime } from '../../__test-utils__/test-runtime';
|
|
3
3
|
import { completeJourney, journeyStep } from '../../core';
|
|
4
4
|
import { moduleRuntime } from '../../core/runtime';
|
|
5
5
|
import { getSharedPolicies } from '../../global';
|
|
@@ -614,3 +614,40 @@ describe('[journey] instrumentAxios', () => {
|
|
|
614
614
|
expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
|
|
615
615
|
});
|
|
616
616
|
});
|
|
617
|
+
|
|
618
|
+
describe('[journey] Axios response correlation', () => {
|
|
619
|
+
useJourneyTestRuntime(emitMock);
|
|
620
|
+
test.each(['plain', 'get'])(
|
|
621
|
+
'captures %s headers and preserves the rejection identity',
|
|
622
|
+
async shape => {
|
|
623
|
+
const axios = createAxiosMock();
|
|
624
|
+
axios.defaults.baseURL = 'https://go.servicetitan.com';
|
|
625
|
+
const restore = instrumentAxios(axios);
|
|
626
|
+
const headers =
|
|
627
|
+
shape === 'plain'
|
|
628
|
+
? { 'cf-ray': '0123456789abcdef-IAD' }
|
|
629
|
+
: { get: jest.fn().mockReturnValue('0123456789abcdef-IAD') };
|
|
630
|
+
let error: Error;
|
|
631
|
+
try {
|
|
632
|
+
await expect(
|
|
633
|
+
journeyStep({ ...baseJourneyDef, name: 'axios-ray' }, 'save', async step => {
|
|
634
|
+
const config = axios.requestHandlers[0]({
|
|
635
|
+
url: '/test',
|
|
636
|
+
stamp: step.stamp(),
|
|
637
|
+
});
|
|
638
|
+
error = Object.assign(new Error('server error'), {
|
|
639
|
+
config,
|
|
640
|
+
response: { config, status: 500, headers },
|
|
641
|
+
});
|
|
642
|
+
await axios.responseErrHandlers[0](error);
|
|
643
|
+
})
|
|
644
|
+
).rejects.toBe(error!);
|
|
645
|
+
const event = emitMock.mock.calls[0][0].journey;
|
|
646
|
+
expect(event.reason).toBe('request-error');
|
|
647
|
+
expect(event.steps[0].requests[0].rayId).toBe('0123456789abcdef-iad');
|
|
648
|
+
} finally {
|
|
649
|
+
restore();
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
);
|
|
653
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** @jest-environment jsdom */
|
|
2
|
-
import { useJourneyTestRuntime } from '../../__test-utils__/test-runtime';
|
|
2
|
+
import { baseJourneyDef, useJourneyTestRuntime } from '../../__test-utils__/test-runtime';
|
|
3
3
|
import { completeJourney, journeyStep } from '../../core';
|
|
4
4
|
import { moduleRuntime } from '../../core/runtime';
|
|
5
5
|
import { getSharedPolicies } from '../../global';
|
|
@@ -537,3 +537,96 @@ describe('[journey] instrumentJquery', () => {
|
|
|
537
537
|
expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good');
|
|
538
538
|
});
|
|
539
539
|
});
|
|
540
|
+
|
|
541
|
+
describe('[journey] jQuery early response correlation', () => {
|
|
542
|
+
useJourneyTestRuntime(emitMock);
|
|
543
|
+
|
|
544
|
+
function pendingAjax($: ReturnType<typeof createJqueryMock>, stamp: unknown) {
|
|
545
|
+
const callbacks: (() => void)[] = [];
|
|
546
|
+
const xhr = {
|
|
547
|
+
status: 500,
|
|
548
|
+
getResponseHeader: jest.fn().mockReturnValue('0123456789abcdef-IAD'),
|
|
549
|
+
always(callback: () => void) {
|
|
550
|
+
callbacks.push(callback);
|
|
551
|
+
},
|
|
552
|
+
};
|
|
553
|
+
const settings = { url: 'https://go.servicetitan.com/test', stamp };
|
|
554
|
+
for (const prefilter of $.__prefilters) {
|
|
555
|
+
prefilter(settings, settings, xhr);
|
|
556
|
+
}
|
|
557
|
+
return { xhr, settings, callbacks };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
test('captures before a synchronous application failure and does not duplicate at ajaxComplete', async () => {
|
|
561
|
+
const $ = createJqueryMock();
|
|
562
|
+
const restore = instrumentJquery($);
|
|
563
|
+
try {
|
|
564
|
+
await journeyStep({ ...baseJourneyDef, name: 'jq-early' }, 'save', step => {
|
|
565
|
+
const { xhr, settings, callbacks } = pendingAjax($, step.stamp());
|
|
566
|
+
callbacks.forEach(callback => callback());
|
|
567
|
+
expect(emitMock).not.toHaveBeenCalled();
|
|
568
|
+
step.failJourney('application-error');
|
|
569
|
+
for (const complete of $.__completes) {
|
|
570
|
+
complete({}, xhr, settings);
|
|
571
|
+
}
|
|
572
|
+
});
|
|
573
|
+
const event = emitMock.mock.calls[0][0].journey;
|
|
574
|
+
expect(emitMock).toHaveBeenCalledTimes(1);
|
|
575
|
+
expect(event.reason).toBe('application-error');
|
|
576
|
+
expect(event.steps[0].requests).toHaveLength(1);
|
|
577
|
+
expect(event.steps[0].requests[0].rayId).toBe('0123456789abcdef-iad');
|
|
578
|
+
} finally {
|
|
579
|
+
restore();
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test('keeps scoring after application callbacks, as before', async () => {
|
|
584
|
+
const $ = createJqueryMock();
|
|
585
|
+
const restore = instrumentJquery($);
|
|
586
|
+
try {
|
|
587
|
+
await journeyStep({ ...baseJourneyDef, name: 'jq-scoring' }, 'save', step => {
|
|
588
|
+
const { xhr, settings, callbacks } = pendingAjax($, step.stamp());
|
|
589
|
+
callbacks.forEach(callback => callback());
|
|
590
|
+
expect(emitMock).not.toHaveBeenCalled();
|
|
591
|
+
for (const complete of $.__completes) {
|
|
592
|
+
complete({}, xhr, settings);
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
const event = emitMock.mock.calls[0][0].journey;
|
|
596
|
+
expect(event.reason).toBe('request-error');
|
|
597
|
+
expect(event.steps[0].requests).toHaveLength(1);
|
|
598
|
+
} finally {
|
|
599
|
+
restore();
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test('restore prevents early observers from modifying outstanding requests', async () => {
|
|
604
|
+
const $ = createJqueryMock();
|
|
605
|
+
const restore = instrumentJquery($);
|
|
606
|
+
await journeyStep({ ...baseJourneyDef, name: 'jq-restored' }, 'save', step => {
|
|
607
|
+
const { callbacks } = pendingAjax($, step.stamp());
|
|
608
|
+
restore();
|
|
609
|
+
callbacks.forEach(callback => callback());
|
|
610
|
+
step.failJourney('application-error');
|
|
611
|
+
});
|
|
612
|
+
expect(emitMock.mock.calls[0][0].journey.steps[0].requests).toBeUndefined();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test('a throwing header reader cannot stop the application callback', async () => {
|
|
616
|
+
const $ = createJqueryMock();
|
|
617
|
+
const restore = instrumentJquery($);
|
|
618
|
+
try {
|
|
619
|
+
await journeyStep({ ...baseJourneyDef, name: 'jq-blocked' }, 'save', step => {
|
|
620
|
+
const { xhr, callbacks } = pendingAjax($, step.stamp());
|
|
621
|
+
xhr.getResponseHeader.mockImplementation(() => {
|
|
622
|
+
throw new Error('blocked');
|
|
623
|
+
});
|
|
624
|
+
expect(() => callbacks.forEach(callback => callback())).not.toThrow();
|
|
625
|
+
step.failJourney('application-error');
|
|
626
|
+
});
|
|
627
|
+
expect(emitMock.mock.calls[0][0].journey.steps[0].requests).toBeUndefined();
|
|
628
|
+
} finally {
|
|
629
|
+
restore();
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
});
|