Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 6 additions & 12 deletions src/pageViewStorage.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -54,19 +47,20 @@ 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 {
removeNamespacedField(LS_NAMESPACE_KEY, LS_PAGE_VIEWS_FIELD);
}

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);

Expand Down
57 changes: 45 additions & 12 deletions test/src/pageViewStorage.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
});
});

Expand Down
63 changes: 24 additions & 39 deletions test/src/tests.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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',
Expand All @@ -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 {
Expand All @@ -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 () => {
Expand Down
Loading