From df805b77c1a9c4aefbf778386d3ec1d427b0299b Mon Sep 17 00:00:00 2001 From: James Newman Date: Tue, 18 Aug 2026 11:09:06 -0400 Subject: [PATCH 1/4] feat: forward terminate from the kit to the Rokt launcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backs the new `window.mParticle.Rokt.terminate()` entry point in the core SDK (mParticle/mparticle-web-sdk). The manager delegates to the kit, which forwards to the launcher, giving partners a supported teardown path in place of the undocumented `window.Rokt.currentLauncher?.terminate()`. The launcher reference is deliberately left in place. The Rokt Web SDK memoizes a single launcher per page, so clearing it could not buy the caller a fresh one — it would only flip the kit to not-ready and leave later calls queued forever. Co-Authored-By: Claude Opus 5 (1M context) --- src/Rokt-Kit.ts | 19 ++++++ test/src/tests.spec.ts | 133 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index d3b5b9b..d4d30ad 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -86,6 +86,7 @@ interface RoktLauncher { selectPlacements(options: Record): RoktSelection | Promise; hashAttributes(attributes: Record): Promise>; use(extensionName: string): Promise; + terminate(): Promise; } interface RoktGlobal { @@ -1521,6 +1522,24 @@ class RoktKit implements KitInterface { return this.launcher!.use(extensionName); } + /** + * Tears down the Rokt launcher and the placements it rendered. + * + * The launcher reference is deliberately left in place. The Rokt Web SDK + * memoizes a single launcher per page, so clearing it here could not buy the + * caller a fresh one — it would only flip the kit to not-ready and leave + * later calls queued forever. Leaving state untouched makes this exactly + * equivalent to the `window.Rokt.currentLauncher.terminate()` call partners + * use today, just reachable through a supported API. + */ + public terminate(): Promise { + if (!this.isKitReady()) { + console.error('Rokt Kit: Not initialized'); + return Promise.resolve(); + } + return this.launcher!.terminate(); + } + /** * Registers a callback to be invoked once rokt-thank-you-element.js becomes available. */ diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 40c6ed2..95552d6 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -3249,6 +3249,139 @@ describe('Rokt Forwarder', () => { }); }); + describe('#terminate', () => { + beforeEach(() => { + (window as any).Rokt = new (MockRoktForwarder as any)(); + (window as any).mParticle.Rokt = (window as any).Rokt; + (window as any).mParticle.Rokt.attachKitCalled = false; + (window as any).mParticle.Rokt.attachKit = async (kit: any) => { + (window as any).mParticle.Rokt.attachKitCalled = true; + (window as any).mParticle.Rokt.kit = kit; + Promise.resolve(); + }; + }); + + it('should call launcher.terminate when fully initialized', async () => { + let terminateCalled = false; + (window as any).mParticle.forwarder.isInitialized = true; + (window as any).mParticle.forwarder.launcher = { + terminate: function () { + terminateCalled = true; + return Promise.resolve(); + }, + }; + + await (window as any).mParticle.forwarder.terminate(); + + expect(terminateCalled).toBe(true); + }); + + it('should return the promise from launcher.terminate', async () => { + let resolved = false; + (window as any).mParticle.forwarder.isInitialized = true; + (window as any).mParticle.forwarder.launcher = { + terminate: () => new Promise((resolve) => setTimeout(resolve, 0)), + }; + + await (window as any).mParticle.forwarder.terminate().then(() => { + resolved = true; + }); + + expect(resolved).toBe(true); + }); + + it('should resolve without calling the launcher when called before initialization', async () => { + const originalConsoleError = window.console.error; + let errorMessage = null; + window.console.error = function (message: any) { + errorMessage = message; + }; + + (window as any).mParticle.forwarder.isInitialized = false; + (window as any).mParticle.forwarder.launcher = null; + + try { + await expect((window as any).mParticle.forwarder.terminate()).resolves.toBeUndefined(); + } finally { + window.console.error = originalConsoleError; + } + + expect(errorMessage).toBe('Rokt Kit: Not initialized'); + }); + + it('should resolve without calling the launcher when initialized but the launcher is missing', async () => { + const originalConsoleError = window.console.error; + let errorMessage = null; + window.console.error = function (message: any) { + errorMessage = message; + }; + + (window as any).mParticle.forwarder.isInitialized = true; + (window as any).mParticle.forwarder.launcher = null; + + try { + await expect((window as any).mParticle.forwarder.terminate()).resolves.toBeUndefined(); + } finally { + window.console.error = originalConsoleError; + } + + expect(errorMessage).toBe('Rokt Kit: Not initialized'); + }); + + // The Rokt Web SDK memoizes one launcher per page, so terminate must not + // clear the kit's launcher references — doing so would flip the kit to + // not-ready with no way back. + it('should leave the launcher references intact so the kit stays ready', async () => { + const launcher = { + terminate: () => Promise.resolve(), + }; + (window as any).mParticle.forwarder.isInitialized = true; + (window as any).mParticle.forwarder.launcher = launcher; + (window as any).Rokt.currentLauncher = launcher; + + await (window as any).mParticle.forwarder.terminate(); + + expect((window as any).mParticle.forwarder.launcher).toBe(launcher); + expect((window as any).Rokt.currentLauncher).toBe(launcher); + }); + + it('should call launcher.terminate after init (test mode) and attach', async () => { + let terminateCalled = false; + + (window as any).mParticle.Rokt.attachKitCalled = false; + (window as any).mParticle.Rokt.attachKit = async (kit: any) => { + (window as any).mParticle.Rokt.attachKitCalled = true; + (window as any).mParticle.Rokt.kit = kit; + Promise.resolve(); + }; + + (window as any).Rokt.createLauncher = async function () { + return Promise.resolve({ + terminate: function () { + terminateCalled = true; + return Promise.resolve(); + }, + }); + }; + + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + await (window as any).mParticle.forwarder.terminate(); + + expect(terminateCalled).toBe(true); + }); + }); + describe('#setUserAttribute', () => { beforeEach(() => { (window as any).mParticle.sessionManager = { From da1d313915dc84e81fec5be1e1cafc4dde387f6b Mon Sep 17 00:00:00 2001 From: Matt Bodle Date: Sat, 22 Aug 2026 08:45:19 +1000 Subject: [PATCH 2/4] fix: recreate launcher after terminate so SPA selectPlacements continues The Web SDK drops its memoized launcher on terminate, so createLauncher can mint a new instance. The next selectPlacements now re-attaches that instance instead of calling into the terminated one. --- src/Rokt-Kit.ts | 65 +++++++++++++++++++++++++++++++------ test/src/tests.spec.ts | 73 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 126 insertions(+), 12 deletions(-) diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index d4d30ad..310e3ae 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -782,6 +782,12 @@ class RoktKit implements KitInterface { // so a re-login re-evaluates fresh. private _workspaceLastSearchedIdentitiesKey?: string; + private _attachAccountId?: string; + private _attachLauncherOptions: Record = {}; + private _attachLegacyRoktExtensions: string[] = []; + private _launcherTerminated = false; + private _recreateLauncherPromise: Promise | null = null; + // ---- Private helpers ---- private getEventAttributeValue(event: SDKEvent, eventAttributeKey: string): unknown { @@ -984,7 +990,12 @@ class RoktKit implements KitInterface { accountId: string, launcherOptions: Record, legacyRoktExtensions: string[] = [], - ): void { + ): Promise { + this._attachAccountId = accountId; + this._attachLauncherOptions = launcherOptions || {}; + this._attachLegacyRoktExtensions = legacyRoktExtensions; + this._launcherTerminated = false; + const options: Record = { accountId, ...(launcherOptions || {}), @@ -997,16 +1008,38 @@ class RoktKit implements KitInterface { launcherPromise = window.Rokt!.createLauncher(options); } - launcherPromise + return launcherPromise .then(async (launcher) => { await registerLegacyExtensions(legacyRoktExtensions, launcher); this.initRoktLauncher(launcher); }) .catch((err: unknown) => { + this._launcherTerminated = true; console.error('Error creating Rokt launcher:', err); }); } + private recreateLauncherIfTerminated(): Promise | undefined { + if (!this._launcherTerminated) { + return undefined; + } + if (this._recreateLauncherPromise) { + return this._recreateLauncherPromise; + } + if (!this._attachAccountId || !this.isLauncherReadyToAttach()) { + return undefined; + } + + this._recreateLauncherPromise = this.attachLauncher( + this._attachAccountId, + this._attachLauncherOptions, + this._attachLegacyRoktExtensions, + ).finally(() => { + this._recreateLauncherPromise = null; + }); + return this._recreateLauncherPromise; + } + private initRoktLauncher(launcher: RoktLauncher): void { // Assign the launcher to a global variable for later access if (window.Rokt) { @@ -1425,9 +1458,23 @@ class RoktKit implements KitInterface { * rejects it as the awaited return of an async function (TS1058) — * working around that would require a cast or wrapping every return in * `Promise.resolve(...)`. The inner work runs in `_dispatchPlacements`; - * this wrapper just gates it on the in-flight search via `Promise.race`. + * this wrapper just gates it on the in-flight search via `Promise.race`, + * and on a post-terminate createLauncher when the SPA needs a new instance. */ public selectPlacements(options: Record): RoktSelection | Promise | undefined { + const recreate = this.recreateLauncherIfTerminated(); + if (recreate) { + const inFlight = this._workspaceSearchInFlightPromise; + const waitForSearch = inFlight + ? Promise.race([ + inFlight, + new Promise((resolve) => setTimeout(resolve, WORKSPACE_SEARCH_SELECT_TIMEOUT_MS)), + ]) + : Promise.resolve(); + return Promise.all([recreate, waitForSearch]).then(() => + this._dispatchPlacements(options), + ) as Promise; + } if (this._workspaceSearchInFlightPromise) { const inFlight = this._workspaceSearchInFlightPromise; return Promise.race([ @@ -1525,18 +1572,18 @@ class RoktKit implements KitInterface { /** * Tears down the Rokt launcher and the placements it rendered. * - * The launcher reference is deliberately left in place. The Rokt Web SDK - * memoizes a single launcher per page, so clearing it here could not buy the - * caller a fresh one — it would only flip the kit to not-ready and leave - * later calls queued forever. Leaving state untouched makes this exactly - * equivalent to the `window.Rokt.currentLauncher.terminate()` call partners - * use today, just reachable through a supported API. + * The kit's launcher reference is left in place so isKitReady() stays true. + * The Web SDK clears its memoized launcher on terminate, so a later + * createLauncher (SPA navigation) produces a new instance. The next + * selectPlacements call re-attaches that instance. Nulling the reference + * here would flip the kit to not-ready with no drain path for queued calls. */ public terminate(): Promise { if (!this.isKitReady()) { console.error('Rokt Kit: Not initialized'); return Promise.resolve(); } + this._launcherTerminated = true; return this.launcher!.terminate(); } diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 95552d6..e3a0cc6 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -216,6 +216,8 @@ describe('Rokt Forwarder', () => { afterEach(() => { (window as any).mParticle.forwarder.userAttributes = {}; + (window as any).mParticle.forwarder._launcherTerminated = false; + (window as any).mParticle.forwarder._recreateLauncherPromise = null; delete (window as any).mParticle.forwarder.launcherOptions; delete (window as any).mParticle.Rokt.launcherOptions; }); @@ -3328,9 +3330,8 @@ describe('Rokt Forwarder', () => { expect(errorMessage).toBe('Rokt Kit: Not initialized'); }); - // The Rokt Web SDK memoizes one launcher per page, so terminate must not - // clear the kit's launcher references — doing so would flip the kit to - // not-ready with no way back. + // Leave the kit ready after terminate. A later createLauncher (SPA) can + // still mint a new instance because the Web SDK drops its memoized launcher. it('should leave the launcher references intact so the kit stays ready', async () => { const launcher = { terminate: () => Promise.resolve(), @@ -3345,6 +3346,72 @@ describe('Rokt Forwarder', () => { expect((window as any).Rokt.currentLauncher).toBe(launcher); }); + it('should create a new launcher instance and continue after terminate', async () => { + let createCount = 0; + const launchers: any[] = []; + + (window as any).mParticle.Rokt.filters = { + userAttributesFilters: [], + filterUserAttributes: function (attributes: any) { + return attributes; + }, + filteredUser: { + getMPID: function () { + return '123'; + }, + }, + }; + + (window as any).Rokt.createLauncher = async function () { + createCount += 1; + const id = createCount; + const launcher = { + id, + terminate: () => Promise.resolve(), + selectPlacements: function (opts: any) { + (window as any).Rokt.selectPlacementsCalled = true; + (window as any).Rokt.selectPlacementsLauncherId = id; + (window as any).Rokt.selectPlacementsOptions = opts; + }, + }; + launchers.push(launcher); + return launcher; + }; + + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + const firstLauncher = (window as any).mParticle.forwarder.launcher; + expect(firstLauncher).toBe(launchers[0]); + + await (window as any).mParticle.forwarder.terminate(); + + expect((window as any).mParticle.forwarder.launcher).toBe(firstLauncher); + + (window as any).mParticle.Rokt.attachKitCalled = false; + + await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + const secondLauncher = (window as any).mParticle.forwarder.launcher; + expect(createCount).toBe(2); + expect(secondLauncher).toBe(launchers[1]); + expect(secondLauncher).not.toBe(firstLauncher); + expect((window as any).Rokt.currentLauncher).toBe(secondLauncher); + expect((window as any).Rokt.selectPlacementsCalled).toBe(true); + expect((window as any).Rokt.selectPlacementsLauncherId).toBe(2); + }); + it('should call launcher.terminate after init (test mode) and attach', async () => { let terminateCalled = false; From 413ec970ac2065d79c7bcd332b8e39ffd9543272 Mon Sep 17 00:00:00 2001 From: Matt Bodle Date: Sat, 22 Aug 2026 11:13:26 +1000 Subject: [PATCH 3/4] refactor: extract typed launcher attach state for post-terminate recreate Move the SPA recreate path onto a LauncherAttachContext / lifecycle state module so createLauncher options and Window.Rokt stay typed. --- src/Rokt-Kit.ts | 66 +++++++------ src/launcherAttachState.ts | 74 +++++++++++++++ test/src/launcherAttachState.spec.ts | 133 +++++++++++++++++++++++++++ test/src/tests.spec.ts | 21 +++-- 4 files changed, 251 insertions(+), 43 deletions(-) create mode 100644 src/launcherAttachState.ts create mode 100644 test/src/launcherAttachState.spec.ts diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 310e3ae..1cea604 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -36,6 +36,17 @@ import { import { isLocalStorageAvailable } from './storage'; import { isObject, isString, isEmpty, isFunction, sanitizeUrl } from './utils'; +import { + createLauncherAttachState, + markLauncherAttached, + markLauncherAttachFailed, + markLauncherTerminated, + rememberAttachContext, + recreateIfTerminated, + resetLauncherAttachState, + type LauncherAttachState, + type RoktLauncherOptions, +} from './launcherAttachState'; interface RoktKitSettings { accountId: string; @@ -90,8 +101,8 @@ interface RoktLauncher { } interface RoktGlobal { - createLauncher(options: Record): Promise; - createLocalLauncher(options: Record): RoktLauncher; + createLauncher(options: RoktLauncherOptions): Promise; + createLocalLauncher(options: RoktLauncherOptions): RoktLauncher; currentLauncher?: RoktLauncher; setExtensionData(data: Record): void; } @@ -192,6 +203,7 @@ interface TestHelpers { RateLimiter: typeof RateLimiter; ErrorCodes: typeof ErrorCodes; WSDKErrorSeverity: typeof WSDKErrorSeverity; + resetLauncherAttachState: () => void; } interface ForwarderRegistration { @@ -782,11 +794,7 @@ class RoktKit implements KitInterface { // so a re-login re-evaluates fresh. private _workspaceLastSearchedIdentitiesKey?: string; - private _attachAccountId?: string; - private _attachLauncherOptions: Record = {}; - private _attachLegacyRoktExtensions: string[] = []; - private _launcherTerminated = false; - private _recreateLauncherPromise: Promise | null = null; + private _launcherAttachState: LauncherAttachState = createLauncherAttachState(); // ---- Private helpers ---- @@ -988,15 +996,16 @@ class RoktKit implements KitInterface { private attachLauncher( accountId: string, - launcherOptions: Record, - legacyRoktExtensions: string[] = [], + launcherOptions: RoktLauncherOptions, + legacyRoktExtensions: readonly string[] = [], ): Promise { - this._attachAccountId = accountId; - this._attachLauncherOptions = launcherOptions || {}; - this._attachLegacyRoktExtensions = legacyRoktExtensions; - this._launcherTerminated = false; + rememberAttachContext(this._launcherAttachState, { + accountId, + launcherOptions: launcherOptions || {}, + legacyRoktExtensions, + }); - const options: Record = { + const options: RoktLauncherOptions = { accountId, ...(launcherOptions || {}), }; @@ -1010,34 +1019,19 @@ class RoktKit implements KitInterface { return launcherPromise .then(async (launcher) => { - await registerLegacyExtensions(legacyRoktExtensions, launcher); + await registerLegacyExtensions([...legacyRoktExtensions], launcher); this.initRoktLauncher(launcher); }) .catch((err: unknown) => { - this._launcherTerminated = true; + markLauncherAttachFailed(this._launcherAttachState); console.error('Error creating Rokt launcher:', err); }); } private recreateLauncherIfTerminated(): Promise | undefined { - if (!this._launcherTerminated) { - return undefined; - } - if (this._recreateLauncherPromise) { - return this._recreateLauncherPromise; - } - if (!this._attachAccountId || !this.isLauncherReadyToAttach()) { - return undefined; - } - - this._recreateLauncherPromise = this.attachLauncher( - this._attachAccountId, - this._attachLauncherOptions, - this._attachLegacyRoktExtensions, - ).finally(() => { - this._recreateLauncherPromise = null; - }); - return this._recreateLauncherPromise; + return recreateIfTerminated(this._launcherAttachState, this.isLauncherReadyToAttach(), (context) => + this.attachLauncher(context.accountId, context.launcherOptions, context.legacyRoktExtensions), + ); } private initRoktLauncher(launcher: RoktLauncher): void { @@ -1047,6 +1041,7 @@ class RoktKit implements KitInterface { } // Locally cache the launcher and filters this.launcher = launcher; + markLauncherAttached(this._launcherAttachState); const roktFilters = mp().Rokt?.filters; @@ -1230,6 +1225,7 @@ class RoktKit implements KitInterface { RateLimiter: RateLimiter, ErrorCodes: ErrorCodes, WSDKErrorSeverity: WSDKErrorSeverity, + resetLauncherAttachState: () => resetLauncherAttachState(this._launcherAttachState), }; this.attachLauncher(accountId, launcherOptions); return 'Successfully initialized: ' + name; @@ -1583,7 +1579,7 @@ class RoktKit implements KitInterface { console.error('Rokt Kit: Not initialized'); return Promise.resolve(); } - this._launcherTerminated = true; + markLauncherTerminated(this._launcherAttachState); return this.launcher!.terminate(); } diff --git a/src/launcherAttachState.ts b/src/launcherAttachState.ts new file mode 100644 index 0000000..2b776ec --- /dev/null +++ b/src/launcherAttachState.ts @@ -0,0 +1,74 @@ +export type RoktLauncherOptions = Record; + +export interface LauncherAttachContext { + accountId: string; + launcherOptions: RoktLauncherOptions; + legacyRoktExtensions: readonly string[]; +} + +export type LauncherLifecycle = 'idle' | 'attached' | 'terminated' | 'recreating'; + +export interface LauncherAttachState { + context: LauncherAttachContext | null; + lifecycle: LauncherLifecycle; + recreateInFlight: Promise | null; +} + +export type AttachLauncher = (context: LauncherAttachContext) => Promise; + +export function createLauncherAttachState(): LauncherAttachState { + return { + context: null, + lifecycle: 'idle', + recreateInFlight: null, + }; +} + +export function rememberAttachContext(state: LauncherAttachState, context: LauncherAttachContext): void { + state.context = { + accountId: context.accountId, + launcherOptions: { ...context.launcherOptions }, + legacyRoktExtensions: [...context.legacyRoktExtensions], + }; +} + +export function markLauncherAttached(state: LauncherAttachState): void { + state.lifecycle = 'attached'; +} + +export function markLauncherTerminated(state: LauncherAttachState): void { + if (state.lifecycle === 'idle') { + return; + } + state.lifecycle = 'terminated'; +} + +export function markLauncherAttachFailed(state: LauncherAttachState): void { + state.lifecycle = 'terminated'; +} + +export function resetLauncherAttachState(state: LauncherAttachState): void { + state.context = null; + state.lifecycle = 'idle'; + state.recreateInFlight = null; +} + +export function recreateIfTerminated( + state: LauncherAttachState, + canAttach: boolean, + attach: AttachLauncher, +): Promise | undefined { + if (state.recreateInFlight) { + return state.recreateInFlight; + } + if (state.lifecycle !== 'terminated' || !state.context || !canAttach) { + return undefined; + } + + state.lifecycle = 'recreating'; + const context = state.context; + state.recreateInFlight = attach(context).finally(() => { + state.recreateInFlight = null; + }); + return state.recreateInFlight; +} diff --git a/test/src/launcherAttachState.spec.ts b/test/src/launcherAttachState.spec.ts new file mode 100644 index 0000000..5efbe36 --- /dev/null +++ b/test/src/launcherAttachState.spec.ts @@ -0,0 +1,133 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + createLauncherAttachState, + rememberAttachContext, + markLauncherAttached, + markLauncherTerminated, + markLauncherAttachFailed, + resetLauncherAttachState, + recreateIfTerminated, + type LauncherAttachContext, +} from '../../src/launcherAttachState'; + +function context(overrides: Partial = {}): LauncherAttachContext { + return { + accountId: 'acct-1', + launcherOptions: { sandbox: true }, + legacyRoktExtensions: ['ext-a'], + ...overrides, + }; +} + +describe('launcherAttachState', () => { + it('starts idle with no context', () => { + const state = createLauncherAttachState(); + + expect(state.lifecycle).toBe('idle'); + expect(state.context).toBeNull(); + expect(state.recreateInFlight).toBeNull(); + }); + + it('copies attach context so later mutations do not leak', () => { + const state = createLauncherAttachState(); + const launcherOptions = { sandbox: true }; + const legacyRoktExtensions = ['ext-a']; + + rememberAttachContext(state, { accountId: 'acct-1', launcherOptions, legacyRoktExtensions }); + launcherOptions.sandbox = false; + legacyRoktExtensions.push('ext-b'); + + expect(state.context).toEqual({ + accountId: 'acct-1', + launcherOptions: { sandbox: true }, + legacyRoktExtensions: ['ext-a'], + }); + }); + + it('does not treat idle as terminated', () => { + const state = createLauncherAttachState(); + + markLauncherTerminated(state); + + expect(state.lifecycle).toBe('idle'); + expect(recreateIfTerminated(state, true, vi.fn())).toBeUndefined(); + }); + + it('recreates once after terminate and coalesces concurrent callers', async () => { + const state = createLauncherAttachState(); + rememberAttachContext(state, context()); + markLauncherAttached(state); + markLauncherTerminated(state); + + let resolveAttach: () => void = () => undefined; + const attach = vi.fn( + () => + new Promise((resolve) => { + resolveAttach = resolve; + }), + ); + + const first = recreateIfTerminated(state, true, attach); + const second = recreateIfTerminated(state, true, attach); + + expect(first).toBeInstanceOf(Promise); + expect(second).toBe(first); + expect(attach).toHaveBeenCalledTimes(1); + expect(attach).toHaveBeenCalledWith({ + accountId: 'acct-1', + launcherOptions: { sandbox: true }, + legacyRoktExtensions: ['ext-a'], + }); + expect(state.lifecycle).toBe('recreating'); + + resolveAttach(); + await first; + + expect(state.recreateInFlight).toBeNull(); + }); + + it('does not recreate without context or when attach is unavailable', () => { + const attach = vi.fn(); + const noContext = createLauncherAttachState(); + noContext.lifecycle = 'terminated'; + + expect(recreateIfTerminated(noContext, true, attach)).toBeUndefined(); + + const blocked = createLauncherAttachState(); + rememberAttachContext(blocked, context()); + markLauncherAttached(blocked); + markLauncherTerminated(blocked); + + expect(recreateIfTerminated(blocked, false, attach)).toBeUndefined(); + expect(attach).not.toHaveBeenCalled(); + }); + + it('can retry after a failed attach', async () => { + const state = createLauncherAttachState(); + rememberAttachContext(state, context()); + markLauncherAttached(state); + markLauncherTerminated(state); + + const firstAttach = vi.fn().mockRejectedValue(new Error('createLauncher failed')); + const failed = recreateIfTerminated(state, true, firstAttach); + await expect(failed).rejects.toThrow('createLauncher failed'); + markLauncherAttachFailed(state); + + const secondAttach = vi.fn().mockResolvedValue(undefined); + const retried = recreateIfTerminated(state, true, secondAttach); + await retried; + + expect(secondAttach).toHaveBeenCalledTimes(1); + }); + + it('resets to idle', () => { + const state = createLauncherAttachState(); + rememberAttachContext(state, context()); + markLauncherAttached(state); + markLauncherTerminated(state); + + resetLauncherAttachState(state); + + expect(state).toEqual(createLauncherAttachState()); + }); +}); diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index e3a0cc6..6bd2f3c 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -216,8 +216,7 @@ describe('Rokt Forwarder', () => { afterEach(() => { (window as any).mParticle.forwarder.userAttributes = {}; - (window as any).mParticle.forwarder._launcherTerminated = false; - (window as any).mParticle.forwarder._recreateLauncherPromise = null; + (window as any).mParticle.forwarder.testHelpers?.resetLauncherAttachState(); delete (window as any).mParticle.forwarder.launcherOptions; delete (window as any).mParticle.Rokt.launcherOptions; }); @@ -3347,12 +3346,18 @@ describe('Rokt Forwarder', () => { }); it('should create a new launcher instance and continue after terminate', async () => { + interface SpaTestLauncher { + id: number; + terminate: () => Promise; + selectPlacements: (options: Record) => void; + } + let createCount = 0; - const launchers: any[] = []; + const launchers: SpaTestLauncher[] = []; (window as any).mParticle.Rokt.filters = { userAttributesFilters: [], - filterUserAttributes: function (attributes: any) { + filterUserAttributes: function (attributes: Record) { return attributes; }, filteredUser: { @@ -3362,16 +3367,16 @@ describe('Rokt Forwarder', () => { }, }; - (window as any).Rokt.createLauncher = async function () { + (window as any).Rokt.createLauncher = async function (): Promise { createCount += 1; const id = createCount; - const launcher = { + const launcher: SpaTestLauncher = { id, terminate: () => Promise.resolve(), - selectPlacements: function (opts: any) { + selectPlacements: function (options: Record) { (window as any).Rokt.selectPlacementsCalled = true; (window as any).Rokt.selectPlacementsLauncherId = id; - (window as any).Rokt.selectPlacementsOptions = opts; + (window as any).Rokt.selectPlacementsOptions = options; }, }; launchers.push(launcher); From d74e1af1367cc5c1293dc802e290be03b4c55fbe Mon Sep 17 00:00:00 2001 From: Matt Bodle Date: Sat, 22 Aug 2026 11:15:37 +1000 Subject: [PATCH 4/4] test: drop any from the terminate suite Type the #terminate window/kit/launcher surface so the new tests no longer cast through any. --- test/src/tests.spec.ts | 177 +++++++++++++++++++++++++---------------- 1 file changed, 110 insertions(+), 67 deletions(-) diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 6bd2f3c..6eabbc4 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -178,7 +178,7 @@ describe('Rokt Forwarder', () => { }; this.currentLauncher = function () {}; - }; + } as unknown as { new (): Record }; beforeAll(async () => { (window as any).Rokt = new (MockRoktForwarder as any)(); @@ -3251,58 +3251,108 @@ describe('Rokt Forwarder', () => { }); describe('#terminate', () => { + interface TerminateTestLauncher { + id?: number; + terminate: () => Promise; + selectPlacements?: (options: Record) => unknown; + } + + interface TerminateTestKit { + isInitialized: boolean; + launcher: TerminateTestLauncher | null; + terminate: () => Promise; + init: ( + settings: Record, + service: unknown, + testMode: boolean, + trackerId: null, + filteredUserAttributes: Record, + ) => string; + selectPlacements: (options: Record) => unknown; + } + + interface TerminateTestRokt { + currentLauncher?: TerminateTestLauncher; + createLauncher: (options?: Record) => Promise; + attachKitCalled: boolean; + attachKit: (kit: TerminateTestKit) => Promise; + kit?: TerminateTestKit; + filters?: { + userAttributesFilters: unknown[]; + filterUserAttributes: (attributes: Record) => Record; + filteredUser: { getMPID: () => string }; + }; + selectPlacementsCalled?: boolean; + selectPlacementsLauncherId?: number; + selectPlacementsOptions?: Record; + } + + interface TerminateTestWindow { + Rokt: TerminateTestRokt; + mParticle: { + Rokt: TerminateTestRokt; + forwarder: TerminateTestKit; + }; + } + + function tw(): TerminateTestWindow { + return window as unknown as TerminateTestWindow; + } + beforeEach(() => { - (window as any).Rokt = new (MockRoktForwarder as any)(); - (window as any).mParticle.Rokt = (window as any).Rokt; - (window as any).mParticle.Rokt.attachKitCalled = false; - (window as any).mParticle.Rokt.attachKit = async (kit: any) => { - (window as any).mParticle.Rokt.attachKitCalled = true; - (window as any).mParticle.Rokt.kit = kit; - Promise.resolve(); + const rokt = new MockRoktForwarder() as unknown as TerminateTestRokt; + tw().Rokt = rokt; + tw().mParticle.Rokt = rokt; + tw().mParticle.Rokt.attachKitCalled = false; + tw().mParticle.Rokt.attachKit = async (kit: TerminateTestKit) => { + tw().mParticle.Rokt.attachKitCalled = true; + tw().mParticle.Rokt.kit = kit; }; }); it('should call launcher.terminate when fully initialized', async () => { let terminateCalled = false; - (window as any).mParticle.forwarder.isInitialized = true; - (window as any).mParticle.forwarder.launcher = { + tw().mParticle.forwarder.isInitialized = true; + tw().mParticle.forwarder.launcher = { terminate: function () { terminateCalled = true; return Promise.resolve(); }, }; - await (window as any).mParticle.forwarder.terminate(); + await tw().mParticle.forwarder.terminate(); expect(terminateCalled).toBe(true); }); it('should return the promise from launcher.terminate', async () => { let resolved = false; - (window as any).mParticle.forwarder.isInitialized = true; - (window as any).mParticle.forwarder.launcher = { + tw().mParticle.forwarder.isInitialized = true; + tw().mParticle.forwarder.launcher = { terminate: () => new Promise((resolve) => setTimeout(resolve, 0)), }; - await (window as any).mParticle.forwarder.terminate().then(() => { - resolved = true; - }); + await tw() + .mParticle.forwarder.terminate() + .then(() => { + resolved = true; + }); expect(resolved).toBe(true); }); it('should resolve without calling the launcher when called before initialization', async () => { const originalConsoleError = window.console.error; - let errorMessage = null; - window.console.error = function (message: any) { + let errorMessage: string | null = null; + window.console.error = function (message: string) { errorMessage = message; }; - (window as any).mParticle.forwarder.isInitialized = false; - (window as any).mParticle.forwarder.launcher = null; + tw().mParticle.forwarder.isInitialized = false; + tw().mParticle.forwarder.launcher = null; try { - await expect((window as any).mParticle.forwarder.terminate()).resolves.toBeUndefined(); + await expect(tw().mParticle.forwarder.terminate()).resolves.toBeUndefined(); } finally { window.console.error = originalConsoleError; } @@ -3312,16 +3362,16 @@ describe('Rokt Forwarder', () => { it('should resolve without calling the launcher when initialized but the launcher is missing', async () => { const originalConsoleError = window.console.error; - let errorMessage = null; - window.console.error = function (message: any) { + let errorMessage: string | null = null; + window.console.error = function (message: string) { errorMessage = message; }; - (window as any).mParticle.forwarder.isInitialized = true; - (window as any).mParticle.forwarder.launcher = null; + tw().mParticle.forwarder.isInitialized = true; + tw().mParticle.forwarder.launcher = null; try { - await expect((window as any).mParticle.forwarder.terminate()).resolves.toBeUndefined(); + await expect(tw().mParticle.forwarder.terminate()).resolves.toBeUndefined(); } finally { window.console.error = originalConsoleError; } @@ -3332,30 +3382,24 @@ describe('Rokt Forwarder', () => { // Leave the kit ready after terminate. A later createLauncher (SPA) can // still mint a new instance because the Web SDK drops its memoized launcher. it('should leave the launcher references intact so the kit stays ready', async () => { - const launcher = { + const launcher: TerminateTestLauncher = { terminate: () => Promise.resolve(), }; - (window as any).mParticle.forwarder.isInitialized = true; - (window as any).mParticle.forwarder.launcher = launcher; - (window as any).Rokt.currentLauncher = launcher; + tw().mParticle.forwarder.isInitialized = true; + tw().mParticle.forwarder.launcher = launcher; + tw().Rokt.currentLauncher = launcher; - await (window as any).mParticle.forwarder.terminate(); + await tw().mParticle.forwarder.terminate(); - expect((window as any).mParticle.forwarder.launcher).toBe(launcher); - expect((window as any).Rokt.currentLauncher).toBe(launcher); + expect(tw().mParticle.forwarder.launcher).toBe(launcher); + expect(tw().Rokt.currentLauncher).toBe(launcher); }); it('should create a new launcher instance and continue after terminate', async () => { - interface SpaTestLauncher { - id: number; - terminate: () => Promise; - selectPlacements: (options: Record) => void; - } - let createCount = 0; - const launchers: SpaTestLauncher[] = []; + const launchers: TerminateTestLauncher[] = []; - (window as any).mParticle.Rokt.filters = { + tw().mParticle.Rokt.filters = { userAttributesFilters: [], filterUserAttributes: function (attributes: Record) { return attributes; @@ -3367,23 +3411,23 @@ describe('Rokt Forwarder', () => { }, }; - (window as any).Rokt.createLauncher = async function (): Promise { + tw().Rokt.createLauncher = async function (): Promise { createCount += 1; const id = createCount; - const launcher: SpaTestLauncher = { + const launcher: TerminateTestLauncher = { id, terminate: () => Promise.resolve(), selectPlacements: function (options: Record) { - (window as any).Rokt.selectPlacementsCalled = true; - (window as any).Rokt.selectPlacementsLauncherId = id; - (window as any).Rokt.selectPlacementsOptions = options; + tw().Rokt.selectPlacementsCalled = true; + tw().Rokt.selectPlacementsLauncherId = id; + tw().Rokt.selectPlacementsOptions = options; }, }; launchers.push(launcher); return launcher; }; - await (window as any).mParticle.forwarder.init( + await tw().mParticle.forwarder.init( { accountId: '123456', }, @@ -3393,41 +3437,40 @@ describe('Rokt Forwarder', () => { {}, ); - await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + await waitForCondition(() => tw().mParticle.Rokt.attachKitCalled); - const firstLauncher = (window as any).mParticle.forwarder.launcher; + const firstLauncher = tw().mParticle.forwarder.launcher; expect(firstLauncher).toBe(launchers[0]); - await (window as any).mParticle.forwarder.terminate(); + await tw().mParticle.forwarder.terminate(); - expect((window as any).mParticle.forwarder.launcher).toBe(firstLauncher); + expect(tw().mParticle.forwarder.launcher).toBe(firstLauncher); - (window as any).mParticle.Rokt.attachKitCalled = false; + tw().mParticle.Rokt.attachKitCalled = false; - await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + await tw().mParticle.forwarder.selectPlacements({ attributes: {} }); - await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + await waitForCondition(() => tw().mParticle.Rokt.attachKitCalled); - const secondLauncher = (window as any).mParticle.forwarder.launcher; + const secondLauncher = tw().mParticle.forwarder.launcher; expect(createCount).toBe(2); expect(secondLauncher).toBe(launchers[1]); expect(secondLauncher).not.toBe(firstLauncher); - expect((window as any).Rokt.currentLauncher).toBe(secondLauncher); - expect((window as any).Rokt.selectPlacementsCalled).toBe(true); - expect((window as any).Rokt.selectPlacementsLauncherId).toBe(2); + expect(tw().Rokt.currentLauncher).toBe(secondLauncher); + expect(tw().Rokt.selectPlacementsCalled).toBe(true); + expect(tw().Rokt.selectPlacementsLauncherId).toBe(2); }); it('should call launcher.terminate after init (test mode) and attach', async () => { let terminateCalled = false; - (window as any).mParticle.Rokt.attachKitCalled = false; - (window as any).mParticle.Rokt.attachKit = async (kit: any) => { - (window as any).mParticle.Rokt.attachKitCalled = true; - (window as any).mParticle.Rokt.kit = kit; - Promise.resolve(); + tw().mParticle.Rokt.attachKitCalled = false; + tw().mParticle.Rokt.attachKit = async (kit: TerminateTestKit) => { + tw().mParticle.Rokt.attachKitCalled = true; + tw().mParticle.Rokt.kit = kit; }; - (window as any).Rokt.createLauncher = async function () { + tw().Rokt.createLauncher = async function () { return Promise.resolve({ terminate: function () { terminateCalled = true; @@ -3436,7 +3479,7 @@ describe('Rokt Forwarder', () => { }); }; - await (window as any).mParticle.forwarder.init( + await tw().mParticle.forwarder.init( { accountId: '123456', }, @@ -3446,9 +3489,9 @@ describe('Rokt Forwarder', () => { {}, ); - await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + await waitForCondition(() => tw().mParticle.Rokt.attachKitCalled); - await (window as any).mParticle.forwarder.terminate(); + await tw().mParticle.forwarder.terminate(); expect(terminateCalled).toBe(true); });