/** @jest-environment jsdom */ import { baseJourneyDef, lastJourney, useJourneyTestRuntime, } from '../../__test-utils__/test-runtime'; import type { JourneyStepStamp } from '../../core'; import { completeJourney, journeyStep } from '../../core'; import { moduleRuntime } from '../../core/runtime'; const emitMock = jest.fn(); const baseDef = { ...baseJourneyDef, name: 'timeout-api' }; describe('[journey] timeouts', () => { useJourneyTestRuntime(emitMock); beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); describe('with a journey timeoutMs', () => { beforeEach(() => { moduleRuntime.startJourney({ ...baseDef, timeoutMs: 50 }); }); const subject = () => jest.advanceTimersByTimeAsync(50); test('closes bad with journey-timeout', async () => { await subject(); expect(lastJourney(emitMock).outcome).toBe('bad'); expect(lastJourney(emitMock).reason).toBe('journey-timeout'); }); }); describe('with timeoutMs 0', () => { beforeEach(() => { moduleRuntime.startJourney({ ...baseDef, timeoutMs: 0 }); }); const subject = () => jest.advanceTimersByTimeAsync(60_000); test('does not arm a countdown', async () => { await subject(); expect(emitMock).not.toHaveBeenCalled(); completeJourney(baseDef.name); expect(lastJourney(emitMock).outcome).toBe('good'); }); }); describe('without timeoutMs', () => { beforeEach(() => { moduleRuntime.startJourney({ name: 'timeout-idle', team: baseDef.team, group: baseDef.group, service: baseDef.service, }); }); test('closes excluded with journey-idle-timeout after default idle window', async () => { await jest.advanceTimersByTimeAsync(15 * 60 * 1_000); expect(lastJourney(emitMock).outcome).toBe('excluded'); expect(lastJourney(emitMock).reason).toBe('journey-idle-timeout'); }); }); describe('with a step timeoutMs', () => { let resolveWork!: () => void; let stepPromise: Promise; beforeEach(() => { moduleRuntime.startJourney({ ...baseDef, stepTimeoutMs: 40 }); const hang = new Promise(resolve => { resolveWork = resolve; }); stepPromise = journeyStep(baseDef.name, 'slow', async () => { await hang; }); }); afterEach(async () => { resolveWork(); await stepPromise; }); const subject = () => jest.advanceTimersByTimeAsync(40); test('closes bad with step-timeout', async () => { await subject(); expect(lastJourney(emitMock).reason).toBe('step-timeout'); expect(lastJourney(emitMock).steps[0].attributes.timeout_ms).toBe(40); }); }); describe('with per-call step timeoutMs', () => { let resolveWork!: () => void; let stepPromise: Promise; beforeEach(() => { moduleRuntime.startJourney({ ...baseDef, stepTimeoutMs: 5_000 }); const hang = new Promise(resolve => { resolveWork = resolve; }); stepPromise = journeyStep( baseDef.name, 'slow', async () => { await hang; }, { timeoutMs: 25 } ); }); afterEach(async () => { resolveWork(); await stepPromise; }); const subject = () => jest.advanceTimersByTimeAsync(25); test('overrides the journey default', async () => { await subject(); expect(lastJourney(emitMock).steps[0].attributes.timeout_ms).toBe(25); }); }); describe('when the journey closes before the step timeout', () => { let resolveWork!: () => void; let stepPromise: Promise; beforeEach(() => { moduleRuntime.startJourney({ ...baseDef, stepTimeoutMs: 40 }); const hang = new Promise(resolve => { resolveWork = resolve; }); stepPromise = journeyStep(baseDef.name, 'slow', async () => { await hang; }); completeJourney(baseDef.name); emitMock.mockClear(); }); afterEach(async () => { resolveWork(); await stepPromise; }); const subject = () => jest.advanceTimersByTimeAsync(40); test('the step timeout is a no-op', async () => { await subject(); expect(emitMock).not.toHaveBeenCalled(); }); }); describe('when step reason is pre-set before timeout', () => { let resolveWork!: () => void; let stepPromise: Promise; beforeEach(() => { moduleRuntime.startJourney({ ...baseDef, stepTimeoutMs: 40 }); const hang = new Promise(resolve => { resolveWork = resolve; }); stepPromise = journeyStep(baseDef.name, 'slow', async step => { const ref = (step.stamp() as JourneyStepStamp).step; ref.reason = 'pre-existing'; ref.attributes.timeout_ms = 999; await hang; }); }); afterEach(async () => { resolveWork(); await stepPromise; }); const subject = () => jest.advanceTimersByTimeAsync(40); test('preserves the first step reason', async () => { await subject(); expect(lastJourney(emitMock).steps[0].reason).toBe('pre-existing'); expect(lastJourney(emitMock).steps[0].attributes?.timeout_ms).toBe(999); }); }); describe('when clearTimeout is bypassed after complete', () => { beforeEach(() => { jest.spyOn(globalThis, 'clearTimeout').mockImplementation(() => {}); moduleRuntime.startJourney({ ...baseDef, timeoutMs: 50 }); completeJourney(baseDef.name); emitMock.mockClear(); }); afterEach(() => { jest.mocked(globalThis.clearTimeout).mockRestore(); }); const subject = () => jest.advanceTimersByTimeAsync(50); test('the late timer does not emit', async () => { await subject(); expect(emitMock).not.toHaveBeenCalled(); }); }); describe('when a journey timeout closes a hung step', () => { let releaseWork!: () => void; let stepPromise: Promise; beforeEach(() => { moduleRuntime.startJourney({ ...baseDef, timeoutMs: 50 }); const hang = new Promise(resolve => { releaseWork = resolve; }); stepPromise = journeyStep(baseDef.name, 'slow', async () => { await hang; }); }); afterEach(async () => { releaseWork(); await stepPromise; }); test('releases inFlight state before the step settles', async () => { await jest.advanceTimersByTimeAsync(50); expect(moduleRuntime.activeJourneyStep()).toBeNull(); expect(lastJourney(emitMock).outcome).toBe('bad'); expect(lastJourney(emitMock).reason).toBe('journey-timeout'); }); }); describe('with stepTimeoutMs 0', () => { let resolveWork!: () => void; let stepPromise: Promise; beforeEach(() => { moduleRuntime.startJourney({ ...baseDef, stepTimeoutMs: 0 }); const hang = new Promise(resolve => { resolveWork = resolve; }); stepPromise = journeyStep(baseDef.name, 'slow', async () => { await hang; }); }); afterEach(async () => { resolveWork(); await stepPromise; }); const subject = () => jest.advanceTimersByTimeAsync(60_000); test('does not arm a step countdown', async () => { await subject(); expect(emitMock).not.toHaveBeenCalled(); completeJourney(baseDef.name); expect(lastJourney(emitMock).outcome).toBe('good'); }); }); describe('with non-finite timeoutMs', () => { test('Infinity disables the countdown', async () => { moduleRuntime.startJourney({ ...baseDef, timeoutMs: Infinity }); await jest.advanceTimersByTimeAsync(60_000); expect(emitMock).not.toHaveBeenCalled(); completeJourney(baseDef.name); expect(lastJourney(emitMock).outcome).toBe('good'); }); test('NaN disables the countdown', async () => { moduleRuntime.startJourney({ ...baseDef, timeoutMs: Number.NaN }); await jest.advanceTimersByTimeAsync(60_000); expect(emitMock).not.toHaveBeenCalled(); completeJourney(baseDef.name); expect(lastJourney(emitMock).outcome).toBe('good'); }); }); });