/** @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);
});
});