diff --git a/src/pageViewStorage.ts b/src/pageViewStorage.ts index e52ee77..d4f21da 100644 --- a/src/pageViewStorage.ts +++ b/src/pageViewStorage.ts @@ -1,18 +1,11 @@ import type { LoggingService } from './Rokt-Kit'; -import { - readJSON, - removeKey, - readNamespacedField, - writeNamespacedField, - removeNamespacedField, - writeNamespacedFieldWithinBudget, -} from './storage'; +import { readJSON, removeKey, readNamespacedField, writeNamespacedField, removeNamespacedField } from './storage'; import { sanitizeUrl } from './utils'; 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; +const PAGE_VIEWS_MAX_COUNT = 25; export interface PageEvent { pageUrl: string; @@ -54,7 +47,7 @@ export function loadPageViews(loggingService: LoggingService | null): PageEvent[ } export function writePageViews(pageViews: PageEvent[]): boolean { - return writeNamespacedFieldWithinBudget(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, pageViews, PAGE_VIEWS_MAX_LENGTH); + return writeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD, pageViews.slice(-PAGE_VIEWS_MAX_COUNT)); } export function clearPageViews(): void { @@ -62,11 +55,12 @@ export function clearPageViews(): void { } export function buildPageEvents(pageViews: PageEvent[]): PageEvent[] { - return pageViews.map((pageView, index) => { + const views = pageViews.slice(-PAGE_VIEWS_MAX_COUNT); + return views.map((pageView, index) => { const activeTimeOnSite = pageView.activeTimeOnSite; const hasActiveTime = activeTimeOnSite !== undefined && Number.isFinite(activeTimeOnSite); - const next = pageViews[index + 1]; + const next = views[index + 1]; const nextActiveTimeOnSite = next?.activeTimeOnSite; const hasNextActiveTimeOnSite = nextActiveTimeOnSite !== undefined && Number.isFinite(nextActiveTimeOnSite); diff --git a/test/src/pageViewStorage.spec.ts b/test/src/pageViewStorage.spec.ts index 88f69e7..325eb48 100644 --- a/test/src/pageViewStorage.spec.ts +++ b/test/src/pageViewStorage.spec.ts @@ -1,6 +1,12 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { readJSON, writeNamespacedField } from '../../src/storage'; -import { migrateLegacyPageViewStorage, loadPageViews, writePageViews, clearPageViews } from '../../src/pageViewStorage'; +import { + migrateLegacyPageViewStorage, + loadPageViews, + writePageViews, + clearPageViews, + buildPageEvents, +} from '../../src/pageViewStorage'; import type { LoggingService } from '../../src/Rokt-Kit'; const NAMESPACE_KEY = 'mp-rokt-kit'; @@ -85,20 +91,47 @@ describe('pageViewStorage', () => { 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, + it('keeps only the 25 most-recent views when given more than 25', () => { + const views = Array.from({ length: 40 }, (_, i) => pageView('page-' + i)); + expect(writePageViews(views)).toBe(true); + const stored = loadPageViews(null); + expect(stored).toHaveLength(25); + expect(stored[0].sourceMessageId).toBe('page-15'); + expect(stored[24].sourceMessageId).toBe('page-39'); + }); + }); + + describe('buildPageEvents', () => { + it('returns all views when count is within the send limit', () => { + const views = Array.from({ length: 10 }, (_, i) => pageView('page-' + i)); + expect(buildPageEvents(views)).toHaveLength(10); + }); + + it('caps output at 25 most-recent views when storage exceeds the send limit', () => { + const views = Array.from({ length: 40 }, (_, i) => ({ + pageUrl: 'https://example.com/page-' + i, + sourceMessageId: 'id-' + i, timestamp: i, })); + const result = buildPageEvents(views); + expect(result).toHaveLength(25); + expect(result[0].sourceMessageId).toBe('id-15'); + expect(result[24].sourceMessageId).toBe('id-39'); + }); - 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'); + it('computes activeTimeOnPage correctly within the capped window', () => { + const views = Array.from({ length: 30 }, (_, i) => ({ + pageUrl: 'https://example.com/page-' + i, + sourceMessageId: 'id-' + i, + timestamp: i, + activeTimeOnSite: i * 1000, + })); + const result = buildPageEvents(views); + expect(result).toHaveLength(25); + // First record in the capped window should have activeTimeOnPage derived from + // the next record within the slice, not from the unsliced original array. + expect(result[0].activeTimeOnPage).toBe(1000); + expect(result[24].activeTimeOnPage).toBeUndefined(); }); }); diff --git a/test/src/tests.spec.ts b/test/src/tests.spec.ts index a1204a5..31eb56e 100644 --- a/test/src/tests.spec.ts +++ b/test/src/tests.spec.ts @@ -5891,12 +5891,7 @@ describe('Rokt Forwarder', () => { expect(readStoredPageViews()).toBeNull(); }); - // 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 () => { + it('keeps only the 25 most-recent views when more than 25 are written', async () => { await (window as any).mParticle.forwarder.init( { accountId: '123456', @@ -5909,17 +5904,16 @@ 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++) { - seed.push({ pageUrl: bigUrl, sourceMessageId: 'seed-' + i, timestamp: 1712345678000 + i }); + seed.push({ + pageUrl: 'https://example.com/page-' + i, + 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, @@ -5930,13 +5924,12 @@ describe('Rokt Forwarder', () => { }); const stored = readStoredPageViews(); - expect(JSON.stringify(stored).length).toBeLessThanOrEqual(PAGE_VIEWS_MAX_LENGTH); - expect(stored.length).toBeLessThan(31); + expect(stored.length).toBe(25); expect(stored[stored.length - 1].sourceMessageId).toBe('newest'); - expect(stored[0].sourceMessageId).not.toBe('seed-0'); + expect(stored[0].sourceMessageId).toBe('seed-6'); }); - it('retains at least the newest page view even if an older record alone exceeds the budget', async () => { + it('stores both old and new record regardless of individual record size', async () => { await (window as any).mParticle.forwarder.init( { accountId: '123456', @@ -5949,8 +5942,13 @@ describe('Rokt Forwarder', () => { 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 }]); + seedStoredPageViews([ + { + pageUrl: 'https://example.com/' + 'a'.repeat(50000), + sourceMessageId: 'seed-huge', + timestamp: 1712345678000, + }, + ]); (window as any).mParticle.forwarder.process({ EventName: 'Newest', @@ -5962,8 +5960,8 @@ describe('Rokt Forwarder', () => { }); const stored = readStoredPageViews(); - expect(stored.length).toBe(1); - expect(stored[0].sourceMessageId).toBe('newest'); + expect(stored.length).toBe(2); + expect(stored[stored.length - 1].sourceMessageId).toBe('newest'); }); it('clears the stored page-view history on a SessionEnd event', async () => { @@ -6245,7 +6243,7 @@ describe('Rokt Forwarder', () => { reportSpy.mockRestore(); }); - it('evicts oldest and retries when the browser quota is exceeded, then persists', async () => { + it('fails gracefully and preserves existing data when localStorage quota is exceeded', async () => { await (window as any).mParticle.forwarder.init( { accountId: '123456', @@ -6264,20 +6262,8 @@ describe('Rokt Forwarder', () => { } 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); + const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function () { + throw new DOMException('quota', 'QuotaExceededError'); }); try { @@ -6293,11 +6279,10 @@ describe('Rokt Forwarder', () => { setItemSpy.mockRestore(); } + // Write failed silently — existing seed data is unchanged. 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'); + expect(stored.length).toBe(5); + expect(stored[stored.length - 1].sourceMessageId).toBe('seed-4'); }); it('captures page views independently of setLocalSessionAttribute availability', async () => {