/** @jest-environment jsdom */ import { baseJourneyDef, lastJourney, useJourneyTestRuntime, } from '../../__test-utils__/test-runtime'; import { completeJourney, failJourney, JourneyStepStamp, journeyStep } from '../../core'; import { moduleRuntime } from '../../core/runtime'; const emitMock = jest.fn(); const baseDef = { ...baseJourneyDef, name: 'step-api' }; const otherDef = { ...baseJourneyDef, name: 'step-other' }; describe('[journey] journeyStep', () => { useJourneyTestRuntime(emitMock); describe('with a candidate list', () => { beforeEach(() => { moduleRuntime.startJourney(baseDef); }); const subject = () => journeyStep(['missing', baseDef.name], 'do-work', step => step.journeyName); test('resolves the first open name', async () => { await expect(subject()).resolves.toBe(baseDef.name); }); }); describe('with multiple open candidates', () => { beforeEach(() => { moduleRuntime.startJourney(otherDef); moduleRuntime.startJourney(baseDef); }); const subject = () => journeyStep([baseDef.name, otherDef.name], 'do-work', step => step.journeyName); test('prefers the first name in order', async () => { await expect(subject()).resolves.toBe(baseDef.name); }); }); describe('with no matching open journey', () => { const subject = () => journeyStep(['missing-a', 'missing-b'], 'do-work', step => { step.completeJourney({ a: 1 }); step.failJourney('noop'); return step.journeyName; }); test('runs untraced', async () => { await expect(subject()).resolves.toBeNull(); expect(emitMock).not.toHaveBeenCalled(); }); }); describe('with a bare name and nothing open', () => { test('does not auto-start', async () => { await journeyStep(baseDef.name, 'do-work', step => { expect(step.journeyName).toBeNull(); expect(step.stamp()).toBeUndefined(); }); expect(emitMock).not.toHaveBeenCalled(); }); }); describe('with a null target', () => { const subject = () => journeyStep(null, 'untraced', step => step.stamp()); test('returns undefined', async () => { await expect(subject()).resolves.toBeUndefined(); }); }); describe('with an open journey', () => { beforeEach(() => { moduleRuntime.startJourney(baseDef); }); describe('when the step completes with tags', () => { const subject = () => journeyStep(baseDef.name, 'do-work', step => { step.setAttribute('x', 1); step.completeJourney({ 'payment.id': 42 }); }); test('emits good with tags and attributes', async () => { await subject(); expect(lastJourney(emitMock).outcome).toBe('good'); expect(lastJourney(emitMock).tags['payment.id']).toBe(42); expect(lastJourney(emitMock).steps[0].attributes).toEqual({ x: 1 }); }); }); describe('when setTags is called without completing', () => { const subject = () => journeyStep(baseDef.name, 'load', step => { step.setTags({ 'customer.id': 9 }); step.setTags({ 'location.id': 3 }); }); test('merges tags without closing', async () => { await subject(); expect(emitMock).not.toHaveBeenCalled(); completeJourney(baseDef.name); expect(lastJourney(emitMock).tags).toEqual({ 'customer.id': 9, 'location.id': 3, }); }); }); describe('when setAttribute uses unsafe or overflowing keys', () => { test('skips prototype keys and caps the map', async () => { await journeyStep(baseDef.name, 'attrs', step => { step.setAttribute('__proto__', 'nope'); step.setAttribute('token', 'secret'); step.setAttribute('ok', 1); for (let i = 0; i < 70; i += 1) { step.setAttribute(`k${i}`, i); } }); completeJourney(baseDef.name); const attributes = lastJourney(emitMock).steps[0].attributes as Record< string, unknown >; expect(Object.prototype.hasOwnProperty.call(attributes, '__proto__')).toBe(false); expect(attributes.token).toBeUndefined(); expect(attributes.ok).toBe(1); expect(Object.keys(attributes).length).toBe(64); expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); }); }); describe('when step.stamp is called', () => { let tagged: JourneyStepStamp | undefined; beforeEach(() => { tagged = undefined; }); const subject = () => journeyStep(baseDef.name, 'do-work', step => { tagged = step.stamp(); }); test('binds stamp', async () => { await subject(); expect(tagged).toBeInstanceOf(JourneyStepStamp); }); }); describe('when step.stamp is called with a score function', () => { test('stores score on the stamp', async () => { await journeyStep(baseDef.name, 'do-work', step => { const score = () => 'exclude' as const; const stamp = step.stamp(score); expect(stamp?.score).toBe(score); }); }); }); describe('when step.failJourney is called', () => { const subject = () => journeyStep(baseDef.name, 'do-work', step => { step.failJourney('empty-catalog'); }); test('marks bad without throwing', async () => { await subject(); expect(lastJourney(emitMock).outcome).toBe('bad'); expect(lastJourney(emitMock).reason).toBe('empty-catalog'); }); }); describe('when the step function throws an Error', () => { const subject = () => journeyStep(baseDef.name, 'do-work', () => { throw new Error('boom'); }); test('marks step-error and rethrows', async () => { await expect(subject()).rejects.toThrow('boom'); expect(lastJourney(emitMock).reason).toBe('step-error'); expect(lastJourney(emitMock).steps[0].attributes).toBeUndefined(); }); }); describe('when the step function throws a non-Error', () => { const subject = () => journeyStep(baseDef.name, 'do-work', () => { // eslint-disable-next-line @typescript-eslint/only-throw-error -- intentional non-Error throw throw 'string-boom'; }); test('does not stringify the throw onto attributes', async () => { await expect(subject()).rejects.toBe('string-boom'); expect(lastJourney(emitMock).steps[0].attributes).toBeUndefined(); }); }); describe('when error is pre-set on the step', () => { const subject = () => journeyStep(baseDef.name, 'do-work', step => { step.setAttribute('phase', 'load'); throw new Error('boom'); }); test('keeps developer attributes', async () => { await expect(subject()).rejects.toThrow('boom'); expect(lastJourney(emitMock).steps[0].attributes).toEqual({ phase: 'load' }); }); }); describe('with multiple steps', () => { beforeEach(() => { // Parent already opened baseDef; restart with expected steps. emitMock.mockClear(); moduleRuntime.startJourney({ ...baseDef, expected: ['load', 'save'] }); emitMock.mockClear(); }); test('only the final complete closes once', async () => { await journeyStep(baseDef.name, 'load', step => { step.setAttribute('phase', 'load'); }); expect(emitMock).not.toHaveBeenCalled(); await journeyStep(baseDef.name, 'save', step => { step.completeJourney({ done: true }); }); expect(emitMock).toHaveBeenCalledTimes(1); expect(lastJourney(emitMock).expected).toEqual(['load', 'save']); expect(lastJourney(emitMock).steps.map((s: { name: string }) => s.name)).toEqual([ 'load', 'save', ]); }); }); describe('with a JourneyDef-like object target', () => { const subject = () => journeyStep(baseDef, 'via-def', step => { step.completeJourney(); return step.journeyName; }); test('resolves the def name', async () => { await expect(subject()).resolves.toBe(baseDef.name); }); }); describe('when completing without attributes', () => { const subject = () => journeyStep(baseDef.name, 'load', () => {}).then(() => completeJourney(baseDef.name) ); test('omits empty step attributes from the event', async () => { await subject(); expect(lastJourney(emitMock).steps[0].attributes).toBeUndefined(); }); }); }); describe('journeyMountStep', () => { describe('with candidates', () => { beforeEach(() => { moduleRuntime.startJourney(baseDef); }); const subject = () => moduleRuntime.journeyMountStep(['missing', baseDef.name], 'mount-load', step => { step.completeJourney(); return step.journeyName; }); test('resolves the open candidate', async () => { await expect(subject()).resolves.toBe(baseDef.name); }); }); describe('when started after scheduling', () => { const order: string[] = []; beforeEach(() => { order.length = 0; }); test('defers until after a sync startJourney', async () => { const mount = moduleRuntime.journeyMountStep(baseDef.name, 'boot', () => { order.push('mount-step'); }); moduleRuntime.startJourney(baseDef); order.push('started'); await mount; completeJourney(baseDef.name); expect(order).toEqual(['started', 'mount-step']); }); }); }); describe('when fail-fast happens mid-step', () => { beforeEach(() => { jest.spyOn(globalThis.performance, 'now').mockReturnValue(1_000); moduleRuntime.startJourney(baseDef); }); afterEach(() => { jest.restoreAllMocks(); }); test('finalizes in-flight step duration', async () => { let resolveWork!: () => void; const hang = new Promise(resolve => { resolveWork = resolve; }); const stepPromise = journeyStep(baseDef.name, 'slow', async () => { (globalThis.performance.now as jest.Mock).mockReturnValue(1_150); failJourney(baseDef.name, 'aborted'); await hang; }); await Promise.resolve(); expect(lastJourney(emitMock).steps[0].durationMs).toBe(150); resolveWork(); await stepPromise; }); }); describe('when the same name is restarted while a step is in flight', () => { test('completeJourney on the old step does not close the new instance', async () => { moduleRuntime.startJourney(baseDef); let resolveWork!: () => void; const hang = new Promise(resolve => { resolveWork = resolve; }); const stepPromise = journeyStep(baseDef.name, 'slow', async step => { await hang; step.completeJourney(); }); await Promise.resolve(); moduleRuntime.startJourney(baseDef); expect(lastJourney(emitMock).outcome).toBe('excluded'); emitMock.mockClear(); resolveWork(); await stepPromise; expect(emitMock).not.toHaveBeenCalled(); completeJourney(baseDef.name); expect(lastJourney(emitMock).outcome).toBe('good'); expect(lastJourney(emitMock).steps).toHaveLength(0); }); }); describe('when maxSteps is exceeded', () => { test('stops recording steps and sets steps_truncated', async () => { moduleRuntime.startJourney({ ...baseDef, maxSteps: 2 }); await journeyStep(baseDef.name, 'a', () => {}); await journeyStep(baseDef.name, 'b', () => {}); await journeyStep(baseDef.name, 'c', () => {}); completeJourney(baseDef.name); expect(lastJourney(emitMock).steps.map((s: { name: string }) => s.name)).toEqual([ 'a', 'b', ]); expect(lastJourney(emitMock).tags?.steps_truncated).toBe(true); }); }); describe('when the started config is frozen', () => { test('rejects mutation of the snapshot', () => { const config = { ...baseDef, name: 'frozen-def' }; moduleRuntime.startJourney(config); const snapshot = moduleRuntime.getOpenJourney('frozen-def')!.config; expect(Object.isFrozen(snapshot)).toBe(true); expect(snapshot).not.toBe(config); expect(() => { (snapshot as { name: string }).name = 'hacked'; }).toThrow(); expect(snapshot.name).toBe('frozen-def'); completeJourney('frozen-def'); }); }); });