/** @jest-environment jsdom */ import { render, screen } from '@testing-library/react'; import { useContext, useEffect } from 'react'; import { useJourneyTestRuntime } from '../../__test-utils__/test-runtime'; import { completeJourney, failJourney, journeyStep } from '../../core'; import { onHttpComplete } from '../../integrations/http-report'; import { JourneyNameContext } from '../../integrations/react/context'; import { JourneyScope } from '../../integrations/react/scope'; const emitMock = jest.fn(); const def = { name: 'scope-journey', team: 'platform', group: 'test', service: 'journey', timeoutMs: 0, }; function NameProbe() { const name = useContext(JourneyNameContext); return {name}; } describe('[journey] JourneyScope', () => { useJourneyTestRuntime(emitMock); beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); test('opens a journey on mount and publishes the name via context', () => { render( ); expect(screen.getByTestId('journey-name').textContent).toBe(def.name); completeJourney(def.name); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good'); }); test('applies tags from props', () => { render( ); completeJourney(def.name); expect(emitMock.mock.calls[0][0].journey.tags).toEqual({ plan: 'pro' }); }); test('excludes the journey on unmount', () => { const { unmount } = render( ); unmount(); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('excluded'); }); describe('when the journey already completed before unmount', () => { test('unmount does not emit again', () => { const { unmount } = render( ); completeJourney(def.name); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good'); emitMock.mockClear(); unmount(); expect(emitMock).not.toHaveBeenCalled(); }); }); describe('when the name prop changes', () => { const next = { ...def, name: 'scope-journey-next' }; test('excludes the prior journey and opens the new one', () => { const { rerender } = render( ); rerender( ); expect(screen.getByTestId('journey-name').textContent).toBe(next.name); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.name).toBe(def.name); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('excluded'); completeJourney(next.name); expect(emitMock.mock.calls[1][0].journey.name).toBe(next.name); expect(emitMock.mock.calls[1][0].journey.outcome).toBe('good'); }); }); describe('when a breach already occurred before unmount', () => { test('unmount stays bad (breach-anchored)', () => { const { unmount } = render( ); failJourney(def.name, 'validation'); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad'); emitMock.mockClear(); unmount(); expect(emitMock).not.toHaveBeenCalled(); }); }); test('applies updated tags without restarting', () => { const { rerender } = render( ); rerender( ); completeJourney(def.name); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.tags).toEqual({ plan: 'b' }); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('good'); }); test('child abort cleanup then unmount is excluded not request-aborted', async () => { let stamp: Parameters[0]['stamp']; let release!: () => void; const hang = new Promise(resolve => { release = resolve; }); function Child() { useEffect( () => () => { if (stamp) { onHttpComplete({ stamp, aborted: true, requestKey: '/x', durationMs: 5, }); } }, [] ); return null; } const { unmount } = render( ); const stepPromise = journeyStep(def.name, 'load', async step => { stamp = step.stamp(); await hang; }); await Promise.resolve(); unmount(); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('excluded'); release(); await stepPromise; }); test('rerendering with equal endpoint values does not re-arm timeout', async () => { const timeoutDef = { ...def, name: 'scope-timer-stable', timeoutMs: 30, endpoints: [{ match: '/api/jobs', slowRequestMs: 100 }], }; const { rerender } = render( ); await jest.advanceTimersByTimeAsync(20); rerender( ); await jest.advanceTimersByTimeAsync(15); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.outcome).toBe('bad'); expect(emitMock.mock.calls[0][0].journey.reason).toBe('journey-timeout'); }); test('live config updates keep original timeout budget', async () => { const timeoutDef = { ...def, name: 'scope-timeout-updates', timeoutMs: 30, team: 'team-a', }; const { rerender } = render( ); await jest.advanceTimersByTimeAsync(20); rerender( ); await jest.advanceTimersByTimeAsync(15); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.name).toBe(timeoutDef.name); expect(emitMock.mock.calls[0][0].journey.reason).toBe('journey-timeout'); }); test('live maxSteps updates apply without restarting', async () => { const maxStepsDef = { ...def, name: 'scope-max-steps', maxSteps: 1, }; const { rerender } = render( ); await journeyStep(maxStepsDef.name, 'first', async () => {}); rerender( ); await journeyStep(maxStepsDef.name, 'second', async () => {}); completeJourney(maxStepsDef.name); expect(emitMock).toHaveBeenCalledTimes(1); expect(emitMock.mock.calls[0][0].journey.steps).toHaveLength(2); }); });