/** @jest-environment jsdom */ /** * Host + multi-MFE coexistence. * * On a real page, duplicated `@servicetitan/journey` copies each get their own * `moduleRuntime`, while sharing `globalThis.__stJourney` (policies, enable, * instrument WeakSets) and the host's `DD_RUM`. Tests simulate that with * `createJourneyRuntime` engines registered beside the host `moduleRuntime`. */ import { lastJourney } from '../../__test-utils__/test-runtime'; import { configureJourney } from '../../config'; import type { JourneyStepStamp } from '../../core'; import { createJourneyRuntime, moduleRuntime, type JourneyRuntime } from '../../core/runtime'; import { JOURNEY_ENGINE, type JourneyDef } from '../../core/types'; import { ST_JOURNEY_GLOBAL_KEY, enableJourneyDebugging, getSharedPolicies, registerJourneyRuntime, resetSharedPolicies, setJourneyDefaultEnabled, teardownJourney, type StJourneyGlobal, } from '../../global'; import { instrumentAxios, type AxiosLikeInstance } from '../../integrations/axios'; import { onHttpComplete } from '../../integrations/http-report'; import { exposeAppJourney, type WindowWithAppJourney } from '../../integrations/jquery'; import { sendToConsole } from '../../sinks/console'; import { sendToDatadog } from '../../sinks/datadog'; const DD_RUM_KEY = 'DD_RUM'; function journeyDef(name: string, overrides: Partial = {}): JourneyDef { return { name, team: 'platform', group: 'test', service: 'journey', timeoutMs: 0, ...overrides, }; } type AxiosMock = AxiosLikeInstance & { requestHandlers: ((config: any) => any)[]; responseOkHandlers: ((response: any) => any)[]; }; function createAxiosMock(): AxiosMock { const requestHandlers: AxiosMock['requestHandlers'] = []; const responseOkHandlers: AxiosMock['responseOkHandlers'] = []; return { defaults: { baseURL: 'https://app.example.com' }, interceptors: { request: { use(onFulfilled) { requestHandlers.push(onFulfilled); return 0; }, }, response: { use(onFulfilled) { responseOkHandlers.push(onFulfilled); return 0; }, }, }, requestHandlers, responseOkHandlers, }; } function getStJourney(): StJourneyGlobal { return (globalThis as Record)[ST_JOURNEY_GLOBAL_KEY] as StJourneyGlobal; } describe('[journey] host + multi-MFE', () => { const hostEmit = jest.fn(); const mfeAEmit = jest.fn(); const mfeBEmit = jest.fn(); const ddRumAddAction = jest.fn(); let host: JourneyRuntime; let mfeA: JourneyRuntime; let mfeB: JourneyRuntime; let warnSpy: jest.SpyInstance; beforeEach(() => { localStorage.clear(); setJourneyDefaultEnabled(true); resetSharedPolicies(); teardownJourney(); hostEmit.mockClear(); mfeAEmit.mockClear(); mfeBEmit.mockClear(); ddRumAddAction.mockClear(); delete (globalThis as Record)[DD_RUM_KEY]; delete getStJourney().rum; warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined); // Host package copy = moduleRuntime (free helpers / KO bridge / configureJourney). host = moduleRuntime; host.reset({ sinks: [hostEmit], slowRequests: { defaultMs: 4_000, endpoints: [] }, }); // Duplicate package copies for two MFEs loaded by the host. mfeA = createJourneyRuntime({ sinks: [mfeAEmit] }); mfeB = createJourneyRuntime({ sinks: [mfeBEmit] }); registerJourneyRuntime(mfeA, { teardown: () => mfeA.reset({ sinks: [mfeAEmit] }) }); registerJourneyRuntime(mfeB, { teardown: () => mfeB.reset({ sinks: [mfeBEmit] }) }); (globalThis as Record)[DD_RUM_KEY] = { addAction: ddRumAddAction }; }); afterEach(() => { warnSpy.mockRestore(); localStorage.clear(); setJourneyDefaultEnabled(false); resetSharedPolicies(); teardownJourney(); delete (globalThis as Record)[DD_RUM_KEY]; delete getStJourney().rum; moduleRuntime.reset({ sinks: [sendToDatadog, sendToConsole], slowRequests: { defaultMs: 4_000, endpoints: [] }, }); }); describe('host enable gate', () => { test('skips MFE configureJourney until the host enables journeys', () => { setJourneyDefaultEnabled(false); const hostAxios = createAxiosMock(); const mfeAxios = createAxiosMock(); configureJourney({ axios: hostAxios }); expect(warnSpy).toHaveBeenCalledWith( expect.stringContaining('[journey] configureJourney: skipped — disabled') ); expect(hostAxios.requestHandlers).toHaveLength(0); setJourneyDefaultEnabled(true); configureJourney({ slowRequests: { defaultMs: 2_500, endpoints: [] }, axios: mfeAxios, }); expect(mfeAxios.requestHandlers).toHaveLength(1); expect(getSharedPolicies().slowRequests?.defaultMs).toBe(2_500); }); }); describe('shared host policies', () => { test('MFEs inherit host policies for ambient and slow-request thresholds', async () => { configureJourney({ slowRequests: { defaultMs: 50, endpoints: [{ match: '/api/slow', slowRequestMs: 8_000 }], }, autoAttributeRequests: false, httpAbortedRequests: 'continue', httpClientErrorRequests: 'bad', }); const shared = getSharedPolicies(); expect(shared.slowRequests?.defaultMs).toBe(50); expect(shared.autoAttributeRequests).toBe(false); expect(shared.httpAbortedRequests).toBe('continue'); expect(shared.httpClientErrorRequests).toBe('bad'); // Host module engine mirrors configureJourney timeouts locally too. expect(host.getSlowRequests().defaultMs).toBe(50); mfeA.startJourney(journeyDef('mfe-a-checkout')); await mfeA.journeyStep('mfe-a-checkout', 'pay', step => { // Shared autoAttributeRequests:false — ambient is null even with one step. expect(mfeA.activeJourneyStep()).toBeNull(); const stamp = step.stamp() as JourneyStepStamp; // Shared defaultMs=50 applies via effectiveAppTimeouts on every runtime. mfeA.reportBackendRequest(stamp.step, 200, 120, '/api/pay'); }); expect(lastJourney(mfeAEmit).outcome).toBe('bad'); expect(lastJourney(mfeAEmit).reason).toBe('request-latency'); expect(Object.isFrozen(shared)).toBe(true); mfeA.setPolicyOverlay({ slowRequests: { defaultMs: 9_999, endpoints: [] }, }); expect(getSharedPolicies().slowRequests?.defaultMs).toBe(50); expect(mfeA.getSlowRequests().defaultMs).toBe(9_999); expect(host.getSlowRequests().defaultMs).toBe(50); }); test('MFE overlay does not rewrite frozen host policies', async () => { configureJourney({ autoAttributeRequests: true }); expect(getSharedPolicies().autoAttributeRequests).toBe(true); expect(Object.isFrozen(getSharedPolicies())).toBe(true); expect(() => { getSharedPolicies().autoAttributeRequests = false; }).toThrow(); mfeA.setPolicyOverlay({ autoAttributeRequests: false }); host.startJourney(journeyDef('host-ambient')); await host.journeyStep('host-ambient', 'load', () => { expect(host.activeJourneyStep()).not.toBeNull(); }); host.completeJourney('host-ambient'); mfeA.startJourney(journeyDef('mfe-overlay')); await mfeA.journeyStep('mfe-overlay', 'load', () => { expect(mfeA.activeJourneyStep()).toBeNull(); }); mfeA.completeJourney('mfe-overlay'); expect(getSharedPolicies().autoAttributeRequests).toBe(true); expect(host.getAutoAttributeRequests()).toBe(true); expect(mfeA.getAutoAttributeRequests()).toBe(false); expect(host.getDebugSnapshot().policyOverlay.autoAttributeRequests).toBe(true); expect(mfeA.getDebugSnapshot().policyOverlay.autoAttributeRequests).toBe(true); }); }); describe('isolated open journeys', () => { beforeEach(() => { host.startJourney(journeyDef('host-shell')); mfeA.startJourney(journeyDef('checkout')); mfeB.startJourney(journeyDef('book-job')); }); test('each runtime only sees its own open journeys', () => { expect(host.getOpenJourney('host-shell')).toBeDefined(); expect(host.getOpenJourney('checkout')).toBeUndefined(); expect(host.getOpenJourney('book-job')).toBeUndefined(); expect(mfeA.getOpenJourney('checkout')).toBeDefined(); expect(mfeA.getOpenJourney('book-job')).toBeUndefined(); expect(mfeA.getOpenJourney('host-shell')).toBeUndefined(); expect(mfeB.getOpenJourney('book-job')).toBeDefined(); expect(mfeB.getOpenJourney('checkout')).toBeUndefined(); }); test('same journey name can be open independently on two MFEs', () => { mfeA.startJourney(journeyDef('shared-name', { service: 'mfe-a' })); mfeB.startJourney(journeyDef('shared-name', { service: 'mfe-b' })); expect(mfeA.getOpenJourney('shared-name')?.service).toBe('mfe-a'); expect(mfeB.getOpenJourney('shared-name')?.service).toBe('mfe-b'); mfeA.completeJourney('shared-name'); mfeB.failJourney(mfeB.getOpenJourney('shared-name')!, 'booking-rejected'); expect(lastJourney(mfeAEmit).outcome).toBe('good'); expect(lastJourney(mfeAEmit).service).toBe('mfe-a'); expect(lastJourney(mfeBEmit).outcome).toBe('bad'); expect(lastJourney(mfeBEmit).service).toBe('mfe-b'); expect(hostEmit).not.toHaveBeenCalled(); }); }); describe('isolated ambient attribution', () => { test('in-flight step on MFE A does not become ambient on MFE B or the host', async () => { mfeA.startJourney(journeyDef('checkout')); mfeB.startJourney(journeyDef('book-job')); host.startJourney(journeyDef('host-shell')); let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); const stepA = mfeA.journeyStep('checkout', 'pay', async () => { await gate; }); expect(mfeA.activeJourneyStep()?.name).toBe('pay'); expect(mfeB.activeJourneyStep()).toBeNull(); expect(host.activeJourneyStep()).toBeNull(); release(); await stepA; }); test('overlap nulls ambient only inside that MFE runtime', async () => { mfeA.startJourney(journeyDef('checkout')); mfeA.startJourney(journeyDef('upsell')); mfeB.startJourney(journeyDef('book-job')); let releasePay!: () => void; let releaseUpsell!: () => void; const payGate = new Promise(r => { releasePay = r; }); const upsellGate = new Promise(r => { releaseUpsell = r; }); const pay = mfeA.journeyStep('checkout', 'pay', async () => { await payGate; }); const upsell = mfeA.journeyStep('upsell', 'offer', async () => { await upsellGate; }); const book = mfeB.journeyStep('book-job', 'search', () => { expect(mfeA.activeJourneyStep()).toBeNull(); expect(mfeB.activeJourneyStep()?.name).toBe('search'); }); await book; releasePay(); releaseUpsell(); await Promise.all([pay, upsell]); }); test('explicit step.stamp on one MFE is unaffected by a concurrent step on another', async () => { mfeA.startJourney(journeyDef('checkout')); mfeB.startJourney(journeyDef('book-job')); let releaseB!: () => void; const gateB = new Promise(r => { releaseB = r; }); const stepB = mfeB.journeyStep('book-job', 'search', async () => { await gateB; }); await mfeA.journeyStep('checkout', 'pay', step => { const tagged = step.stamp(); expect(tagged).toBeDefined(); // Sibling MFE ambient is independent — still one in-flight on B, but A tags itself. expect(mfeB.activeJourneyStep()?.name).toBe('search'); expect(mfeA.activeJourneyStep()?.name).toBe('pay'); }); releaseB(); await stepB; }); }); describe('shared host RUM session', () => { test('host and both MFEs emit user_journey into the same DD_RUM', () => { host.setSinks([sendToDatadog]); mfeA.setSinks([sendToDatadog]); mfeB.setSinks([sendToDatadog]); host.startJourney(journeyDef('host-shell')); mfeA.startJourney(journeyDef('checkout')); mfeB.startJourney(journeyDef('book-job')); host.completeJourney('host-shell'); mfeA.completeJourney('checkout'); mfeB.failJourney(mfeB.getOpenJourney('book-job')!, 'timeout'); expect(ddRumAddAction).toHaveBeenCalledTimes(3); const names = ddRumAddAction.mock.calls.map( ([action, payload]: [string, { journey: { name: string; outcome: string } }]) => { expect(action).toBe('user_journey'); return `${payload.journey.name}:${payload.journey.outcome}`; } ); expect(names).toEqual(['host-shell:good', 'checkout:good', 'book-job:bad']); }); }); describe('per-bundle axios wiring', () => { test('host and each MFE can instrument distinct axios instances once', () => { const hostAxios = createAxiosMock(); const mfeAAxios = createAxiosMock(); const mfeBAxios = createAxiosMock(); configureJourney({ axios: hostAxios, fetch: true }); instrumentAxios(mfeAAxios); instrumentAxios(mfeBAxios); // Second wire of the same instance is a no-op (shared WeakSet). instrumentAxios(hostAxios); instrumentAxios(mfeAAxios); expect(hostAxios.requestHandlers).toHaveLength(1); expect(mfeAAxios.requestHandlers).toHaveLength(1); expect(mfeBAxios.requestHandlers).toHaveLength(1); }); }); describe('host KO bridge + MFE engines', () => { test('exposeAppJourney only reflects host-runtime open journeys', () => { const target = { App: {} } as WindowWithAppJourney; const api = exposeAppJourney(target); host.startJourney(journeyDef('host-ko')); mfeA.startJourney(journeyDef('mfe-checkout')); expect(api.getJourneyHandle('host-ko')).not.toBeNull(); expect(api.getJourneyHandle('mfe-checkout')).toBeNull(); expect(api.firstOpenJourneyName(['mfe-checkout', 'host-ko'])).toBe('host-ko'); expect(target.App?.Journey).toBe(api); }); }); describe('DevTools multi-runtime listing', () => { test('lists host and registered MFE engines when debugging is on', () => { enableJourneyDebugging(); host.startJourney(journeyDef('host-shell')); mfeA.startJourney(journeyDef('checkout')); mfeB.startJourney(journeyDef('book-job')); const state = getStJourney().getDebugState(); expect(state.debug).toBe(true); expect(state.hasRum).toBe(true); expect(state.runtimes.length).toBeGreaterThanOrEqual(3); const openNames = state.runtimes.flatMap(r => r.openJourneys.map(j => j.name)); expect(openNames).toEqual( expect.arrayContaining(['host-shell', 'checkout', 'book-job']) ); }); test('teardownJourney drops runtimes that registered a teardown', () => { enableJourneyDebugging(); expect(getStJourney().getDebugState().runtimes.length).toBeGreaterThanOrEqual(3); teardownJourney(); const remaining = getStJourney().getDebugState().runtimes; expect(remaining.some(r => r.id === host.id)).toBe(true); expect(remaining.some(r => r.id === mfeA.id)).toBe(false); expect(remaining.some(r => r.id === mfeB.id)).toBe(false); }); }); describe('HTTP breach isolation', () => { test('a slow request on MFE A does not fail MFE B', async () => { configureJourney({ slowRequests: { defaultMs: 50, endpoints: [] }, }); mfeA.startJourney(journeyDef('checkout')); mfeB.startJourney(journeyDef('book-job')); await mfeA.journeyStep('checkout', 'pay', step => { const stamp = step.stamp() as JourneyStepStamp; // Owning package copy reports against its own engine. mfeA.reportBackendRequest(stamp.step, 200, 200, '/api/pay'); }); expect(lastJourney(mfeAEmit).outcome).toBe('bad'); expect(lastJourney(mfeAEmit).reason).toBe('request-latency'); expect(mfeB.getOpenJourney('book-job')).toBeDefined(); expect(mfeBEmit).not.toHaveBeenCalled(); }); }); describe('when a host transport reports an MFE step.stamp()', () => { test('abort fails the MFE journey and leaves a same-named host journey open', async () => { configureJourney({ httpAbortedRequests: 'bad' }); host.startJourney(journeyDef('checkout', { service: 'host' })); mfeA.startJourney(journeyDef('checkout', { service: 'mfe-a' })); await mfeA.journeyStep('checkout', 'pay', step => { const stamp = step.stamp() as JourneyStepStamp; onHttpComplete({ stamp, requestKey: '/api/pay', durationMs: 10, aborted: true, }); }); expect(lastJourney(mfeAEmit).outcome).toBe('bad'); expect(lastJourney(mfeAEmit).reason).toBe('request-aborted'); expect(lastJourney(mfeAEmit).service).toBe('mfe-a'); expect(host.getOpenJourney('checkout')?.service).toBe('host'); expect(hostEmit).not.toHaveBeenCalled(); }); test('unstamped foreign abort does not poison onHttpAborted on the MFE step', async () => { configureJourney({ httpAbortedRequests: 'bad' }); host.startJourney(journeyDef('checkout', { service: 'host' })); mfeA.startJourney(journeyDef('checkout', { service: 'mfe-a' })); const aborted = Object.assign(new Error('aborted'), { name: 'AbortError' }); await expect( mfeA.journeyStep('checkout', 'pay', step => { const stamp = step.stamp() as JourneyStepStamp; const ref = stamp.step; delete ref.journey[JOURNEY_ENGINE]; onHttpComplete({ stamp, requestKey: '/api/pay', durationMs: 10, aborted: true, }); expect(ref.onHttpAborted).toBeUndefined(); throw aborted; }) ).rejects.toBe(aborted); mfeA.completeJourney('checkout'); expect(lastJourney(mfeAEmit).outcome).toBe('good'); expect(host.getOpenJourney('checkout')?.service).toBe('host'); expect(hostEmit).not.toHaveBeenCalled(); }); test('4xx exclude closes the MFE journey', async () => { configureJourney({ httpClientErrorRequests: 'exclude' }); mfeA.startJourney(journeyDef('checkout')); await mfeA.journeyStep('checkout', 'pay', step => { const stamp = step.stamp() as JourneyStepStamp; onHttpComplete({ stamp, status: 404, requestKey: '/api/pay', durationMs: 10, }); }); expect(lastJourney(mfeAEmit).outcome).toBe('excluded'); expect(hostEmit).not.toHaveBeenCalled(); expect(mfeA.getOpenJourney('checkout')).toBeUndefined(); }); test('5xx fails the MFE journey', async () => { mfeA.startJourney(journeyDef('checkout')); await mfeA.journeyStep('checkout', 'pay', step => { const stamp = step.stamp() as JourneyStepStamp; onHttpComplete({ stamp, status: 500, requestKey: '/api/pay', durationMs: 10, }); }); expect(lastJourney(mfeAEmit).outcome).toBe('bad'); expect(lastJourney(mfeAEmit).reason).toBe('request-error'); expect(hostEmit).not.toHaveBeenCalled(); }); }); });