diff --git a/AGENTS.md b/AGENTS.md index 7317c26..9feb2b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,32 +17,6 @@ The Rokt web kit (`@mparticle/web-rokt-kit`) is an mParticle integration kit (fo - **Code Quality**: ESLint v9 flat config + `@typescript-eslint/recommended` - **Formatting**: Prettier (120 chars, single quotes, trailing commas) -## Project Structure - -``` -/ - src/ - 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 # 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 - vite.config.ts # Build + test configuration - tsconfig.json # TypeScript config (src only) - tsconfig.test.json # TypeScript config (src + test) - eslint.config.mjs # ESLint v9 flat config - package.json -``` - ## Key Commands ```bash diff --git a/src/Rokt-Kit.ts b/src/Rokt-Kit.ts index 2db1f88..0ba7f23 100644 --- a/src/Rokt-Kit.ts +++ b/src/Rokt-Kit.ts @@ -24,7 +24,15 @@ import { removeSelectPlacementsAttributePersistenceDeniedAttributes, } from './selectPlacementsAttributePersistence'; -import { readJSON, removeKey, readNamespacedField, writeNamespacedField, removeNamespacedField } from './storage'; +import { + PageEvent, + migrateLegacyPageViewStorage, + loadPageViews, + writePageViews, + clearPageViews, +} from './pageViewStorage'; + +import { isObject, isString, isEmpty } from './utils'; interface RoktKitSettings { accountId: string; @@ -63,17 +71,6 @@ interface RoktExtensionEntry { value: string; } -interface PageEvent { - pageUrl: string; - sourceMessageId: string; - timestamp: number; - activeTimeOnSite?: number; - // Derived at transmission not at capture based - // on the next page view's activeTimeOnSite, - // so it is absent on stored records. - activeTimeOnPage?: number; -} - interface RoktSelection { context?: { sessionId?: Promise; @@ -259,11 +256,6 @@ 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 -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'; // Bound on how long selectPlacements will wait for an in-flight Workspace @@ -309,49 +301,6 @@ function mp(): MParticleExtended { // Module-level utility functions // ============================================================ -// 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; - } - } - - removeKey(LEGACY_PAGE_VIEWS_KEY); -} - -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 { - removeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD); -} - function generateLauncherScript(domain: string | undefined, extensions: string[]): string { const launcherPath = '/wsdk/integrations/launcher.js'; const baseUrl = [generateBaseUrl(domain), launcherPath].join(''); @@ -405,10 +354,6 @@ function loadRoktScript( target.appendChild(script); } -function isObject(val: unknown): val is Record { - return val != null && typeof val === 'object' && Array.isArray(val) === false; -} - function parseSettingsString(settingsString?: string): T[] { if (!settingsString) { return []; @@ -500,21 +445,6 @@ function hashEventMessage(messageType: number, eventType: number, eventName: str return mp().generateHash([messageType, eventType, eventName].join('')); } -function isEmpty(value: unknown): boolean { - if (value == null) return true; - if (typeof value === 'object') { - return Object.keys(value as object).length === 0; - } - if (Array.isArray(value)) { - return (value as unknown[]).length === 0; - } - return false; -} - -function isString(value: unknown): value is string { - return typeof value === 'string'; -} - // Strips the query string from a page-view URL before it is persisted and sent // to Rokt, since query params commonly carry PII (emails, tokens, order refs). // Returns the input unchanged if it can't be parsed as a URL. @@ -943,11 +873,7 @@ class RoktKit implements KitInterface { pageViews.push(pageView); - while (pageViews.length > PAGE_VIEWS_MAX_COUNT) { - pageViews.shift(); - } - - if (!writePageViewsStorage(pageViews)) { + if (!writePageViews(pageViews)) { this.loggingService?.log({ message: `Rokt Kit: Failed to persist page view for ${pageUrl}`, code: 'PAGE_VIEW_CAPTURE_FAILED', @@ -1242,7 +1168,7 @@ class RoktKit implements KitInterface { if (this.isTargetingDisabled()) { try { - clearPageViewsStorage(); + clearPageViews(); } catch (err) { this.errorReportingService?.report({ message: 'Rokt Kit: Failed to clear page views when targeting is disabled', @@ -1331,7 +1257,7 @@ class RoktKit implements KitInterface { if (event.EventDataType === MESSAGE_TYPE_SESSION_END) { migrateLegacyPageViewStorage(this.loggingService); - clearPageViewsStorage(); + clearPageViews(); } } @@ -1656,3 +1582,4 @@ if (typeof window !== 'undefined' && window.mParticle && mp().addForwarder) { } export { register }; +export type { LoggingService }; diff --git a/src/pageViewStorage.ts b/src/pageViewStorage.ts new file mode 100644 index 0000000..3c22086 --- /dev/null +++ b/src/pageViewStorage.ts @@ -0,0 +1,59 @@ +import type { LoggingService } from './Rokt-Kit'; +import { + readJSON, + removeKey, + readNamespacedField, + writeNamespacedField, + removeNamespacedField, + writeNamespacedFieldWithinBudget, +} from './storage'; + +const LS_NAMESPACE_KEY = 'mp-rokt-kit'; +const LS_PAGE_VIEWS_FIELD = 'pageViews'; +const LEGACY_PAGE_VIEWS_KEY = 'mpPageViews'; +const PAGE_VIEWS_MAX_LENGTH = 100 * 1024; + +export interface PageEvent { + pageUrl: string; + sourceMessageId: string; + timestamp: number; + activeTimeOnSite?: number; + activeTimeOnPage?: number; +} + +export 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; + } + } + + removeKey(LEGACY_PAGE_VIEWS_KEY); +} + +export 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[]) : []; +} + +export function writePageViews(pageViews: PageEvent[]): boolean { + return writeNamespacedFieldWithinBudget(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, pageViews, PAGE_VIEWS_MAX_LENGTH); +} + +export function clearPageViews(): void { + removeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD); +} diff --git a/src/storage.ts b/src/storage.ts index 9e06bd3..6a125c6 100644 --- a/src/storage.ts +++ b/src/storage.ts @@ -1,3 +1,5 @@ +import { isObject } from './utils'; + export function readJSON(key: string): unknown { try { const stored = window.localStorage.getItem(key); @@ -24,25 +26,21 @@ export function removeKey(key: string): void { } } -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; + return isObject(blob) ? blob[field] : undefined; } export function writeNamespacedField(namespaceKey: string, field: string, value: unknown): boolean { const blob = readJSON(namespaceKey); - const next = isPlainObject(blob) ? { ...blob } : {}; + const next = isObject(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)) { + if (!isObject(blob) || !(field in blob)) { return; } const next = { ...blob }; @@ -53,3 +51,35 @@ export function removeNamespacedField(namespaceKey: string, field: string): void writeJSON(namespaceKey, next); } } + +export function writeNamespacedFieldWithinBudget( + namespaceKey: string, + field: string, + records: unknown[], + maxLength: number, +): boolean { + // Operate on a copy so the caller's array isn't trimmed as a side effect. + const remaining = records.slice(); + + const evictOldest = (): boolean => { + if (remaining.length <= 1) { + return false; + } + remaining.shift(); + return true; + }; + + // Two limits: our own soft cap (maxLength), then the browser's hard quota, + // which is shared across the origin and only surfaces when setItem throws. + let overBudget = JSON.stringify(remaining).length > maxLength; + while (overBudget && evictOldest()) { + overBudget = JSON.stringify(remaining).length > maxLength; + } + + let written = writeNamespacedField(namespaceKey, field, remaining); + while (!written && evictOldest()) { + written = writeNamespacedField(namespaceKey, field, remaining); + } + + return written; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..0d839b2 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,15 @@ +export function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +export function isEmpty(value: unknown): boolean { + if (value == null) return true; + if (typeof value === 'object') { + return Object.keys(value as object).length === 0; + } + return false; +} diff --git a/test/src/pageViewStorage.spec.ts b/test/src/pageViewStorage.spec.ts new file mode 100644 index 0000000..88f69e7 --- /dev/null +++ b/test/src/pageViewStorage.spec.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { readJSON, writeNamespacedField } from '../../src/storage'; +import { migrateLegacyPageViewStorage, loadPageViews, writePageViews, clearPageViews } from '../../src/pageViewStorage'; +import type { LoggingService } from '../../src/Rokt-Kit'; + +const NAMESPACE_KEY = 'mp-rokt-kit'; +const PAGE_VIEWS_FIELD = 'pageViews'; +const LEGACY_PAGE_VIEWS_KEY = 'mpPageViews'; + +const pageView = (id: string) => ({ pageUrl: 'https://example.com/' + id, sourceMessageId: id, timestamp: 1 }); + +describe('pageViewStorage', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + window.localStorage.clear(); + }); + + describe('migrateLegacyPageViewStorage', () => { + it('is a no-op when the legacy key is absent', () => { + migrateLegacyPageViewStorage(null); + expect(readJSON(NAMESPACE_KEY)).toBeNull(); + }); + + it('moves a legacy array into the namespaced field and removes the legacy key', () => { + window.localStorage.setItem(LEGACY_PAGE_VIEWS_KEY, JSON.stringify([pageView('home')])); + migrateLegacyPageViewStorage(null); + + expect(readJSON(NAMESPACE_KEY)).toEqual({ [PAGE_VIEWS_FIELD]: [pageView('home')] }); + expect(window.localStorage.getItem(LEGACY_PAGE_VIEWS_KEY)).toBeNull(); + }); + + it('does not overwrite an already-migrated field, but still clears the legacy key', () => { + writeNamespacedField(NAMESPACE_KEY, PAGE_VIEWS_FIELD, [pageView('current')]); + window.localStorage.setItem(LEGACY_PAGE_VIEWS_KEY, JSON.stringify([pageView('stale')])); + + migrateLegacyPageViewStorage(null); + + expect(readJSON(NAMESPACE_KEY)).toEqual({ [PAGE_VIEWS_FIELD]: [pageView('current')] }); + expect(window.localStorage.getItem(LEGACY_PAGE_VIEWS_KEY)).toBeNull(); + }); + + it('retains the legacy key and logs when the migrating write fails', () => { + window.localStorage.setItem(LEGACY_PAGE_VIEWS_KEY, JSON.stringify([pageView('home')])); + const logger = { log: vi.fn() } as unknown as LoggingService; + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('quota', 'QuotaExceededError'); + }); + + migrateLegacyPageViewStorage(logger); + + expect(window.localStorage.getItem(LEGACY_PAGE_VIEWS_KEY)).not.toBeNull(); + expect(logger.log).toHaveBeenCalledWith(expect.objectContaining({ code: 'PAGE_VIEW_CAPTURE_FAILED' })); + }); + }); + + describe('loadPageViews', () => { + it('returns an empty array when nothing is stored', () => { + expect(loadPageViews(null)).toEqual([]); + }); + + it('returns the stored page views', () => { + writeNamespacedField(NAMESPACE_KEY, PAGE_VIEWS_FIELD, [pageView('home'), pageView('about')]); + expect(loadPageViews(null)).toEqual([pageView('home'), pageView('about')]); + }); + + it('returns an empty array when the stored value is not an array', () => { + writeNamespacedField(NAMESPACE_KEY, PAGE_VIEWS_FIELD, { not: 'an array' }); + expect(loadPageViews(null)).toEqual([]); + }); + + it('migrates the legacy key before reading', () => { + window.localStorage.setItem(LEGACY_PAGE_VIEWS_KEY, JSON.stringify([pageView('legacy')])); + expect(loadPageViews(null)).toEqual([pageView('legacy')]); + expect(window.localStorage.getItem(LEGACY_PAGE_VIEWS_KEY)).toBeNull(); + }); + }); + + describe('writePageViews', () => { + it('persists the page views and returns true', () => { + expect(writePageViews([pageView('home')])).toBe(true); + expect(loadPageViews(null)).toEqual([pageView('home')]); + }); + + it('evicts oldest-first to stay within the byte budget', () => { + const oversizedUrl = 'https://example.com/' + 'a'.repeat(5000); + const views = Array.from({ length: 30 }, (_, i) => ({ + pageUrl: oversizedUrl, + sourceMessageId: 'seed-' + i, + timestamp: i, + })); + + expect(writePageViews(views)).toBe(true); + + const stored = loadPageViews(null); + expect(JSON.stringify(stored).length).toBeLessThanOrEqual(100 * 1024); + expect(stored.length).toBeLessThan(30); + expect(stored[stored.length - 1].sourceMessageId).toBe('seed-29'); + }); + }); + + describe('clearPageViews', () => { + it('removes the page-view field', () => { + writeNamespacedField(NAMESPACE_KEY, PAGE_VIEWS_FIELD, [pageView('home')]); + writeNamespacedField(NAMESPACE_KEY, 'unrelatedField', 'keep-me'); + clearPageViews(); + + const blob = readJSON(NAMESPACE_KEY); + expect(blob).not.toHaveProperty(PAGE_VIEWS_FIELD); + expect(blob).toHaveProperty('unrelatedField', 'keep-me'); + }); + }); +}); diff --git a/test/src/storage.spec.ts b/test/src/storage.spec.ts index 985ae9a..5f51fa8 100644 --- a/test/src/storage.spec.ts +++ b/test/src/storage.spec.ts @@ -6,6 +6,7 @@ import { readNamespacedField, writeNamespacedField, removeNamespacedField, + writeNamespacedFieldWithinBudget, } from '../../src/storage'; describe('storage: key-agnostic localStorage helpers', () => { @@ -86,72 +87,134 @@ describe('storage: key-agnostic localStorage helpers', () => { }); describe('namespaced fields', () => { - const NS = 'mp-rokt-kit'; + const NAMESPACE_KEY = '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] }); + expect(writeNamespacedField(NAMESPACE_KEY, 'pageViews', [1, 2])).toBe(true); + expect(readJSON(NAMESPACE_KEY)).toEqual({ pageViews: [1, 2] }); }); it('readNamespacedField returns the stored field value', () => { - writeNamespacedField(NS, 'pageViews', ['a']); - expect(readNamespacedField(NS, 'pageViews')).toEqual(['a']); + writeNamespacedField(NAMESPACE_KEY, 'pageViews', ['a']); + expect(readNamespacedField(NAMESPACE_KEY, '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 } }); + writeNamespacedField(NAMESPACE_KEY, 'pageViews', ['a']); + writeNamespacedField(NAMESPACE_KEY, 'other', { x: 1 }); + expect(readJSON(NAMESPACE_KEY)).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); + writeNamespacedField(NAMESPACE_KEY, 'pageViews', ['a']); + writeNamespacedField(NAMESPACE_KEY, 'other', 1); + writeNamespacedField(NAMESPACE_KEY, 'pageViews', ['b']); + expect(readNamespacedField(NAMESPACE_KEY, 'pageViews')).toEqual(['b']); + expect(readNamespacedField(NAMESPACE_KEY, 'other')).toBe(1); }); it('readNamespacedField returns undefined when the key is absent', () => { - expect(readNamespacedField(NS, 'pageViews')).toBeUndefined(); + expect(readNamespacedField(NAMESPACE_KEY, 'pageViews')).toBeUndefined(); }); it('readNamespacedField returns undefined when the field is absent', () => { - writeNamespacedField(NS, 'other', 1); - expect(readNamespacedField(NS, 'pageViews')).toBeUndefined(); + writeNamespacedField(NAMESPACE_KEY, 'other', 1); + expect(readNamespacedField(NAMESPACE_KEY, '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(); + writeJSON(NAMESPACE_KEY, [1, 2, 3]); + expect(readNamespacedField(NAMESPACE_KEY, '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); + expect(writeNamespacedField(NAMESPACE_KEY, '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 }); + writeNamespacedField(NAMESPACE_KEY, 'pageViews', ['a']); + writeNamespacedField(NAMESPACE_KEY, 'other', 1); + removeNamespacedField(NAMESPACE_KEY, 'pageViews'); + expect(readNamespacedField(NAMESPACE_KEY, 'pageViews')).toBeUndefined(); + expect(readJSON(NAMESPACE_KEY)).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(); + writeNamespacedField(NAMESPACE_KEY, 'pageViews', ['a']); + removeNamespacedField(NAMESPACE_KEY, 'pageViews'); + expect(window.localStorage.getItem(NAMESPACE_KEY)).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 }); + expect(() => removeNamespacedField(NAMESPACE_KEY, 'pageViews')).not.toThrow(); + writeNamespacedField(NAMESPACE_KEY, 'other', 1); + removeNamespacedField(NAMESPACE_KEY, 'pageViews'); + expect(readJSON(NAMESPACE_KEY)).toEqual({ other: 1 }); + }); + }); + + describe('writeNamespacedFieldWithinBudget', () => { + const NAMESPACE_KEY = 'mp-rokt-kit'; + const BUDGET = 1024; + + it('writes all records unchanged when under budget', () => { + const records = [{ id: 1 }, { id: 2 }]; + expect(writeNamespacedFieldWithinBudget(NAMESPACE_KEY, 'pageViews', records, BUDGET)).toBe(true); + expect(records).toHaveLength(2); + expect(readNamespacedField(NAMESPACE_KEY, 'pageViews')).toEqual([{ id: 1 }, { id: 2 }]); + }); + + it('evicts oldest-first until the serialized size is within budget', () => { + const big = 'x'.repeat(300); + const records = [ + { id: 'a', v: big }, + { id: 'b', v: big }, + { id: 'c', v: big }, + { id: 'd', v: big }, + ]; + expect(writeNamespacedFieldWithinBudget(NAMESPACE_KEY, 'pageViews', records, BUDGET)).toBe(true); + + const stored = readNamespacedField(NAMESPACE_KEY, 'pageViews') as { id: string }[]; + expect(JSON.stringify(stored).length).toBeLessThanOrEqual(BUDGET); + expect(stored[stored.length - 1].id).toBe('d'); + expect(stored.map((r) => r.id)).not.toContain('a'); + }); + + it('keeps at least the newest record even when it alone exceeds the budget', () => { + const records = [{ id: 'newest', v: 'x'.repeat(BUDGET * 2) }]; + expect(writeNamespacedFieldWithinBudget(NAMESPACE_KEY, 'pageViews', records, BUDGET)).toBe(true); + expect(readNamespacedField(NAMESPACE_KEY, 'pageViews')).toEqual([{ id: 'newest', v: 'x'.repeat(BUDGET * 2) }]); + }); + + it('evicts and retries when writes fail, then persists', () => { + let calls = 0; + const realSetItem = Storage.prototype.setItem; + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function (this: Storage, key: string, value: string) { + calls += 1; + if (calls <= 2) { + throw new DOMException('quota', 'QuotaExceededError'); + } + return realSetItem.call(this, key, value); + }); + + const records = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }]; + expect(writeNamespacedFieldWithinBudget(NAMESPACE_KEY, 'pageViews', records, BUDGET)).toBe(true); + + const stored = readNamespacedField(NAMESPACE_KEY, 'pageViews') as { id: string }[]; + // 2 failed writes evict the oldest twice (a, then b), leaving [c, d]. + expect(stored.map((r) => r.id)).toEqual(['c', 'd']); + }); + + it('returns false when even a single record cannot be written', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new DOMException('quota', 'QuotaExceededError'); + }); + const records = [{ id: 'a' }, { id: 'b' }]; + expect(writeNamespacedFieldWithinBudget(NAMESPACE_KEY, 'pageViews', records, BUDGET)).toBe(false); + expect(records).toEqual([{ id: 'a' }, { id: 'b' }]); }); }); }); diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index 76ecbee..7edfc49 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -5752,7 +5752,12 @@ describe('Rokt Forwarder', () => { expect(readStoredPageViews()).toBeNull(); }); - it('caps the stored history at 25 records, evicting oldest first', async () => { + // The budget is a code constant (PAGE_VIEWS_MAX_LENGTH = 100 * 1024), + // measured as JSON string length. Not exported, so tests reference the + // literal value. + const PAGE_VIEWS_MAX_LENGTH = 100 * 1024; + + it('caps the stored history by byte budget, evicting oldest first', async () => { await (window as any).mParticle.forwarder.init( { accountId: '123456', @@ -5765,22 +5770,61 @@ describe('Rokt Forwarder', () => { await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + // Pre-seed a history that already exceeds the byte budget, using long + // synthetic URLs so a handful of records is enough (~5KB each × 30 ≈ + // 150KB). Seeding directly avoids ~1000 process() calls to reach 100KB. + const bigUrl = 'https://example.com/' + 'a'.repeat(5000); + const seed = []; for (let i = 0; i < 30; i++) { - (window as any).mParticle.forwarder.process({ - EventName: 'Page ' + i, - EventCategory: EventType.Unknown, - EventDataType: MessageType.PageView, - SourceMessageId: 'source-message-id-' + i, - Timestamp: 1712345678000 + i, - ActiveTimeOnSite: i, - }); + seed.push({ pageUrl: bigUrl, sourceMessageId: 'seed-' + i, timestamp: 1712345678000 + i }); } + seedStoredPageViews(seed); + + // One more capture triggers byte-budget eviction on write. + (window as any).mParticle.forwarder.process({ + EventName: 'Newest', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'newest', + Timestamp: 1712345678999, + ActiveTimeOnSite: 1, + }); const stored = readStoredPageViews(); - // 30 written, capped at 25 — the 5 oldest evicted, newest always retained. - expect(stored.length).toBe(25); - expect(stored[0].sourceMessageId).toBe('source-message-id-5'); - expect(stored[stored.length - 1].sourceMessageId).toBe('source-message-id-29'); + expect(JSON.stringify(stored).length).toBeLessThanOrEqual(PAGE_VIEWS_MAX_LENGTH); + expect(stored.length).toBeLessThan(31); + expect(stored[stored.length - 1].sourceMessageId).toBe('newest'); + expect(stored[0].sourceMessageId).not.toBe('seed-0'); + }); + + it('retains at least the newest page view even if an older record alone exceeds the budget', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + const hugeUrl = 'https://example.com/' + 'a'.repeat(PAGE_VIEWS_MAX_LENGTH + 1); + seedStoredPageViews([{ pageUrl: hugeUrl, sourceMessageId: 'seed-huge', timestamp: 1712345678000 }]); + + (window as any).mParticle.forwarder.process({ + EventName: 'Newest', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'newest', + Timestamp: 1712345678999, + ActiveTimeOnSite: 1, + }); + + const stored = readStoredPageViews(); + expect(stored.length).toBe(1); + expect(stored[0].sourceMessageId).toBe('newest'); }); it('clears the stored page-view history on a SessionEnd event', async () => { @@ -6062,6 +6106,61 @@ describe('Rokt Forwarder', () => { reportSpy.mockRestore(); }); + it('evicts oldest and retries when the browser quota is exceeded, then persists', async () => { + await (window as any).mParticle.forwarder.init( + { + accountId: '123456', + }, + reportService.cb, + true, + null, + {}, + ); + + await waitForCondition(() => (window as any).mParticle.Rokt.attachKitCalled); + + const seed = []; + for (let i = 0; i < 5; i++) { + seed.push({ pageUrl: 'https://example.com/', sourceMessageId: 'seed-' + i, timestamp: 1712345678000 + i }); + } + seedStoredPageViews(seed); + + // writeNamespacedField swallows the quota error and returns false, so + // failing the first 3 writes drives writePageViews's evict-and-retry. + let calls = 0; + const realSetItem = Storage.prototype.setItem; + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function ( + this: Storage, + key: string, + value: string, + ) { + calls += 1; + if (calls <= 3) { + throw new DOMException('quota', 'QuotaExceededError'); + } + return realSetItem.call(this, key, value); + }); + + try { + (window as any).mParticle.forwarder.process({ + EventName: 'Newest', + EventCategory: EventType.Unknown, + EventDataType: MessageType.PageView, + SourceMessageId: 'newest', + Timestamp: 1712345678999, + ActiveTimeOnSite: 1, + }); + } finally { + setItemSpy.mockRestore(); + } + + const stored = readStoredPageViews(); + // 5 seed + 1 newest = 6, minus 3 evicted across the 3 failed retries = 3. + expect(stored.length).toBe(3); + expect(stored[stored.length - 1].sourceMessageId).toBe('newest'); + expect(stored[0].sourceMessageId).toBe('seed-3'); + }); + it('captures page views independently of setLocalSessionAttribute availability', async () => { await (window as any).mParticle.forwarder.init( { diff --git a/test/src/utils.spec.ts b/test/src/utils.spec.ts new file mode 100644 index 0000000..9c53956 --- /dev/null +++ b/test/src/utils.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { isObject, isString, isEmpty } from '../../src/utils'; + +describe('utils: type guards', () => { + describe('isObject', () => { + it('is true for plain objects', () => { + expect(isObject({})).toBe(true); + expect(isObject({ a: 1 })).toBe(true); + }); + + it('is false for arrays', () => { + expect(isObject([])).toBe(false); + expect(isObject([1, 2])).toBe(false); + }); + + it('is false for null and undefined', () => { + expect(isObject(null)).toBe(false); + expect(isObject(undefined)).toBe(false); + }); + + it('is false for primitives', () => { + expect(isObject('s')).toBe(false); + expect(isObject(1)).toBe(false); + expect(isObject(true)).toBe(false); + }); + }); + + describe('isString', () => { + it('is true for strings', () => { + expect(isString('')).toBe(true); + expect(isString('abc')).toBe(true); + }); + + it('is false for non-strings', () => { + expect(isString(1)).toBe(false); + expect(isString(null)).toBe(false); + expect(isString(undefined)).toBe(false); + expect(isString({})).toBe(false); + expect(isString(['a'])).toBe(false); + }); + }); + + describe('isEmpty', () => { + it('is true for null and undefined', () => { + expect(isEmpty(null)).toBe(true); + expect(isEmpty(undefined)).toBe(true); + }); + + it('is true for empty objects and arrays', () => { + expect(isEmpty({})).toBe(true); + expect(isEmpty([])).toBe(true); + }); + + it('is false for non-empty objects and arrays', () => { + expect(isEmpty({ a: 1 })).toBe(false); + expect(isEmpty([1])).toBe(false); + }); + + it('is false for non-empty primitives', () => { + expect(isEmpty('abc')).toBe(false); + expect(isEmpty(0)).toBe(false); + expect(isEmpty(false)).toBe(false); + }); + }); +});