/** @jest-environment jsdom */ import { useJourneyTestRuntime, lastJourney, baseJourneyDef } from '../__test-utils__/test-runtime'; import { configureJourney } from '../config'; import { completeJourney, journeyStep, resolveStamp, singleInFlightJourneyStep, JourneyStepStamp, type StepHandle, } from '../core'; import { moduleRuntime } from '../core/runtime'; import type { JourneyHttpPolicyContext } from '../core/request-policy'; import { onHttpComplete } from '../integrations/http-report'; import { getSharedPolicies, resetSharedPolicies } from '../global'; import { exposeAppJourney } from '../integrations/jquery'; const emitMock = jest.fn(); describe('[journey] hardening', () => { useJourneyTestRuntime(emitMock); describe('resolveStamp realm-safe adopt', () => { test('adopts JourneyStepStamp and branded foreign copies', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'tag-journey' }); await journeyStep('tag-journey', 'load', (step: StepHandle) => { const tagged = step.stamp() as JourneyStepStamp; expect(resolveStamp(tagged)).toBe(tagged); expect(tagged.step.name).toBe('load'); const foreign = { [Symbol.for('st.journey.stamp')]: true, step: tagged.step, }; expect(resolveStamp(foreign)?.step.name).toBe('load'); }); completeJourney('tag-journey'); }); test('does not treat a plain tag wrapper without a StepRecord as a step', () => { expect(resolveStamp({ [Symbol.for('st.journey.stamp')]: true })).toBeUndefined(); expect(resolveStamp({ name: 'no-journey' })).toBeUndefined(); }); }); describe('autoAttributeRequests', () => { test('returns null from singleInFlightJourneyStep when disabled', async () => { getSharedPolicies().autoAttributeRequests = false; moduleRuntime.startJourney({ ...baseJourneyDef, name: 'aa-off' }); await journeyStep('aa-off', 'load', () => { expect(singleInFlightJourneyStep()).toBeNull(); }); completeJourney('aa-off'); resetSharedPolicies(); }); }); describe('httpAbortedRequests / httpClientErrorRequests policies', () => { test('httpAbortedRequests good does not fail the journey', async () => { getSharedPolicies().httpAbortedRequests = 'continue'; moduleRuntime.startJourney({ ...baseJourneyDef, name: 'abort-good' }); await journeyStep('abort-good', 'load', (step: StepHandle) => { const stamp = step.stamp() as JourneyStepStamp; onHttpComplete({ stamp, requestKey: '/api', durationMs: 10, aborted: true, }); }); completeJourney('abort-good'); expect(lastJourney(emitMock).outcome).toBe('good'); }); test('abort throw inside journeyStep follows httpAbortedRequests continue', async () => { getSharedPolicies().httpAbortedRequests = 'continue'; moduleRuntime.startJourney({ ...baseJourneyDef, name: 'abort-step' }); const aborted = Object.assign(new Error('aborted'), { name: 'AbortError' }); await expect( journeyStep('abort-step', 'load', () => { throw aborted; }) ).rejects.toBe(aborted); completeJourney('abort-step'); expect(lastJourney(emitMock).outcome).toBe('good'); }); test('abort throw inside journeyStep does not score unattributed abort', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'abort-bare' }); const aborted = Object.assign(new Error('aborted'), { name: 'AbortError' }); await expect( journeyStep('abort-bare', 'load', () => { throw aborted; }) ).rejects.toBe(aborted); completeJourney('abort-bare'); expect(lastJourney(emitMock).outcome).toBe('good'); }); test('default attributed abort continue does not fail the journey', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'abort-default' }); await journeyStep('abort-default', 'load', (step: StepHandle) => { const stamp = step.stamp() as JourneyStepStamp; onHttpComplete({ stamp, requestKey: '/api', durationMs: 10, aborted: true, }); }); completeJourney('abort-default'); expect(lastJourney(emitMock).outcome).toBe('good'); }); test('ignored stamp abort throw does not fail the journey', async () => { getSharedPolicies().httpAbortedRequests = 'bad'; moduleRuntime.startJourney({ ...baseJourneyDef, name: 'stamp-ignore-abort' }); const aborted = Object.assign(new Error('aborted'), { name: 'AbortError' }); await expect( journeyStep('stamp-ignore-abort', 'load', (step: StepHandle) => { const stamp = step.stamp({ ignore: true }) as JourneyStepStamp; onHttpComplete({ stamp, requestKey: '/telemetry', durationMs: 10, aborted: true, }); throw aborted; }) ).rejects.toBe(aborted); completeJourney('stamp-ignore-abort'); expect(lastJourney(emitMock).outcome).toBe('good'); }); test('Error canceled message is step-error not request-aborted', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'canceled-msg' }); await expect( journeyStep('canceled-msg', 'load', () => { throw new Error('canceled'); }) ).rejects.toThrow('canceled'); expect(lastJourney(emitMock).outcome).toBe('bad'); expect(lastJourney(emitMock).reason).toBe('step-error'); }); test('numeric code 20 is step-error not request-aborted', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'code-20' }); const err = Object.assign(new Error('boom'), { code: 20 }); await expect( journeyStep('code-20', 'load', () => { throw err; }) ).rejects.toBe(err); expect(lastJourney(emitMock).outcome).toBe('bad'); expect(lastJourney(emitMock).reason).toBe('step-error'); }); test('ignored abort then cancel throw does not re-apply shared abort policy', async () => { getSharedPolicies().httpAbortedRequests = 'bad'; moduleRuntime.startJourney({ ...baseJourneyDef, name: 'ignored-abort-throw', endpoints: [{ match: '/poll', ignore: true }], }); const aborted = Object.assign(new Error('aborted'), { name: 'AbortError' }); await expect( journeyStep('ignored-abort-throw', 'load', (step: StepHandle) => { const stamp = step.stamp() as JourneyStepStamp; onHttpComplete({ stamp, requestKey: '/poll/tick', durationMs: 10, aborted: true, }); throw aborted; }) ).rejects.toBe(aborted); completeJourney('ignored-abort-throw'); expect(lastJourney(emitMock).outcome).toBe('good'); }); test('per-request abort good then cancel throw does not re-apply shared policy', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'meta-abort-throw' }); const aborted = Object.assign(new Error('aborted'), { name: 'AbortError' }); await expect( journeyStep('meta-abort-throw', 'load', (step: StepHandle) => { const stamp = step.stamp() as JourneyStepStamp; onHttpComplete({ stamp, requestKey: '/api', durationMs: 10, aborted: true, meta: { httpAbortedRequests: 'continue' }, }); throw aborted; }) ).rejects.toBe(aborted); completeJourney('meta-abort-throw'); expect(lastJourney(emitMock).outcome).toBe('good'); }); test('httpClientErrorRequests bad fails on 4xx', async () => { getSharedPolicies().httpClientErrorRequests = 'bad'; moduleRuntime.startJourney({ ...baseJourneyDef, name: 'four-xx' }); await journeyStep('four-xx', 'load', (step: StepHandle) => { const stamp = step.stamp() as JourneyStepStamp; onHttpComplete({ stamp, status: 404, requestKey: '/api', durationMs: 10, }); }); expect(lastJourney(emitMock).outcome).toBe('bad'); expect(lastJourney(emitMock).reason).toBe('request-client-error'); }); test('default httpClientErrorRequests still marks latency on a slow 4xx', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'slow-4xx' }); await journeyStep('slow-4xx', 'load', (step: StepHandle) => { const stamp = step.stamp() as JourneyStepStamp; onHttpComplete({ stamp, status: 404, requestKey: '/api', durationMs: 5_000, }); }); expect(lastJourney(emitMock).outcome).toBe('bad'); expect(lastJourney(emitMock).reason).toBe('request-latency'); }); test('fast 4xx stays good when httpClientErrorRequests is continue', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'fast-4xx' }); await journeyStep('fast-4xx', 'load', (step: StepHandle) => { const stamp = step.stamp() as JourneyStepStamp; onHttpComplete({ stamp, status: 404, requestKey: '/api', durationMs: 10, }); }); completeJourney('fast-4xx'); expect(lastJourney(emitMock).outcome).toBe('good'); }); test('ignored endpoints skip abort and 4xx policies', async () => { const policies = getSharedPolicies(); policies.httpAbortedRequests = 'bad'; policies.httpClientErrorRequests = 'bad'; moduleRuntime.startJourney({ ...baseJourneyDef, name: 'ignored-http', endpoints: [{ match: '/poll', ignore: true }], }); await journeyStep('ignored-http', 'load', (step: StepHandle) => { const stamp = step.stamp() as JourneyStepStamp; onHttpComplete({ stamp, requestKey: '/poll/tick', durationMs: 10, aborted: true, }); onHttpComplete({ stamp, status: 404, requestKey: '/poll/tick', durationMs: 10, }); }); completeJourney('ignored-http'); expect(lastJourney(emitMock).outcome).toBe('good'); }); test('ignored endpoints skip httpClientErrorRequests exclude', async () => { getSharedPolicies().httpClientErrorRequests = () => 'exclude'; moduleRuntime.startJourney({ ...baseJourneyDef, name: 'ignored-exclude', endpoints: [{ match: '/auth', ignore: true }], }); await journeyStep('ignored-exclude', 'load', (step: StepHandle) => { const stamp = step.stamp() as JourneyStepStamp; onHttpComplete({ stamp, status: 401, requestKey: '/auth/session', durationMs: 10, }); }); completeJourney('ignored-exclude'); expect(lastJourney(emitMock).outcome).toBe('good'); }); test('httpClientErrorRequests resolver can exclude on 401', async () => { getSharedPolicies().httpClientErrorRequests = (ctx: JourneyHttpPolicyContext) => ctx.status === 401 ? 'exclude' : 'continue'; moduleRuntime.startJourney({ ...baseJourneyDef, name: 'auth' }); await journeyStep('auth', 'load', (step: StepHandle) => { const stamp = step.stamp() as JourneyStepStamp; onHttpComplete({ stamp, status: 401, requestKey: '/api', durationMs: 10, }); }); expect(lastJourney(emitMock).outcome).toBe('excluded'); }); }); describe('step.stamp score', () => { test('excludes on 5xx when the stamp decide reads the response body', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'decide-exclude' }); await journeyStep('decide-exclude', 'submit', (step: StepHandle) => { const tagged = step.stamp(ctx => { const body = (ctx.response as { data?: { code?: string } } | undefined)?.data; return body?.code === 'USER_DATA' ? 'exclude' : undefined; }); onHttpComplete({ stamp: tagged, status: 500, requestKey: '/api/checkout', durationMs: 10, meta: tagged, response: { data: { code: 'USER_DATA', message: 'bad customer' } }, }); }); expect(lastJourney(emitMock).outcome).toBe('excluded'); }); test('keeps 5xx from failing when decide returns good', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'decide-good' }); await journeyStep('decide-good', 'submit', (step: StepHandle) => { const tagged = step.stamp({ score: () => 'continue' }); onHttpComplete({ stamp: tagged, status: 500, requestKey: '/api', durationMs: 10, meta: tagged, }); }); completeJourney('decide-good'); expect(lastJourney(emitMock).outcome).toBe('good'); }); test('fails a 200 when decide returns bad', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'decide-bad' }); await journeyStep('decide-bad', 'submit', (step: StepHandle) => { const tagged = step.stamp(() => 'bad'); onHttpComplete({ stamp: tagged, status: 200, requestKey: '/api', durationMs: 10, meta: tagged, request: { url: '/api' }, response: { data: { success: false } }, }); }); expect(lastJourney(emitMock).outcome).toBe('bad'); expect(lastJourney(emitMock).reason).toBe('request-error'); }); test('falls through to default 5xx scoring when decide returns void', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'decide-void' }); await journeyStep('decide-void', 'submit', (step: StepHandle) => { const tagged = step.stamp(() => undefined); onHttpComplete({ stamp: tagged, status: 500, requestKey: '/api', durationMs: 10, meta: tagged, }); }); expect(lastJourney(emitMock).outcome).toBe('bad'); expect(lastJourney(emitMock).reason).toBe('request-error'); }); test('falls through when decide throws', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'decide-throw' }); await journeyStep('decide-throw', 'submit', (step: StepHandle) => { const tagged = step.stamp(() => { throw new Error('decide boom'); }); onHttpComplete({ stamp: tagged, status: 500, requestKey: '/api', durationMs: 10, meta: tagged, }); }); expect(lastJourney(emitMock).outcome).toBe('bad'); expect(lastJourney(emitMock).reason).toBe('request-error'); }); test('does not run decide on an unstamped stamp() used only to unwrap the step', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'decide-unstamped' }); await journeyStep('decide-unstamped', 'submit', (step: StepHandle) => { const stamp = step.stamp() as JourneyStepStamp; onHttpComplete({ stamp, status: 500, requestKey: '/api', durationMs: 10, }); }); expect(lastJourney(emitMock).outcome).toBe('bad'); }); test('ignored endpoints skip stamp decide', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'decide-ignore', endpoints: [{ match: '/poll', ignore: true }], }); await journeyStep('decide-ignore', 'load', (step: StepHandle) => { const tagged = step.stamp(() => 'exclude'); onHttpComplete({ stamp: tagged, status: 500, requestKey: '/poll/tick', durationMs: 10, meta: tagged, }); }); completeJourney('decide-ignore'); expect(lastJourney(emitMock).outcome).toBe('good'); }); test('reads decide off a branded foreign tag copy', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'decide-brand' }); await journeyStep('decide-brand', 'load', (step: StepHandle) => { const tagged = step.stamp(() => 'exclude') as JourneyStepStamp; const foreign = { [Symbol.for('st.journey.stamp')]: true, step: tagged.step, score: tagged.score, }; onHttpComplete({ stamp: resolveStamp(foreign), status: 500, requestKey: '/api', durationMs: 10, meta: { stamp: foreign }, }); }); expect(lastJourney(emitMock).outcome).toBe('excluded'); }); }); describe('stamp / meta ignore', () => { test('meta.ignore skips scoring while a step is in flight', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'meta-ignore' }); await journeyStep('meta-ignore', 'load', (step: StepHandle) => { const stamp = step.stamp() as JourneyStepStamp; onHttpComplete({ stamp, status: 500, requestKey: '/telemetry', durationMs: 10, meta: { ignore: true }, }); }); completeJourney('meta-ignore'); expect(lastJourney(emitMock).outcome).toBe('good'); }); test('step.stamp({ ignore: true }) never changes the verdict', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'stamp-ignore' }); await journeyStep('stamp-ignore', 'load', (step: StepHandle) => { const tagged = step.stamp({ ignore: true }); onHttpComplete({ stamp: tagged, status: 500, requestKey: '/api', durationMs: 10, meta: tagged, }); }); completeJourney('stamp-ignore'); expect(lastJourney(emitMock).outcome).toBe('good'); }); test('a later stamped request on the same step still scores', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'ignore-then-score' }); await journeyStep('ignore-then-score', 'load', (step: StepHandle) => { onHttpComplete({ stamp: step.stamp({ ignore: true }) as JourneyStepStamp, status: 500, requestKey: '/telemetry', durationMs: 10, }); onHttpComplete({ stamp: step.stamp() as JourneyStepStamp, status: 500, requestKey: '/api', durationMs: 10, }); }); expect(lastJourney(emitMock).outcome).toBe('bad'); }); test('decide on an ignored stamp is not applied', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'ignore-decide' }); await journeyStep('ignore-decide', 'load', (step: StepHandle) => { const tagged = step.stamp({ ignore: true, score: () => 'exclude' }); onHttpComplete({ stamp: tagged, status: 200, requestKey: '/api', durationMs: 10, meta: tagged, }); }); completeJourney('ignore-decide'); expect(lastJourney(emitMock).outcome).toBe('good'); }); test('ambient singleInFlightJourneyStep is a stamp without score or ignore', async () => { moduleRuntime.startJourney({ ...baseJourneyDef, name: 'ambient-tag' }); await journeyStep('ambient-tag', 'load', () => { const stamp = singleInFlightJourneyStep(); expect(stamp).toBeInstanceOf(JourneyStepStamp); expect(stamp?.score).toBeUndefined(); expect(stamp?.ignore).toBeUndefined(); }); completeJourney('ambient-tag'); }); }); describe('getJourneyHandle', () => { test('shares the original config object', () => { const target: { App?: { Journey?: unknown } } = {}; const api = exposeAppJourney(target as never); const config = { ...baseJourneyDef, name: 'ko-open' }; const original = api.defineJourney(config); original.start(); const lookedUp = api.getJourneyHandle('ko-open'); expect(lookedUp?.config).not.toBe(config); expect(lookedUp?.config.name).toBe(config.name); expect(Object.isFrozen(lookedUp?.config)).toBe(true); original.complete(); expect(lastJourney(emitMock).outcome).toBe('good'); expect(api.getJourneyHandle('ko-open')).toBeNull(); }); test('start on a lookup handle restarts the open journey', () => { const target: { App?: { Journey?: unknown } } = {}; const api = exposeAppJourney(target as never); const config = { ...baseJourneyDef, name: 'ko-start' }; api.defineJourney(config).start(); const handle = api.getJourneyHandle('ko-start'); expect(handle?.config.name).toBe(config.name); expect(Object.isFrozen(handle?.config)).toBe(true); handle!.start(); expect(lastJourney(emitMock).outcome).toBe('excluded'); expect(api.getJourneyHandle('ko-start')?.config.name).toBe(config.name); handle!.complete(); expect(lastJourney(emitMock).outcome).toBe('good'); expect(emitMock).toHaveBeenCalledTimes(2); }); test('wraps a config that was started without defineJourney', () => { const target: { App?: { Journey?: unknown } } = {}; const api = exposeAppJourney(target as never); const config = { ...baseJourneyDef, name: 'ko-config', expected: ['load'], tags: { plan: 'pro' }, }; moduleRuntime.startJourney(config); const handle = api.getJourneyHandle('ko-config'); expect(handle?.config).not.toBe(config); expect(handle?.config.name).toBe('ko-config'); expect(handle?.config.expected).toEqual(['load']); expect(handle?.config.tags?.plan).toBe('pro'); expect(Object.isFrozen(handle?.config)).toBe(true); handle!.complete(); }); test('step on a lookup handle reopens a closed journey', async () => { const target: { App?: { Journey?: unknown } } = {}; const api = exposeAppJourney(target as never); moduleRuntime.startJourney({ ...baseJourneyDef, name: 'ko-step' }); const handle = api.getJourneyHandle('ko-step')!; handle.complete(); expect(emitMock).toHaveBeenCalledTimes(1); await handle.step('late', () => {}); expect(api.getJourneyHandle('ko-step')).not.toBeNull(); handle.complete(); expect(emitMock).toHaveBeenCalledTimes(2); }); }); describe('configureJourney shared policies', () => { test('applies autoAttributeRequests via configureJourney', async () => { configureJourney({ autoAttributeRequests: false }); moduleRuntime.startJourney({ ...baseJourneyDef, name: 'cfg-aa' }); await journeyStep('cfg-aa', 'load', () => { expect(singleInFlightJourneyStep()).toBeNull(); }); completeJourney('cfg-aa'); resetSharedPolicies(); }); }); });