@servicetitan/journey 3.0.0 → 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.
@@ -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,14 +1,7 @@
1
1
  /** @jest-environment jsdom */
2
2
  const addAction = jest.fn();
3
- const importedAddAction = jest.fn();
4
3
  const ddRumAddAction = jest.fn();
5
4
 
6
- jest.mock('@datadog/browser-rum', () => ({
7
- datadogRum: {
8
- addAction: (...args: unknown[]) => importedAddAction(...args),
9
- },
10
- }));
11
-
12
5
  import type { JourneyEvent } from '../../core/types';
13
6
  import { ST_JOURNEY_GLOBAL_KEY, type StJourneyGlobal } from '../../global';
14
7
  import { normalizeTagValue, sendToDatadog, setJourneyRum } from '../../sinks/datadog';
@@ -43,7 +36,6 @@ describe('[journey] setJourneyRum / sendToDatadog', () => {
43
36
 
44
37
  beforeEach(() => {
45
38
  addAction.mockClear();
46
- importedAddAction.mockClear();
47
39
  ddRumAddAction.mockClear();
48
40
  clearJourneyRumFallback();
49
41
  delete (globalThis as Record<string, unknown>)[DD_RUM_KEY];
@@ -59,11 +51,8 @@ describe('[journey] setJourneyRum / sendToDatadog', () => {
59
51
  describe('without a shared RUM instance', () => {
60
52
  const subject = () => sendToDatadog(sampleEvent());
61
53
 
62
- test('falls back to the imported datadogRum', () => {
63
- subject();
64
-
65
- expect(importedAddAction).toHaveBeenCalledTimes(1);
66
- expect(importedAddAction.mock.calls[0][0]).toBe('user_journey');
54
+ test('drops the event without throwing', () => {
55
+ expect(subject).not.toThrow();
67
56
  expect(addAction).not.toHaveBeenCalled();
68
57
  });
69
58
  });
@@ -80,7 +69,6 @@ describe('[journey] setJourneyRum / sendToDatadog', () => {
80
69
 
81
70
  expect(ddRumAddAction).toHaveBeenCalledTimes(1);
82
71
  expect(addAction).not.toHaveBeenCalled();
83
- expect(importedAddAction).not.toHaveBeenCalled();
84
72
  });
85
73
  });
86
74
 
@@ -89,7 +77,6 @@ describe('[journey] setJourneyRum / sendToDatadog', () => {
89
77
  (globalThis as Record<string, unknown>)[DD_RUM_KEY] = {};
90
78
 
91
79
  expect(() => sendToDatadog(sampleEvent())).not.toThrow();
92
- expect(importedAddAction).not.toHaveBeenCalled();
93
80
  expect(addAction).not.toHaveBeenCalled();
94
81
  });
95
82
  });
@@ -120,7 +107,6 @@ describe('[journey] setJourneyRum / sendToDatadog', () => {
120
107
  subject();
121
108
 
122
109
  expect(addAction).toHaveBeenCalledTimes(1);
123
- expect(importedAddAction).not.toHaveBeenCalled();
124
110
  expect(warnSpy).not.toHaveBeenCalled();
125
111
  });
126
112
 
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';