@servicetitan/journey 3.0.1 → 4.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.
- package/dist/__tests__/core/persist.test.d.ts +2 -0
- package/dist/__tests__/core/persist.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/runtime.d.ts +16 -1
- package/dist/core/runtime.d.ts.map +1 -1
- package/dist/core/runtime.js +56 -21
- package/dist/core/runtime.js.map +1 -1
- package/dist/core/types.d.ts +25 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/core/types.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/core/persist.test.ts +496 -0
- package/src/core/api.ts +19 -0
- package/src/core/index.ts +3 -0
- package/src/core/persist.ts +131 -0
- package/src/core/runtime.ts +62 -2
- package/src/core/types.ts +26 -0
- package/src/index.ts +3 -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
|
+
});
|
package/src/core/api.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
JourneyHandle,
|
|
6
6
|
JourneyStepOptions,
|
|
7
7
|
JourneyStepTarget,
|
|
8
|
+
SerializedJourneyState,
|
|
8
9
|
StepHandle,
|
|
9
10
|
StepRecord,
|
|
10
11
|
TagValue,
|
|
@@ -157,3 +158,21 @@ export function defineJourney(config: JourneyDef): JourneyHandle {
|
|
|
157
158
|
},
|
|
158
159
|
};
|
|
159
160
|
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Snapshot an open journey for cross-page transfer (e.g. into a cookie). Does
|
|
164
|
+
* NOT close the journey. Returns `null` when no journey is open under `name`.
|
|
165
|
+
*/
|
|
166
|
+
export function serializeJourneyState(name: string): SerializedJourneyState | null {
|
|
167
|
+
return moduleRuntime.serializeJourneyState(name);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Rehydrate a serialized journey onto this runtime. Returns `true` when the
|
|
172
|
+
* journey is now open; `false` when it exhausted its budget in transit (a
|
|
173
|
+
* `bad` `journey-timeout` event is emitted) or when a journey under the
|
|
174
|
+
* same name is already open.
|
|
175
|
+
*/
|
|
176
|
+
export function restoreJourney(serialized: SerializedJourneyState, config: JourneyDef): boolean {
|
|
177
|
+
return moduleRuntime.restoreJourney(serialized, config) !== null;
|
|
178
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -8,6 +8,8 @@ export {
|
|
|
8
8
|
journeyStep,
|
|
9
9
|
reportBackendRequest,
|
|
10
10
|
resolveStamp,
|
|
11
|
+
restoreJourney,
|
|
12
|
+
serializeJourneyState,
|
|
11
13
|
setJourneyTags,
|
|
12
14
|
singleInFlightJourneyStep,
|
|
13
15
|
} from './api';
|
|
@@ -24,6 +26,7 @@ export type {
|
|
|
24
26
|
JourneyStepEvent,
|
|
25
27
|
JourneyStepOptions,
|
|
26
28
|
JourneyStepTarget,
|
|
29
|
+
SerializedJourneyState,
|
|
27
30
|
StepHandle,
|
|
28
31
|
TagValue,
|
|
29
32
|
} from './types';
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import {
|
|
2
|
+
clampMaxSteps,
|
|
3
|
+
finiteSlowRequestMs,
|
|
4
|
+
finiteTimeoutMs,
|
|
5
|
+
MAX_ATTRIBUTE_KEYS,
|
|
6
|
+
MAX_TAG_KEYS,
|
|
7
|
+
resolveJourneyTimeoutMs,
|
|
8
|
+
} from './limits';
|
|
9
|
+
import { assignRecord, emptyRecord } from './sanitize';
|
|
10
|
+
import { bindJourneyEngine, freezeJourneyDef, type JourneyRuntime } from './runtime';
|
|
11
|
+
import {
|
|
12
|
+
JOURNEY_ENGINE,
|
|
13
|
+
type JourneyDef,
|
|
14
|
+
type JourneyState,
|
|
15
|
+
type SerializedJourneyState,
|
|
16
|
+
type StepRecord,
|
|
17
|
+
} from './types';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @internal Project an open {@link JourneyState} into a plain, JSON-safe
|
|
21
|
+
* {@link SerializedJourneyState} that can ride a cookie across page navigations.
|
|
22
|
+
* The step projection mirrors `finish()`; `startedAt` (a `performance.now()`
|
|
23
|
+
* monotonic stamp) is bridged to a wall-clock epoch so it survives the page
|
|
24
|
+
* load where `performance.now()` resets.
|
|
25
|
+
*/
|
|
26
|
+
export function projectJourneyState(journey: JourneyState): SerializedJourneyState {
|
|
27
|
+
const steps = journey.steps.map(s => ({
|
|
28
|
+
name: s.name,
|
|
29
|
+
startMs: Math.round(s.startedAt - journey.startedAt),
|
|
30
|
+
durationMs: s.durationMs || Math.round(globalThis.performance.now() - s.startedAt),
|
|
31
|
+
outcome: s.outcome,
|
|
32
|
+
...(s.reason ? { reason: s.reason } : {}),
|
|
33
|
+
...(s.httpStatus !== undefined ? { httpStatus: s.httpStatus } : {}),
|
|
34
|
+
// Attributes are raw here — final emission sanitization happens in finish().
|
|
35
|
+
...(Object.keys(s.attributes).length ? { attributes: { ...s.attributes } } : {}),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const startedAtEpoch = Date.now() - (globalThis.performance.now() - journey.startedAt);
|
|
39
|
+
// Coerce any non-null st_journey_id tag to a string so a numeric/boolean id survives the round-trip.
|
|
40
|
+
const stJourneyId =
|
|
41
|
+
journey.tags.st_journey_id != null ? String(journey.tags.st_journey_id) : '';
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
stJourneyId,
|
|
45
|
+
startedAtEpoch,
|
|
46
|
+
// Open journeys are never 'excluded' — that verdict is only set inside finish().
|
|
47
|
+
verdict: journey.verdict === 'bad' ? 'bad' : 'good',
|
|
48
|
+
reason: journey.reason,
|
|
49
|
+
steps,
|
|
50
|
+
tags: { ...journey.tags },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @internal Rehydrate a {@link SerializedJourneyState} into a live
|
|
56
|
+
* {@link JourneyState} bound to the supplied runtime. `startedAt` is rebased
|
|
57
|
+
* from the wall-clock epoch back to a `performance.now()` monotonic stamp,
|
|
58
|
+
* `StepRecord[]` is rebuilt with re-linked circular references, and the
|
|
59
|
+
* `JOURNEY_ENGINE` hook is re-bound so HTTP attribution works on the new page.
|
|
60
|
+
*/
|
|
61
|
+
export function deserializeJourneyState(
|
|
62
|
+
serialized: SerializedJourneyState,
|
|
63
|
+
config: JourneyDef,
|
|
64
|
+
api: JourneyRuntime,
|
|
65
|
+
defaultJourneyIdleMs: number
|
|
66
|
+
): JourneyState {
|
|
67
|
+
const tags = emptyRecord();
|
|
68
|
+
// Caller config tags win; serialized tags fill the rest (incl. st_journey_id).
|
|
69
|
+
assignRecord(tags, serialized.tags, MAX_TAG_KEYS);
|
|
70
|
+
assignRecord(tags, config.tags, MAX_TAG_KEYS);
|
|
71
|
+
if (serialized.stJourneyId && tags.st_journey_id === undefined) {
|
|
72
|
+
tags.st_journey_id = serialized.stJourneyId;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const snapshot = freezeJourneyDef(config, tags);
|
|
76
|
+
const timeout = resolveJourneyTimeoutMs(snapshot.timeoutMs, defaultJourneyIdleMs);
|
|
77
|
+
|
|
78
|
+
const startedAt = globalThis.performance.now() - (Date.now() - serialized.startedAtEpoch);
|
|
79
|
+
|
|
80
|
+
const journey: JourneyState = {
|
|
81
|
+
config: snapshot,
|
|
82
|
+
name: snapshot.name,
|
|
83
|
+
team: snapshot.team,
|
|
84
|
+
group: snapshot.group,
|
|
85
|
+
service: snapshot.service,
|
|
86
|
+
verdict: serialized.verdict === 'bad' ? 'bad' : 'good',
|
|
87
|
+
startedAt,
|
|
88
|
+
timeoutMs: timeout.ms,
|
|
89
|
+
timeoutExplicit: timeout.explicit,
|
|
90
|
+
stepTimeoutMs:
|
|
91
|
+
snapshot.stepTimeoutMs !== undefined
|
|
92
|
+
? finiteTimeoutMs(snapshot.stepTimeoutMs)
|
|
93
|
+
: undefined,
|
|
94
|
+
reason: serialized.reason,
|
|
95
|
+
steps: [],
|
|
96
|
+
expected: snapshot.expected ? [...snapshot.expected] : null,
|
|
97
|
+
tags,
|
|
98
|
+
slowRequestMs: finiteSlowRequestMs(snapshot.slowRequestMs),
|
|
99
|
+
endpoints: snapshot.endpoints,
|
|
100
|
+
timer: null,
|
|
101
|
+
closed: false,
|
|
102
|
+
maxSteps: snapshot.maxSteps !== undefined ? clampMaxSteps(snapshot.maxSteps) : undefined,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Rebuild StepRecord[] — re-link the circular `step.journey` reference and rebase `startedAt` from the offset.
|
|
106
|
+
for (const s of serialized.steps) {
|
|
107
|
+
const step: StepRecord = {
|
|
108
|
+
name: s.name,
|
|
109
|
+
startedAt: journey.startedAt + s.startMs,
|
|
110
|
+
durationMs: s.durationMs,
|
|
111
|
+
outcome: s.outcome,
|
|
112
|
+
attributes: emptyRecord(),
|
|
113
|
+
journey,
|
|
114
|
+
};
|
|
115
|
+
if (s.reason !== undefined) {
|
|
116
|
+
step.reason = s.reason;
|
|
117
|
+
}
|
|
118
|
+
if (s.httpStatus !== undefined) {
|
|
119
|
+
step.httpStatus = s.httpStatus;
|
|
120
|
+
}
|
|
121
|
+
if (s.attributes) {
|
|
122
|
+
assignRecord(step.attributes, s.attributes, MAX_ATTRIBUTE_KEYS);
|
|
123
|
+
}
|
|
124
|
+
journey.steps.push(step);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Re-bind the engine so HTTP attribution / fail-fast works on this page.
|
|
128
|
+
journey[JOURNEY_ENGINE] = bindJourneyEngine(api, journey, api.shouldIgnoreRequest);
|
|
129
|
+
|
|
130
|
+
return journey;
|
|
131
|
+
}
|