diff --git a/AGENTS.md b/AGENTS.md index bbfd3fb..7317c26 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,14 +22,17 @@ The Rokt web kit (`@mparticle/web-rokt-kit`) is an mParticle integration kit (fo ``` / src/ - Rokt-Kit.ts # Single monolithic source file + Rokt-Kit.ts # Main kit source (forwarder class + registration) + storage.ts # Key-agnostic localStorage helpers (readJSON/writeJSON/removeKey) + selectPlacementsAttributePersistence.ts # Attribute persistence deny-list dist/ Rokt-Kit.iife.js # Browser bundle (IIFE) Rokt-Kit.common.js # npm bundle (CommonJS) Rokt-Kit.d.ts # Type definitions test/ src/ - tests.spec.ts # Vitest test suite + tests.spec.ts # Main Vitest test suite (Rokt-Kit.ts) + storage.spec.ts # Unit tests for storage.ts helpers vitest.setup.ts # Global test setup / mParticle mock lib/ # Test utilities end-to-end-testapp/ # E2E test app @@ -58,11 +61,12 @@ The `dist/` folder, `CHANGELOG.md`, and version bumps in `package.json`/`package ## Code Conventions -- **Single source file**: All kit logic lives in `src/Rokt-Kit.ts` +- **Prefer small, focused modules**: `src/Rokt-Kit.ts` is the entry point (forwarder class + registration), but favor extracting cohesive concerns into sibling modules (as with `storage.ts`, `selectPlacementsAttributePersistence.ts`) rather than growing `Rokt-Kit.ts`. Vite/Rollup bundles all source files into the single `dist/` output, so extraction is free — it doesn't change the shipped bundle shape. When you add or touch a self-contained concern (storage, serialization, a deny-list, event mapping, etc.), pull it into its own module with a clear name and a co-located `*.spec.ts`. Keep only orchestration and kit lifecycle in `Rokt-Kit.ts`. - **TypeScript class pattern**: `class RoktKit { ... }` with typed public/private members - **const/let**: Use `const` for values that don't change, `let` for reassignable variables - **Strict TypeScript**: `strict: true` — all values must be typed, no implicit `any` - **Module registration**: Kit self-registers via `window.mParticle.addForwarder()` at load time +- **No unnecessary comments**: Don't restate what the code already says. Reserve comments for non-obvious *why* — rationale, invariants, gotchas (e.g. why storage writes swallow errors, why a migration is byte-for-byte). Delete comments that a reader could infer from the code itself. ## Architecture @@ -81,7 +85,7 @@ The `dist/` folder, `CHANGELOG.md`, and version bumps in `package.json`/`package ## Common Gotchas -1. **Single file**: All changes go in `src/Rokt-Kit.ts` — there are no imports/modules +1. **Favor modular extraction**: `src/Rokt-Kit.ts` is the entry point, but prefer splitting self-contained concerns into sibling modules (e.g. `storage.ts`, `selectPlacementsAttributePersistence.ts`) rather than growing the entry file. Co-located `*.spec.ts` under `src/` are picked up by Vitest (see `vite.config.ts` `test.include`), so each extracted module can carry its own unit tests 2. **Browser-only**: Code runs in browser context, `window` is always available 3. **Async launcher**: Rokt launcher loads asynchronously — events must be queued until ready 4. **Window extensions**: `window.Rokt` and `window.mParticle.Rokt` are typed via `declare global` diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 3246b2f..2db1f88 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -24,6 +24,8 @@ import { removeSelectPlacementsAttributePersistenceDeniedAttributes, } from './selectPlacementsAttributePersistence'; +import { readJSON, removeKey, readNamespacedField, writeNamespacedField, removeNamespacedField } from './storage'; + interface RoktKitSettings { accountId: string; roktExtensions?: string; @@ -257,14 +259,10 @@ const USER_IDENTIFIED_IN_WORKSPACE_KEY = 'userIdentifiedInWorkspace'; const MESSAGE_TYPE_PAGE_VIEW = 3; // mParticle MessageType.PageView const MESSAGE_TYPE_SESSION_END = 2; // mParticle MessageType.SessionEnd -// localStorage key under which captured page views are persisted (as a JSON -// string). The kit owns this storage directly — separate from mParticle's -// cookie/localStorage — so page-view capture does not affect mParticle -// persistence or cookie sync. Distinct from PAGE_EVENTS_KEY, which is the -// flattened wire shape sent to Rokt on selectPlacements. -const LS_PAGE_VIEWS_KEY = 'mpPageViews'; -// Fixed cap on the number of persisted page views (oldest evicted first). Code -// constant, not a kit setting — change it here. +const LS_NAMESPACE_KEY = 'mp-rokt-kit'; +const LS_PAGE_VIEWS_FIELD = 'pageViews'; +// TODO: remove after 2027-02-11 — one-time migration of the legacy key. +const LEGACY_PAGE_VIEWS_KEY = 'mpPageViews'; const PAGE_VIEWS_MAX_COUNT = 25; const PAGE_EVENTS_KEY = 'page_events'; @@ -311,25 +309,47 @@ function mp(): MParticleExtended { // Module-level utility functions // ============================================================ -function readPageViewsStorage(): PageEvent[] { - try { - const stored = window.localStorage.getItem(LS_PAGE_VIEWS_KEY); - if (stored === null) { - return []; +// TODO: remove after 2027-02-11 — one-time migration of the legacy 'mpPageViews' +// key into the namespaced storage object's pageViews field. Everything +// migration-related is confined to this function + LEGACY_PAGE_VIEWS_KEY so it +// can be deleted as a single unit. +// Unconditional (no freshness gate): staleness is mParticle's job — a timed-out +// prior session fires SessionEnd (→ clear) before selectPlacements runs. +function migrateLegacyPageViewStorage(loggingService: LoggingService | null): void { + const legacyViews = readJSON(LEGACY_PAGE_VIEWS_KEY); + if (legacyViews === null) { + return; + } + + const alreadyMigrated = readNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD) !== undefined; + const needsMigration = !alreadyMigrated && Array.isArray(legacyViews); + + if (needsMigration) { + const migrated = writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, legacyViews); + if (!migrated) { + loggingService?.log({ + message: 'Rokt Kit: Failed to migrate legacy page-view storage; retaining legacy key for retry', + code: 'PAGE_VIEW_CAPTURE_FAILED', + }); + return; } - const parsed = JSON.parse(stored); - return Array.isArray(parsed) ? (parsed as PageEvent[]) : []; - } catch { - return []; } + + removeKey(LEGACY_PAGE_VIEWS_KEY); } -function writePageViewsStorage(pageViews: PageEvent[]): void { - window.localStorage.setItem(LS_PAGE_VIEWS_KEY, JSON.stringify(pageViews)); +function loadPageViews(loggingService: LoggingService | null): PageEvent[] { + migrateLegacyPageViewStorage(loggingService); + const stored = readNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD); + return Array.isArray(stored) ? (stored as PageEvent[]) : []; +} + +function writePageViewsStorage(pageViews: PageEvent[]): boolean { + return writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, pageViews); } function clearPageViewsStorage(): void { - window.localStorage.removeItem(LS_PAGE_VIEWS_KEY); + removeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD); } function generateLauncherScript(domain: string | undefined, extensions: string[]): string { @@ -909,7 +929,7 @@ class RoktKit implements KitInterface { try { pageUrl = sanitizeUrl(window.location.href); - const pageViews = readPageViewsStorage(); + const pageViews = loadPageViews(this.loggingService); const pageView: PageEvent = { pageUrl, @@ -927,13 +947,18 @@ class RoktKit implements KitInterface { pageViews.shift(); } - writePageViewsStorage(pageViews); + if (!writePageViewsStorage(pageViews)) { + this.loggingService?.log({ + message: `Rokt Kit: Failed to persist page view for ${pageUrl}`, + code: 'PAGE_VIEW_CAPTURE_FAILED', + }); + } } catch (err) { - this.errorReportingService?.report({ - message: `Rokt Kit: Failed to capture page view for ${pageUrl}`, + this.loggingService?.log({ + message: `Rokt Kit: Failed to capture page view for ${pageUrl}: ${ + err instanceof Error ? err.message : String(err) + }`, code: 'PAGE_VIEW_CAPTURE_FAILED', - severity: WSDKErrorSeverity.INFO, - stackTrace: err instanceof Error ? err.stack : undefined, }); } } @@ -1305,16 +1330,8 @@ class RoktKit implements KitInterface { } if (event.EventDataType === MESSAGE_TYPE_SESSION_END) { - try { - clearPageViewsStorage(); - } catch (err) { - this.errorReportingService?.report({ - message: 'Rokt Kit: Failed to clear page views on session end', - code: 'PAGE_VIEW_CAPTURE_FAILED', - severity: WSDKErrorSeverity.INFO, - stackTrace: err instanceof Error ? err.stack : undefined, - }); - } + migrateLegacyPageViewStorage(this.loggingService); + clearPageViewsStorage(); } } @@ -1533,7 +1550,7 @@ class RoktKit implements KitInterface { const filteredUserIdentities = this.returnUserIdentities(filteredUser); const sessionAttributes = this.returnLocalSessionAttributes(); - const pageEvents = this.buildPageEvents(readPageViewsStorage()); + const pageEvents = this.buildPageEvents(loadPageViews(this.loggingService)); const selectPlacementsAttributes: Record = { ...(filteredUserIdentities as Record), diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..9e06bd3 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,55 @@ +export function readJSON(key: string): unknown { + try { + const stored = window.localStorage.getItem(key); + return stored === null ? null : JSON.parse(stored); + } catch { + return null; + } +} + +export function writeJSON(key: string, value: unknown): boolean { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + return true; + } catch { + return false; + } +} + +export function removeKey(key: string): void { + try { + window.localStorage.removeItem(key); + } catch { + /* empty */ + } +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function readNamespacedField(namespaceKey: string, field: string): unknown { + const blob = readJSON(namespaceKey); + return isPlainObject(blob) ? blob[field] : undefined; +} + +export function writeNamespacedField(namespaceKey: string, field: string, value: unknown): boolean { + const blob = readJSON(namespaceKey); + const next = isPlainObject(blob) ? { ...blob } : {}; + next[field] = value; + return writeJSON(namespaceKey, next); +} + +export function removeNamespacedField(namespaceKey: string, field: string): void { + const blob = readJSON(namespaceKey); + if (!isPlainObject(blob) || !(field in blob)) { + return; + } + const next = { ...blob }; + delete next[field]; + if (Object.keys(next).length === 0) { + removeKey(namespaceKey); + } else { + writeJSON(namespaceKey, next); + } +} diff --git a/test/src/storage.spec.ts b/test/src/storage.spec.ts new file mode 100644 index 0000000..985ae9a --- /dev/null +++ b/test/src/storage.spec.ts @@ -0,0 +1,157 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + readJSON, + writeJSON, + removeKey, + readNamespacedField, + writeNamespacedField, + removeNamespacedField, +} from '../../src/storage'; + +describe('storage: key-agnostic localStorage helpers', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); + }); + + describe('readJSON', () => { + it('returns the parsed value for a stored JSON string', () => { + window.localStorage.setItem('k', JSON.stringify({ a: 1, b: [2, 3] })); + expect(readJSON('k')).toEqual({ a: 1, b: [2, 3] }); + }); + + it('round-trips values written by writeJSON', () => { + writeJSON('k', ['x', 'y']); + expect(readJSON('k')).toEqual(['x', 'y']); + }); + + it('returns null when the key is absent', () => { + expect(readJSON('missing')).toBeNull(); + }); + + it('returns null for malformed JSON (does not throw)', () => { + window.localStorage.setItem('k', '{not valid json'); + expect(readJSON('k')).toBeNull(); + }); + + it('returns null when getItem throws (access denied)', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('SecurityError'); + }); + expect(readJSON('k')).toBeNull(); + }); + }); + + describe('writeJSON', () => { + it('persists the value as a JSON string and returns true', () => { + expect(writeJSON('k', { hello: 'world' })).toBe(true); + expect(window.localStorage.getItem('k')).toBe(JSON.stringify({ hello: 'world' })); + }); + + it('returns false when setItem throws (quota exceeded / private mode)', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + expect(writeJSON('k', { hello: 'world' })).toBe(false); + }); + + it('overwrites an existing value', () => { + writeJSON('k', 1); + writeJSON('k', 2); + expect(readJSON('k')).toBe(2); + }); + }); + + describe('removeKey', () => { + it('removes the stored key', () => { + window.localStorage.setItem('k', '1'); + removeKey('k'); + expect(window.localStorage.getItem('k')).toBeNull(); + }); + + it('does not throw when removeItem throws', () => { + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => { + throw new Error('SecurityError'); + }); + expect(() => removeKey('k')).not.toThrow(); + }); + + it('is a no-op for an absent key', () => { + expect(() => removeKey('missing')).not.toThrow(); + }); + }); + + describe('namespaced fields', () => { + const NS = 'mp-rokt-kit'; + + it('writeNamespacedField stores the value under a field of the namespace object', () => { + expect(writeNamespacedField(NS, 'pageViews', [1, 2])).toBe(true); + expect(readJSON(NS)).toEqual({ pageViews: [1, 2] }); + }); + + it('readNamespacedField returns the stored field value', () => { + writeNamespacedField(NS, 'pageViews', ['a']); + expect(readNamespacedField(NS, 'pageViews')).toEqual(['a']); + }); + + it('preserves sibling fields on write (read-modify-write)', () => { + writeNamespacedField(NS, 'pageViews', ['a']); + writeNamespacedField(NS, 'other', { x: 1 }); + expect(readJSON(NS)).toEqual({ pageViews: ['a'], other: { x: 1 } }); + }); + + it('overwrites only the targeted field', () => { + writeNamespacedField(NS, 'pageViews', ['a']); + writeNamespacedField(NS, 'other', 1); + writeNamespacedField(NS, 'pageViews', ['b']); + expect(readNamespacedField(NS, 'pageViews')).toEqual(['b']); + expect(readNamespacedField(NS, 'other')).toBe(1); + }); + + it('readNamespacedField returns undefined when the key is absent', () => { + expect(readNamespacedField(NS, 'pageViews')).toBeUndefined(); + }); + + it('readNamespacedField returns undefined when the field is absent', () => { + writeNamespacedField(NS, 'other', 1); + expect(readNamespacedField(NS, 'pageViews')).toBeUndefined(); + }); + + it('readNamespacedField returns undefined when the stored value is not a plain object', () => { + writeJSON(NS, [1, 2, 3]); + expect(readNamespacedField(NS, 'pageViews')).toBeUndefined(); + }); + + it('writeNamespacedField returns false when the write throws', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('QuotaExceededError'); + }); + expect(writeNamespacedField(NS, 'pageViews', ['a'])).toBe(false); + }); + + it('removeNamespacedField clears the field but keeps other fields', () => { + writeNamespacedField(NS, 'pageViews', ['a']); + writeNamespacedField(NS, 'other', 1); + removeNamespacedField(NS, 'pageViews'); + expect(readNamespacedField(NS, 'pageViews')).toBeUndefined(); + expect(readJSON(NS)).toEqual({ other: 1 }); + }); + + it('removeNamespacedField drops the namespace key once its last field is gone', () => { + writeNamespacedField(NS, 'pageViews', ['a']); + removeNamespacedField(NS, 'pageViews'); + expect(window.localStorage.getItem(NS)).toBeNull(); + }); + + it('removeNamespacedField is a no-op for an absent key or field', () => { + expect(() => removeNamespacedField(NS, 'pageViews')).not.toThrow(); + writeNamespacedField(NS, 'other', 1); + removeNamespacedField(NS, 'pageViews'); + expect(readJSON(NS)).toEqual({ other: 1 }); + }); + }); +}); diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index dc18813..76ecbee 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -5,6 +5,7 @@ import { isSelectPlacementsAttributePersistenceDenied, removeSelectPlacementsAttributePersistenceDeniedAttributes, } from '../../src/selectPlacementsAttributePersistence'; +import { readJSON, readNamespacedField, writeNamespacedField } from '../../src/storage'; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -5638,10 +5639,13 @@ describe('Rokt Forwarder', () => { }); describe('page view capture', () => { - const readStoredPageViews = () => { - const raw = window.localStorage.getItem('mpPageViews'); - return raw === null ? null : JSON.parse(raw); - }; + const NS_KEY = 'mp-rokt-kit'; + const PAGE_VIEWS_FIELD = 'pageViews'; + const LEGACY_KEY = 'mpPageViews'; + + const readStoredPageViews = () => readNamespacedField(NS_KEY, PAGE_VIEWS_FIELD) ?? null; + const seedStoredPageViews = (views: unknown) => writeNamespacedField(NS_KEY, PAGE_VIEWS_FIELD, views); + const seedLegacyPageViews = (views: unknown) => window.localStorage.setItem(LEGACY_KEY, JSON.stringify(views)); beforeEach(() => { window.localStorage.clear(); @@ -5815,6 +5819,150 @@ describe('Rokt Forwarder', () => { expect(readStoredPageViews()).toBeNull(); }); + describe('legacy storage migration', () => { + const initKit = async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + }; + + const runSelectPlacements = async () => { + (window as any).mParticle._Store.localSessionAttributes = {}; + await (window as any).mParticle.forwarder.selectPlacements({ attributes: {} }); + return (window as any).mParticle.Rokt.selectPlacementsOptions.attributes; + }; + + it('adopts legacy history into the new key and sweeps the legacy key on read', async () => { + const seeded = [ + { + pageUrl: 'https://example.com/legacy', + sourceMessageId: 'legacy-1', + timestamp: 1712345678000, + }, + ]; + seedLegacyPageViews(seeded); + + await initKit(); + const attributes = await runSelectPlacements(); + + // Legacy history surfaces on read (adopted into the namespaced field). + expect(JSON.parse(attributes.page_events)).toEqual(seeded); + expect(readStoredPageViews()).toEqual(seeded); + // Legacy key is always swept. + expect(readJSON(LEGACY_KEY)).toBeNull(); + }); + + it('keeps the new key and sweeps the legacy key when both exist', async () => { + const legacy = [ + { + pageUrl: 'https://example.com/legacy', + sourceMessageId: 'legacy-1', + timestamp: 1712345678000, + }, + ]; + const current = [ + { + pageUrl: 'https://example.com/current', + sourceMessageId: 'current-1', + timestamp: 1712345679000, + }, + ]; + seedLegacyPageViews(legacy); + seedStoredPageViews(current); + + await initKit(); + const attributes = await runSelectPlacements(); + + // Namespaced field wins — legacy value is discarded, not merged. + expect(JSON.parse(attributes.page_events)).toEqual(current); + expect(readStoredPageViews()).toEqual(current); + expect(readJSON(LEGACY_KEY)).toBeNull(); + }); + + it('leaves the new key untouched when there is no legacy key', async () => { + const current = [ + { + pageUrl: 'https://example.com/current', + sourceMessageId: 'current-1', + timestamp: 1712345679000, + }, + ]; + seedStoredPageViews(current); + + await initKit(); + const attributes = await runSelectPlacements(); + + expect(JSON.parse(attributes.page_events)).toEqual(current); + expect(readStoredPageViews()).toEqual(current); + expect(readJSON(LEGACY_KEY)).toBeNull(); + }); + + it('sweeps the legacy key on SessionEnd before clearing the new key', async () => { + seedLegacyPageViews([ + { + pageUrl: 'https://example.com/legacy', + sourceMessageId: 'legacy-1', + timestamp: 1712345678000, + }, + ]); + + await initKit(); + + (window as any).mParticle.forwarder.process({ + EventName: 'Session End', + EventCategory: EventType.Unknown, + EventDataType: MessageType.SessionEnd, + SourceMessageId: 'source-message-id-session-end', + Timestamp: 1712345679000, + }); + + expect(readJSON(LEGACY_KEY)).toBeNull(); + expect(readStoredPageViews()).toBeNull(); + }); + + it('does not throw out of selectPlacements when the migration hits a storage error', async () => { + // Legacy present + namespaced field absent → migration attempts the adopt + // write, which throws here. The read path must swallow it (best-effort) so + // placement selection still proceeds without page events. + seedLegacyPageViews([ + { + pageUrl: 'https://example.com/legacy', + sourceMessageId: 'legacy-1', + timestamp: 1712345678000, + }, + ]); + + await initKit(); + + // A read/migration failure is surfaced as a diagnostic INFO log + // (loggingService.log), not an error report. + const logSpy = vi.spyOn((window as any).mParticle.forwarder.loggingService, 'log'); + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation((key: string) => { + if (key === NS_KEY) { + throw new Error('QuotaExceededError'); + } + }); + + try { + const attributes = await runSelectPlacements(); + // Selection proceeds; page events are simply omitted. + expect(attributes.page_events).toBeUndefined(); + } finally { + setItemSpy.mockRestore(); + } + + expect(logSpy).toHaveBeenCalledWith(expect.objectContaining({ code: 'PAGE_VIEW_CAPTURE_FAILED' })); + logSpy.mockRestore(); + }); + }); + it('captures the page view but returns the not-ready signal when the kit is not ready', () => { // Force a not-ready state: capture must still run (kit-owned storage), // but process() must tell the core SDK the forwarder is not ready. @@ -5870,7 +6018,7 @@ describe('Rokt Forwarder', () => { expect(readStoredPageViews()).toBeNull(); }); - it('does not throw and reports a warning when localStorage writes throw', async () => { + it('does not throw and logs a diagnostic when localStorage writes throw', async () => { await (window as any).mParticle.forwarder.init( { accountId: '123456', @@ -5884,6 +6032,7 @@ describe('Rokt Forwarder', () => { await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); const reportSpy = vi.spyOn((window as any).mParticle.forwarder.errorReportingService, 'report'); + const logSpy = vi.spyOn((window as any).mParticle.forwarder.loggingService, 'log'); const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('QuotaExceededError'); }); @@ -5905,10 +6054,11 @@ describe('Rokt Forwarder', () => { // Nothing is persisted, but the forwarder keeps running. expect(readStoredPageViews()).toBeNull(); - // The write failure is surfaced as an INFO (rate-limited per severity). - expect(reportSpy).toHaveBeenCalledWith( - expect.objectContaining({ code: 'PAGE_VIEW_CAPTURE_FAILED', severity: 'INFO' }), - ); + // The failed write is best-effort: surfaced as a diagnostic INFO log + // (loggingService.log), never an error report. + expect(logSpy).toHaveBeenCalledWith(expect.objectContaining({ code: 'PAGE_VIEW_CAPTURE_FAILED' })); + expect(reportSpy).not.toHaveBeenCalledWith(expect.objectContaining({ code: 'PAGE_VIEW_CAPTURE_FAILED' })); + logSpy.mockRestore(); reportSpy.mockRestore(); }); @@ -6157,22 +6307,19 @@ describe('Rokt Forwarder', () => { // followed by one that has it. A coerced-to-0 first record would diff // against the next (300000 - 0) and invent a 5-minute dwell that never // happened; "unknown" must stay distinguishable from a genuine zero. - window.localStorage.setItem( - 'mpPageViews', - JSON.stringify([ - { - pageUrl: 'https://example.com/a', - sourceMessageId: 'missing-ats', - timestamp: 1712345678000, - }, - { - pageUrl: 'https://example.com/b', - sourceMessageId: 'has-ats', - timestamp: 1712345679000, - activeTimeOnSite: 300000, - }, - ]), - ); + seedStoredPageViews([ + { + pageUrl: 'https://example.com/a', + sourceMessageId: 'missing-ats', + timestamp: 1712345678000, + }, + { + pageUrl: 'https://example.com/b', + sourceMessageId: 'has-ats', + timestamp: 1712345679000, + activeTimeOnSite: 300000, + }, + ]); await (window as any).mParticle.forwarder.init( { @@ -6201,17 +6348,14 @@ describe('Rokt Forwarder', () => { it('clears stored page views on init when targeting is disabled', async () => { // Seed a stored page view from a period when targeting was permitted. - window.localStorage.setItem( - 'mpPageViews', - JSON.stringify([ - { - pageUrl: 'https://example.com/', - sourceMessageId: 'seeded', - timestamp: 1712345678000, - activeTimeOnSite: 4200, - }, - ]), - ); + seedStoredPageViews([ + { + pageUrl: 'https://example.com/', + sourceMessageId: 'seeded', + timestamp: 1712345678000, + activeTimeOnSite: 4200, + }, + ]); (window as any).mParticle.Rokt.launcherOptions = { noTargeting: true, @@ -6240,6 +6384,48 @@ describe('Rokt Forwarder', () => { expect(forwardedAttributes.page_events).toBeUndefined(); }); + it('does not sweep the legacy key on init when targeting is disabled', async () => { + // The targeting-disabled clear path (initForwarder) intentionally only + // clears the kit-owned new key; it does not run the legacy migration. + // A user with targeting off keeps an orphaned legacy `mpPageViews` until + // the shim's removal date — benign, and swept the moment targeting is + // re-enabled (loadPageViews) or a SessionEnd fires. + seedLegacyPageViews([ + { + pageUrl: 'https://example.com/legacy', + sourceMessageId: 'legacy-seeded', + timestamp: 1712345678000, + }, + ]); + seedStoredPageViews([ + { + pageUrl: 'https://example.com/', + sourceMessageId: 'seeded', + timestamp: 1712345678000, + }, + ]); + + (window as any).mParticle.Rokt.launcherOptions = { + noTargeting: true, + }; + + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + // New key is cleared; legacy key is left untouched (not swept on this path). + expect(readStoredPageViews()).toBeNull(); + expect(readJSON(LEGACY_KEY)).not.toBeNull(); + }); + it('strips query params from the captured pageUrl', async () => { const originalLocation = window.location; // Query params commonly carry PII (emails, tokens); they must not be captured.