diff --git a/src/roktManager.ts b/src/roktManager.ts index ae6cb64f0..55681d0b0 100644 --- a/src/roktManager.ts +++ b/src/roktManager.ts @@ -83,6 +83,9 @@ export interface IRoktKit { setExtensionData(extensionData: IRoktPartnerExtensionData): void; use: (name: string) => Promise; onShoppableAdsReady(callback: () => void): void; + // Optional because the Rokt Kit ships on its own release cadence; a kit + // published before terminate() existed will not implement it. + terminate?: () => Promise; launcherOptions?: Dictionary; settings?: IRoktKitSettings; integrationName?: string; @@ -479,6 +482,40 @@ export default class RoktManager { } } + /** + * Tears down the current Rokt launcher and removes the placements it rendered. + * + * Intended for hosts that cannot simply destroy the container element — + * SPA route changes and confirmation pages, for example. + * + * Unlike the other proxied methods, a call made before the kit is ready is + * *not* queued. Replaying a teardown against a launcher created later would + * silently tear down placements the caller never asked to remove; with no + * launcher there is nothing to terminate, so this resolves as a no-op. + * + * @example + * await window.mParticle.Rokt.terminate(); + * + * @returns {Promise} Resolves once the launcher has torn down. Never rejects. + */ + public async terminate(): Promise { + if (!this.isReady()) { + this.logger?.verbose('mParticle.Rokt.terminate called before the Rokt kit was ready. Nothing to terminate.'); + return; + } + + if (!isFunction(this.kit.terminate)) { + this.logger?.error('mParticle.Rokt.terminate is not supported by the attached Rokt Kit version.'); + return; + } + + try { + await this.kit.terminate(); + } catch (error) { + this.logger?.error(`Failed to terminate the Rokt launcher: ${getErrorMessage(error)}`); + } + } + public onShoppableAdsReady(callback: () => void): void { if (!this.kit || !this.isShoppableAdsLoaded) { this.deferredCall('onShoppableAdsReady', callback); diff --git a/test/jest/roktManager.spec.ts b/test/jest/roktManager.spec.ts index 974938617..6ea2bcb0d 100644 --- a/test/jest/roktManager.spec.ts +++ b/test/jest/roktManager.spec.ts @@ -3007,6 +3007,106 @@ describe('RoktManager', () => { }); }); + describe('#terminate', () => { + const makeKit = (): IRoktKit => ({ + launcher: { + selectPlacements: jest.fn(), + hashAttributes: jest.fn(), + use: jest.fn(), + }, + filters: undefined, + filteredUser: undefined, + userAttributes: undefined, + selectPlacements: jest.fn(), + hashAttributes: jest.fn(), + setExtensionData: jest.fn(), + use: jest.fn(), + onShoppableAdsReady: jest.fn(), + terminate: jest.fn().mockResolvedValue(undefined), + }); + + it('should call kit.terminate when a kit and launcher are attached', async () => { + const kit = makeKit(); + roktManager.attachKit(kit); + + await roktManager.terminate(); + + expect(kit.terminate).toHaveBeenCalledTimes(1); + }); + + it('should await the kit terminate promise before resolving', async () => { + const kit = makeKit(); + let settled = false; + (kit.terminate as jest.Mock).mockImplementation( + () => new Promise(resolve => setTimeout(() => { + settled = true; + resolve(); + }, 0)) + ); + roktManager.attachKit(kit); + + await roktManager.terminate(); + + expect(settled).toBe(true); + }); + + it('should resolve as a no-op and not queue when no kit is attached', async () => { + await expect(roktManager.terminate()).resolves.toBeUndefined(); + + expect(roktManager['kit']).toBeNull(); + expect(roktManager['messageQueue'].size).toBe(0); + expect(mockMPInstance.Logger.verbose).toHaveBeenCalledWith( + 'mParticle.Rokt.terminate called before the Rokt kit was ready. Nothing to terminate.' + ); + }); + + it('should resolve as a no-op when a kit is attached without a launcher', async () => { + const kit = makeKit(); + kit.launcher = null; + roktManager.attachKit(kit); + + await expect(roktManager.terminate()).resolves.toBeUndefined(); + + expect(kit.terminate).not.toHaveBeenCalled(); + expect(roktManager['messageQueue'].size).toBe(0); + }); + + it('should log an error when the attached kit predates terminate support', async () => { + const kit = makeKit(); + delete kit.terminate; + roktManager.attachKit(kit); + + await expect(roktManager.terminate()).resolves.toBeUndefined(); + + expect(mockMPInstance.Logger.error).toHaveBeenCalledWith( + 'mParticle.Rokt.terminate is not supported by the attached Rokt Kit version.' + ); + }); + + it('should log an error and resolve when kit.terminate rejects', async () => { + const kit = makeKit(); + (kit.terminate as jest.Mock).mockRejectedValue(new Error('teardown failed')); + roktManager.attachKit(kit); + + await expect(roktManager.terminate()).resolves.toBeUndefined(); + + expect(mockMPInstance.Logger.error).toHaveBeenCalledWith( + 'Failed to terminate the Rokt launcher: teardown failed' + ); + }); + + it('should stay ready so repeat calls keep reaching the kit', async () => { + const kit = makeKit(); + roktManager.attachKit(kit); + + await roktManager.terminate(); + await roktManager.terminate(); + + expect(roktManager.isReady()).toBe(true); + expect(kit.terminate).toHaveBeenCalledTimes(2); + }); + }); + describe('#onShoppableAdsReady', () => { const makeKit = (): IRoktKit => ({ launcher: {