From 5cdad6c5d1037ffa8f0983985e7124bfe27a81e5 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 1 Oct 2025 13:40:19 -0700 Subject: [PATCH 01/11] add ConsentAwareStorage --- packages/experiment-tag/src/experiment.ts | 50 ++++++++---- packages/experiment-tag/src/index.ts | 8 +- packages/experiment-tag/src/types.ts | 13 +++ packages/experiment-tag/src/util/storage.ts | 88 +++++++++++++++++++++ 4 files changed, 141 insertions(+), 18 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index caf67cf1..681789e4 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -19,6 +19,8 @@ import { MessageBus } from './message-bus'; import { showPreviewModeModal } from './preview/preview'; import { PageChangeEvent, SubscriptionManager } from './subscriptions'; import { + ConsentOptions, + ConsentStatus, Defaults, WebExperimentClient, WebExperimentConfig, @@ -37,11 +39,7 @@ import { setMarketingCookie } from './util/cookie'; import { getInjectUtils } from './util/inject-utils'; import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger'; import { patchRemoveChild } from './util/patch'; -import { - getStorageItem, - setStorageItem, - removeStorageItem, -} from './util/storage'; +import { ConsentAwareStorage } from './util/storage'; import { getUrlParams, removeQueryParams, @@ -103,6 +101,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // Preview mode is set by url params, postMessage or session storage, not chrome extension isPreviewMode = false; previewFlags: Record = {}; + private consentOptions: ConsentOptions = { + status: ConsentStatus.GRANTED, + }; + private storage: ConsentAwareStorage; constructor( apiKey: string, @@ -127,6 +129,13 @@ export class DefaultWebExperimentClient implements WebExperimentClient { ...(this.globalScope.experimentConfig ?? {}), }; + if (this.config.consentOptions) { + this.consentOptions = this.config.consentOptions; + } + + // Initialize consent-aware storage + this.storage = new ConsentAwareStorage(this.consentOptions.status); + this.initialFlags.forEach((flag: EvaluationFlag) => { const { key, variants, metadata = {} } = flag; @@ -175,7 +184,8 @@ export class DefaultWebExperimentClient implements WebExperimentClient { const urlParams = getUrlParams(); this.isVisualEditorMode = urlParams[VISUAL_EDITOR_PARAM] === 'true' || - getStorageItem('sessionStorage', VISUAL_EDITOR_SESSION_KEY) !== null; + this.storage.getItem('sessionStorage', VISUAL_EDITOR_SESSION_KEY) !== + null; this.subscriptionManager = new SubscriptionManager( this, this.messageBus, @@ -210,7 +220,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { const experimentStorageName = `EXP_${this.apiKey.slice(0, 10)}`; const user = - getStorageItem( + this.storage.getItem( 'localStorage', experimentStorageName, ) || {}; @@ -222,10 +232,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient { if (!user.web_exp_id) { user.web_exp_id = user.device_id || UUID(); delete user.device_id; - setStorageItem('localStorage', experimentStorageName, user); + this.storage.setItem('localStorage', experimentStorageName, user); } else if (user.web_exp_id && user.device_id) { delete user.device_id; - setStorageItem('localStorage', experimentStorageName, user); + this.storage.setItem('localStorage', experimentStorageName, user); } // evaluate variants for page targeting @@ -524,6 +534,12 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.customRedirectHandler = handler; } + public setConsentStatus(consentStatus: ConsentStatus) { + this.consentOptions.status = consentStatus; + // Update storage consent status to handle persistence behavior + this.storage.setConsentStatus(consentStatus); + } + private async fetchRemoteFlags() { try { // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -799,16 +815,16 @@ export class DefaultWebExperimentClient implements WebExperimentClient { const redirectStorageKey = `EXP_${this.apiKey.slice(0, 10)}_REDIRECT`; // Store the current flag and variant for exposure tracking after redirect const storedRedirects = - getStorageItem('sessionStorage', redirectStorageKey) || {}; + this.storage.getItem('sessionStorage', redirectStorageKey) || {}; storedRedirects[flagKey] = { redirectUrl, variant }; - setStorageItem('sessionStorage', redirectStorageKey, storedRedirects); + this.storage.setItem('sessionStorage', redirectStorageKey, storedRedirects); } private fireStoredRedirectImpressions() { // Check for stored redirects and process them const redirectStorageKey = `EXP_${this.apiKey.slice(0, 10)}_REDIRECT`; const storedRedirects = - getStorageItem('sessionStorage', redirectStorageKey) || {}; + this.storage.getItem('sessionStorage', redirectStorageKey) || {}; // If we have stored redirects, track exposures for them if (Object.keys(storedRedirects).length > 0) { @@ -833,7 +849,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // track exposure with timeout of 500ms this.globalScope.setTimeout(() => { const redirects = - getStorageItem('sessionStorage', redirectStorageKey) || {}; + this.storage.getItem('sessionStorage', redirectStorageKey) || {}; for (const storedFlagKey in redirects) { this.exposureWithDedupe( storedFlagKey, @@ -841,10 +857,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient { true, ); } - removeStorageItem('sessionStorage', redirectStorageKey); + this.storage.removeItem('sessionStorage', redirectStorageKey); }, 500); } else { - removeStorageItem('sessionStorage', redirectStorageKey); + this.storage.removeItem('sessionStorage', redirectStorageKey); } } @@ -857,7 +873,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } }); - setStorageItem('sessionStorage', PREVIEW_MODE_SESSION_KEY, { + this.storage.setItem('sessionStorage', PREVIEW_MODE_SESSION_KEY, { previewFlags: this.previewFlags, }); const previewParamsToRemove = [ @@ -875,7 +891,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // if in preview mode, listen for ForceVariant messages WindowMessenger.setup(); } else { - const previewState: PreviewState | null = getStorageItem( + const previewState: PreviewState | null = this.storage.getItem( 'sessionStorage', PREVIEW_MODE_SESSION_KEY, ); diff --git a/packages/experiment-tag/src/index.ts b/packages/experiment-tag/src/index.ts index 3ddf260d..fc3464ac 100644 --- a/packages/experiment-tag/src/index.ts +++ b/packages/experiment-tag/src/index.ts @@ -3,7 +3,7 @@ import { getGlobalScope } from '@amplitude/experiment-core'; import { DefaultWebExperimentClient } from './experiment'; import { HttpClient } from './preview/http'; import { SdkPreviewApi } from './preview/preview-api'; -import { WebExperimentConfig } from './types'; +import { ConsentStatus, WebExperimentConfig } from './types'; import { applyAntiFlickerCss } from './util/anti-flicker'; import { isPreviewMode } from './util/url'; @@ -13,6 +13,12 @@ export const initialize = ( pageObjects: string, config: WebExperimentConfig, ): void => { + if ( + getGlobalScope()?.globalScope.experimentConfig.consentOptions.status === + ConsentStatus.REJECTED + ) { + return; + } const shouldFetchConfigs = isPreviewMode() || getGlobalScope()?.WebExperiment.injectedByExtension; diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index aa7b0b2f..521847e6 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -43,6 +43,16 @@ export type PageObject = { export type PageObjects = { [flagKey: string]: { [id: string]: PageObject } }; +export enum ConsentStatus { + REJECTED = 0, + GRANTED = 1, + PENDING = 2, +} + +export type ConsentOptions = { + status: ConsentStatus; +}; + export interface WebExperimentConfig extends ExperimentConfig { /** * Determines whether the default implementation for handling navigation will be used @@ -51,6 +61,7 @@ export interface WebExperimentConfig extends ExperimentConfig { * 2. Custom handling of navigation {@link setRedirectHandler} should be implemented such that variant actions applied on the site reflect the latest context */ useDefaultNavigationHandler?: boolean; + consentOptions?: ConsentOptions; } export const Defaults: WebExperimentConfig = { @@ -79,6 +90,8 @@ export interface WebExperimentClient { getActivePages(): PageObjects; setRedirectHandler(handler: (url: string) => void): void; + + setConsentStatus(consentStatus: ConsentStatus): void; } export type WebExperimentUser = { diff --git a/packages/experiment-tag/src/util/storage.ts b/packages/experiment-tag/src/util/storage.ts index 51c1cbf3..914e4d82 100644 --- a/packages/experiment-tag/src/util/storage.ts +++ b/packages/experiment-tag/src/util/storage.ts @@ -1,5 +1,7 @@ import { getGlobalScope } from '@amplitude/experiment-core'; +import { ConsentStatus } from '../types'; + export type StorageType = 'localStorage' | 'sessionStorage'; /** @@ -66,3 +68,89 @@ const getStorage = (storageType: StorageType): Storage | null => { } return globalScope[storageType]; }; + +/** + * Consent-aware storage manager that handles persistence based on consent status + */ +export class ConsentAwareStorage { + private inMemoryStorage: Map> = new Map(); + private consentStatus: ConsentStatus = ConsentStatus.PENDING; + + constructor(initialConsentStatus: ConsentStatus) { + this.consentStatus = initialConsentStatus; + } + + /** + * Set the consent status and handle persistence accordingly + */ + public setConsentStatus(consentStatus: ConsentStatus): void { + const previousStatus = this.consentStatus; + this.consentStatus = consentStatus; + + if (previousStatus === ConsentStatus.PENDING) { + if (consentStatus === ConsentStatus.GRANTED) { + for (const [ + storageType, + storageMap, + ] of this.inMemoryStorage.entries()) { + for (const [key, value] of storageMap.entries()) { + try { + const jsonString = JSON.stringify(value); + getStorage(storageType)?.setItem(key, jsonString); + } catch (error) { + console.warn(`Failed to persist data for key ${key}:`, error); + } + } + } + this.inMemoryStorage.clear(); + } + } + } + + /** + * Get a JSON value from storage with consent awareness + */ + public getItem(storageType: StorageType, key: string): T | null { + const storageMap = this.inMemoryStorage.get(storageType); + if (storageMap && storageMap.has(key)) { + return storageMap.get(key) as T; + } + + if (this.consentStatus === ConsentStatus.GRANTED) { + return getStorageItem(storageType, key); + } + + return null; + } + + /** + * Set a JSON value in storage with consent awareness + */ + public setItem(storageType: StorageType, key: string, value: unknown): void { + if (this.consentStatus === ConsentStatus.PENDING) { + if (!this.inMemoryStorage.has(storageType)) { + this.inMemoryStorage.set(storageType, new Map()); + } + this.inMemoryStorage.get(storageType)?.set(key, value); + } else if (this.consentStatus === ConsentStatus.GRANTED) { + setStorageItem(storageType, key, value); + } + } + + /** + * Remove a value from storage with consent awareness + */ + public removeItem(storageType: StorageType, key: string): void { + const storageMap = this.inMemoryStorage.get(storageType); + if (storageMap) { + storageMap.delete(key); + if (storageMap.size === 0) { + this.inMemoryStorage.delete(storageType); + } + } + + if (this.consentStatus === ConsentStatus.GRANTED) { + removeStorageItem(storageType, key); + } + } +} From c36d0fe7c891cb83ce5b7dee21df4e53a420f70a Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:16:11 -0700 Subject: [PATCH 02/11] add test cases --- packages/experiment-tag/src/experiment.ts | 2 +- .../src/{util => storage}/storage.ts | 32 +- packages/experiment-tag/src/util/messenger.ts | 2 +- packages/experiment-tag/src/util/url.ts | 2 +- packages/experiment-tag/test/storage.test.ts | 455 ++++++++++++++++++ 5 files changed, 471 insertions(+), 22 deletions(-) rename packages/experiment-tag/src/{util => storage}/storage.ts (86%) create mode 100644 packages/experiment-tag/test/storage.test.ts diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 681789e4..e1af7ffc 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -39,7 +39,7 @@ import { setMarketingCookie } from './util/cookie'; import { getInjectUtils } from './util/inject-utils'; import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger'; import { patchRemoveChild } from './util/patch'; -import { ConsentAwareStorage } from './util/storage'; +import { ConsentAwareStorage } from './storage/storage'; import { getUrlParams, removeQueryParams, diff --git a/packages/experiment-tag/src/util/storage.ts b/packages/experiment-tag/src/storage/storage.ts similarity index 86% rename from packages/experiment-tag/src/util/storage.ts rename to packages/experiment-tag/src/storage/storage.ts index 914e4d82..070245ed 100644 --- a/packages/experiment-tag/src/util/storage.ts +++ b/packages/experiment-tag/src/storage/storage.ts @@ -84,26 +84,20 @@ export class ConsentAwareStorage { * Set the consent status and handle persistence accordingly */ public setConsentStatus(consentStatus: ConsentStatus): void { - const previousStatus = this.consentStatus; this.consentStatus = consentStatus; - if (previousStatus === ConsentStatus.PENDING) { - if (consentStatus === ConsentStatus.GRANTED) { - for (const [ - storageType, - storageMap, - ] of this.inMemoryStorage.entries()) { - for (const [key, value] of storageMap.entries()) { - try { - const jsonString = JSON.stringify(value); - getStorage(storageType)?.setItem(key, jsonString); - } catch (error) { - console.warn(`Failed to persist data for key ${key}:`, error); - } + if (consentStatus === ConsentStatus.GRANTED) { + for (const [storageType, storageMap] of this.inMemoryStorage.entries()) { + for (const [key, value] of storageMap.entries()) { + try { + const jsonString = JSON.stringify(value); + getStorage(storageType)?.setItem(key, jsonString); + } catch (error) { + console.warn(`Failed to persist data for key ${key}:`, error); } } - this.inMemoryStorage.clear(); } + this.inMemoryStorage.clear(); } } @@ -111,15 +105,15 @@ export class ConsentAwareStorage { * Get a JSON value from storage with consent awareness */ public getItem(storageType: StorageType, key: string): T | null { + if (this.consentStatus === ConsentStatus.GRANTED) { + return getStorageItem(storageType, key); + } + const storageMap = this.inMemoryStorage.get(storageType); if (storageMap && storageMap.has(key)) { return storageMap.get(key) as T; } - if (this.consentStatus === ConsentStatus.GRANTED) { - return getStorageItem(storageType, key); - } - return null; } diff --git a/packages/experiment-tag/src/util/messenger.ts b/packages/experiment-tag/src/util/messenger.ts index 97dd5db9..1cf0bb19 100644 --- a/packages/experiment-tag/src/util/messenger.ts +++ b/packages/experiment-tag/src/util/messenger.ts @@ -1,6 +1,6 @@ import { getGlobalScope } from '@amplitude/experiment-core'; -import { getStorageItem } from './storage'; +import { getStorageItem } from '../storage/storage'; interface VisualEditorSession { injectSrc: string; diff --git a/packages/experiment-tag/src/util/url.ts b/packages/experiment-tag/src/util/url.ts index 743f2cbc..501b8835 100644 --- a/packages/experiment-tag/src/util/url.ts +++ b/packages/experiment-tag/src/util/url.ts @@ -3,7 +3,7 @@ import { getGlobalScope } from '@amplitude/experiment-core'; import { PREVIEW_MODE_PARAM, PREVIEW_MODE_SESSION_KEY } from '../experiment'; import { PreviewState } from '../types'; -import { getStorageItem } from './storage'; +import { getStorageItem } from '../storage/storage'; export const getUrlParams = (): Record => { const globalScope = getGlobalScope(); diff --git a/packages/experiment-tag/test/storage.test.ts b/packages/experiment-tag/test/storage.test.ts new file mode 100644 index 00000000..dd4bb8d4 --- /dev/null +++ b/packages/experiment-tag/test/storage.test.ts @@ -0,0 +1,455 @@ +import * as coreUtil from '@amplitude/experiment-core'; + +import { ConsentAwareStorage } from '../src/storage/storage'; +import { ConsentStatus } from '../src/types'; + +// Mock the getGlobalScope function +const spyGetGlobalScope = jest.spyOn(coreUtil, 'getGlobalScope'); + +describe('ConsentAwareStorage', () => { + let mockGlobal: any; + let storage: ConsentAwareStorage; + + const createStorageMock = () => { + let store: Record = {}; + return { + getItem: jest.fn((key: string) => store[key] || null), + setItem: jest.fn((key: string, value: string) => { + store[key] = value; + }), + removeItem: jest.fn((key: string) => { + delete store[key]; + }), + clear: jest.fn(() => { + store = {}; + }), + length: jest.fn(() => Object.keys(store).length), + key: jest.fn((index: number) => Object.keys(store)[index] || null), + }; + }; + + beforeEach(() => { + mockGlobal = { + localStorage: createStorageMock(), + sessionStorage: createStorageMock(), + }; + spyGetGlobalScope.mockReturnValue(mockGlobal); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with PENDING consent status', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + expect(storage).toBeDefined(); + }); + + it('should initialize with GRANTED consent status', () => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + expect(storage).toBeDefined(); + }); + + it('should initialize with REJECTED consent status', () => { + storage = new ConsentAwareStorage(ConsentStatus.REJECTED); + expect(storage).toBeDefined(); + }); + }); + + describe('setItem with PENDING consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + }); + + it('should store data in memory for localStorage', () => { + const testData = { key: 'value', number: 42 }; + storage.setItem('localStorage', 'testKey', testData); + + // Should not call actual storage + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + + // Should be retrievable from memory + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + + it('should store data in memory for sessionStorage', () => { + const testData = { key: 'value', array: [1, 2, 3] }; + storage.setItem('sessionStorage', 'testKey', testData); + + // Should not call actual storage + expect(mockGlobal.sessionStorage.setItem).not.toHaveBeenCalled(); + + // Should be retrievable from memory + const retrieved = storage.getItem('sessionStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + + it('should handle multiple keys in the same storage type', () => { + storage.setItem('localStorage', 'key1', 'value1'); + storage.setItem('localStorage', 'key2', 'value2'); + + expect(storage.getItem('localStorage', 'key1')).toBe('value1'); + expect(storage.getItem('localStorage', 'key2')).toBe('value2'); + }); + + it('should handle multiple storage types independently', () => { + storage.setItem('localStorage', 'key', 'localValue'); + storage.setItem('sessionStorage', 'key', 'sessionValue'); + + expect(storage.getItem('localStorage', 'key')).toBe('localValue'); + expect(storage.getItem('sessionStorage', 'key')).toBe('sessionValue'); + }); + }); + + describe('setItem with GRANTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + }); + + it('should store data directly in localStorage', () => { + const testData = { key: 'value' }; + storage.setItem('localStorage', 'testKey', testData); + + // Verify the data was stored by checking if we can retrieve it + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + + it('should store data directly in sessionStorage', () => { + const testData = { key: 'value' }; + storage.setItem('sessionStorage', 'testKey', testData); + + // Verify the data was stored by checking if we can retrieve it + const retrieved = storage.getItem('sessionStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + }); + + describe('setItem with REJECTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.REJECTED); + }); + + it('should not store data anywhere', () => { + storage.setItem('localStorage', 'testKey', { key: 'value' }); + + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + }); + + it('should not store data in sessionStorage', () => { + storage.setItem('sessionStorage', 'testKey', { key: 'value' }); + + expect(mockGlobal.sessionStorage.setItem).not.toHaveBeenCalled(); + expect(storage.getItem('sessionStorage', 'testKey')).toBeNull(); + }); + }); + + describe('getItem with PENDING consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + }); + + it('should return data from memory if available', () => { + const testData = { key: 'value' }; + storage.setItem('localStorage', 'testKey', testData); + + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); + }); + + it('should return null if not in memory and consent is pending', () => { + const retrieved = storage.getItem('localStorage', 'nonExistentKey'); + expect(retrieved).toBeNull(); + expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); + + describe('getItem with GRANTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + }); + + it('should return data from actual storage, not memory', () => { + const testData = { key: 'value' }; + + // Store data using setItem + storage.setItem('localStorage', 'testKey', testData); + + // Should be able to retrieve the same data + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + }); + + it('should return null for non-existent keys', () => { + const retrieved = storage.getItem('localStorage', 'nonExistentKey'); + expect(retrieved).toBeNull(); + }); + }); + + describe('getItem with REJECTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.REJECTED); + }); + + it('should return null and not access actual storage', () => { + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toBeNull(); + expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); + }); + }); + + describe('removeItem with PENDING consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + }); + + it('should remove data from memory', () => { + storage.setItem('localStorage', 'testKey', { key: 'value' }); + expect(storage.getItem('localStorage', 'testKey')).not.toBeNull(); + + storage.removeItem('localStorage', 'testKey'); + expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + expect(mockGlobal.localStorage.removeItem).not.toHaveBeenCalled(); + }); + + it('should handle removing non-existent keys gracefully', () => { + storage.removeItem('localStorage', 'nonExistentKey'); + expect(mockGlobal.localStorage.removeItem).not.toHaveBeenCalled(); + }); + + it('should clean up empty storage maps', () => { + storage.setItem('localStorage', 'testKey', { key: 'value' }); + storage.removeItem('localStorage', 'testKey'); + + // Add another item to different storage to verify cleanup + storage.setItem('sessionStorage', 'otherKey', { other: 'value' }); + expect(storage.getItem('sessionStorage', 'otherKey')).not.toBeNull(); + }); + }); + + describe('removeItem with GRANTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + }); + + it('should remove data from both memory and actual storage', () => { + storage.setItem('localStorage', 'testKey', { key: 'value' }); + storage.removeItem('localStorage', 'testKey'); + + expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + }); + + it('should handle removing non-existent keys gracefully', () => { + storage.removeItem('localStorage', 'testKey'); + expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + }); + }); + + describe('removeItem with REJECTED consent', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.REJECTED); + }); + + it('should not remove from actual storage', () => { + storage.removeItem('localStorage', 'testKey'); + expect(mockGlobal.localStorage.removeItem).not.toHaveBeenCalled(); + }); + }); + + describe('setConsentStatus', () => { + it('should persist in-memory data when changing from PENDING to GRANTED', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + // Store data in memory + const localData = { local: 'value' }; + const sessionData = { session: 'value' }; + storage.setItem('localStorage', 'localKey', localData); + storage.setItem('sessionStorage', 'sessionKey', sessionData); + + // Change consent to granted + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Verify data is now accessible from actual storage (not memory) + expect(storage.getItem('localStorage', 'localKey')).toEqual(localData); + expect(storage.getItem('sessionStorage', 'sessionKey')).toEqual( + sessionData, + ); + }); + + it('should handle persistence errors gracefully', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + + // Store data that will cause JSON.stringify to fail + const circularData: any = {}; + circularData.self = circularData; + storage.setItem('localStorage', 'circularKey', circularData); + + storage.setConsentStatus(ConsentStatus.GRANTED); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to persist data for key circularKey:', + expect.any(Error), + ); + + consoleSpy.mockRestore(); + }); + + it('should not persist data when changing from PENDING to REJECTED', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + storage.setItem('localStorage', 'testKey', { key: 'value' }); + storage.setConsentStatus(ConsentStatus.REJECTED); + + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + }); + + it('should not affect storage when changing from GRANTED to REJECTED', () => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + + storage.setItem('localStorage', 'testKey', { key: 'value' }); + + // Verify data is stored + expect(storage.getItem('localStorage', 'testKey')).toEqual({ + key: 'value', + }); + + storage.setConsentStatus(ConsentStatus.REJECTED); + + // After changing to REJECTED, getItem should return null because data is not in memory + // (it was stored directly to actual storage when consent was GRANTED) + expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + }); + + it('should not affect storage when changing from REJECTED to GRANTED', () => { + storage = new ConsentAwareStorage(ConsentStatus.REJECTED); + + storage.setItem('localStorage', 'testKey', { key: 'value' }); + storage.setConsentStatus(ConsentStatus.GRANTED); + + // No storage calls should have been made + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + }); + + it('should only persist on first transition from PENDING to GRANTED', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + storage.setItem('localStorage', 'testKey', { key: 'value' }); + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Clear mock calls + jest.clearAllMocks(); + + // Change status again + storage.setConsentStatus(ConsentStatus.REJECTED); + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Should not trigger persistence again + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + + describe('complex scenarios', () => { + it('should handle mixed storage types and multiple keys', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + // Store multiple items in different storage types + storage.setItem('localStorage', 'key1', { value: 1 }); + storage.setItem('localStorage', 'key2', { value: 2 }); + storage.setItem('sessionStorage', 'key1', { value: 3 }); + storage.setItem('sessionStorage', 'key2', { value: 4 }); + + // Verify all are in memory + expect(storage.getItem('localStorage', 'key1')).toEqual({ value: 1 }); + expect(storage.getItem('localStorage', 'key2')).toEqual({ value: 2 }); + expect(storage.getItem('sessionStorage', 'key1')).toEqual({ value: 3 }); + expect(storage.getItem('sessionStorage', 'key2')).toEqual({ value: 4 }); + + // Grant consent + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Verify all are still accessible after persistence + expect(storage.getItem('localStorage', 'key1')).toEqual({ value: 1 }); + expect(storage.getItem('localStorage', 'key2')).toEqual({ value: 2 }); + expect(storage.getItem('sessionStorage', 'key1')).toEqual({ value: 3 }); + expect(storage.getItem('sessionStorage', 'key2')).toEqual({ value: 4 }); + }); + + it('should handle consent status changes during active usage', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + // Store some data + storage.setItem('localStorage', 'key1', { value: 1 }); + + // Grant consent + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Store more data (should go directly to storage) + storage.setItem('localStorage', 'key2', { value: 2 }); + + // Verify both items are accessible from actual storage + expect(storage.getItem('localStorage', 'key1')).toEqual({ value: 1 }); + expect(storage.getItem('localStorage', 'key2')).toEqual({ value: 2 }); + }); + + it('should handle empty and null values correctly', () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + storage.setItem('localStorage', 'nullKey', null); + storage.setItem('localStorage', 'emptyKey', ''); + storage.setItem('localStorage', 'zeroKey', 0); + storage.setItem('localStorage', 'falseKey', false); + + expect(storage.getItem('localStorage', 'nullKey')).toBeNull(); + expect(storage.getItem('localStorage', 'emptyKey')).toBe(''); + expect(storage.getItem('localStorage', 'zeroKey')).toBe(0); + expect(storage.getItem('localStorage', 'falseKey')).toBe(false); + + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Verify values are still accessible after persistence + expect(storage.getItem('localStorage', 'nullKey')).toBeNull(); + expect(storage.getItem('localStorage', 'emptyKey')).toBe(''); + expect(storage.getItem('localStorage', 'zeroKey')).toBe(0); + expect(storage.getItem('localStorage', 'falseKey')).toBe(false); + }); + }); + + describe('type safety', () => { + beforeEach(() => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + }); + + it('should handle typed getItem correctly', () => { + interface TestData { + name: string; + count: number; + } + + const testData: TestData = { name: 'test', count: 42 }; + storage.setItem('localStorage', 'typedKey', testData); + + const retrieved = storage.getItem('localStorage', 'typedKey'); + expect(retrieved).toEqual(testData); + expect(retrieved?.name).toBe('test'); + expect(retrieved?.count).toBe(42); + }); + + it('should return null for non-existent typed items', () => { + interface TestData { + name: string; + } + + const retrieved = storage.getItem( + 'localStorage', + 'nonExistentKey', + ); + expect(retrieved).toBeNull(); + }); + }); +}); From 1723edc185a95e746c7e8545935f5c5a94dffade Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 1 Oct 2025 15:42:07 -0700 Subject: [PATCH 03/11] simplify tests, fix lint --- packages/experiment-tag/src/experiment.ts | 2 +- .../experiment-tag/src/storage/storage.ts | 8 +- packages/experiment-tag/src/util/url.ts | 3 +- packages/experiment-tag/test/storage.test.ts | 171 +----------------- 4 files changed, 7 insertions(+), 177 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index e1af7ffc..7b7e67ce 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -17,6 +17,7 @@ import mutate, { MutationController } from 'dom-mutator'; import { MessageBus } from './message-bus'; import { showPreviewModeModal } from './preview/preview'; +import { ConsentAwareStorage } from './storage/storage'; import { PageChangeEvent, SubscriptionManager } from './subscriptions'; import { ConsentOptions, @@ -39,7 +40,6 @@ import { setMarketingCookie } from './util/cookie'; import { getInjectUtils } from './util/inject-utils'; import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger'; import { patchRemoveChild } from './util/patch'; -import { ConsentAwareStorage } from './storage/storage'; import { getUrlParams, removeQueryParams, diff --git a/packages/experiment-tag/src/storage/storage.ts b/packages/experiment-tag/src/storage/storage.ts index 070245ed..759a235b 100644 --- a/packages/experiment-tag/src/storage/storage.ts +++ b/packages/experiment-tag/src/storage/storage.ts @@ -136,15 +136,15 @@ export class ConsentAwareStorage { */ public removeItem(storageType: StorageType, key: string): void { const storageMap = this.inMemoryStorage.get(storageType); + if (this.consentStatus === ConsentStatus.GRANTED) { + removeStorageItem(storageType, key); + return; + } if (storageMap) { storageMap.delete(key); if (storageMap.size === 0) { this.inMemoryStorage.delete(storageType); } } - - if (this.consentStatus === ConsentStatus.GRANTED) { - removeStorageItem(storageType, key); - } } } diff --git a/packages/experiment-tag/src/util/url.ts b/packages/experiment-tag/src/util/url.ts index 501b8835..12089f27 100644 --- a/packages/experiment-tag/src/util/url.ts +++ b/packages/experiment-tag/src/util/url.ts @@ -1,9 +1,8 @@ import { getGlobalScope } from '@amplitude/experiment-core'; import { PREVIEW_MODE_PARAM, PREVIEW_MODE_SESSION_KEY } from '../experiment'; -import { PreviewState } from '../types'; - import { getStorageItem } from '../storage/storage'; +import { PreviewState } from '../types'; export const getUrlParams = (): Record => { const globalScope = getGlobalScope(); diff --git a/packages/experiment-tag/test/storage.test.ts b/packages/experiment-tag/test/storage.test.ts index dd4bb8d4..f3703d9f 100644 --- a/packages/experiment-tag/test/storage.test.ts +++ b/packages/experiment-tag/test/storage.test.ts @@ -41,23 +41,6 @@ describe('ConsentAwareStorage', () => { jest.restoreAllMocks(); }); - describe('constructor', () => { - it('should initialize with PENDING consent status', () => { - storage = new ConsentAwareStorage(ConsentStatus.PENDING); - expect(storage).toBeDefined(); - }); - - it('should initialize with GRANTED consent status', () => { - storage = new ConsentAwareStorage(ConsentStatus.GRANTED); - expect(storage).toBeDefined(); - }); - - it('should initialize with REJECTED consent status', () => { - storage = new ConsentAwareStorage(ConsentStatus.REJECTED); - expect(storage).toBeDefined(); - }); - }); - describe('setItem with PENDING consent', () => { beforeEach(() => { storage = new ConsentAwareStorage(ConsentStatus.PENDING); @@ -67,10 +50,8 @@ describe('ConsentAwareStorage', () => { const testData = { key: 'value', number: 42 }; storage.setItem('localStorage', 'testKey', testData); - // Should not call actual storage expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); - // Should be retrievable from memory const retrieved = storage.getItem('localStorage', 'testKey'); expect(retrieved).toEqual(testData); }); @@ -79,10 +60,8 @@ describe('ConsentAwareStorage', () => { const testData = { key: 'value', array: [1, 2, 3] }; storage.setItem('sessionStorage', 'testKey', testData); - // Should not call actual storage expect(mockGlobal.sessionStorage.setItem).not.toHaveBeenCalled(); - // Should be retrievable from memory const retrieved = storage.getItem('sessionStorage', 'testKey'); expect(retrieved).toEqual(testData); }); @@ -113,7 +92,6 @@ describe('ConsentAwareStorage', () => { const testData = { key: 'value' }; storage.setItem('localStorage', 'testKey', testData); - // Verify the data was stored by checking if we can retrieve it const retrieved = storage.getItem('localStorage', 'testKey'); expect(retrieved).toEqual(testData); }); @@ -122,7 +100,6 @@ describe('ConsentAwareStorage', () => { const testData = { key: 'value' }; storage.setItem('sessionStorage', 'testKey', testData); - // Verify the data was stored by checking if we can retrieve it const retrieved = storage.getItem('sessionStorage', 'testKey'); expect(retrieved).toEqual(testData); }); @@ -177,10 +154,8 @@ describe('ConsentAwareStorage', () => { it('should return data from actual storage, not memory', () => { const testData = { key: 'value' }; - // Store data using setItem storage.setItem('localStorage', 'testKey', testData); - // Should be able to retrieve the same data const retrieved = storage.getItem('localStorage', 'testKey'); expect(retrieved).toEqual(testData); }); @@ -226,7 +201,6 @@ describe('ConsentAwareStorage', () => { storage.setItem('localStorage', 'testKey', { key: 'value' }); storage.removeItem('localStorage', 'testKey'); - // Add another item to different storage to verify cleanup storage.setItem('sessionStorage', 'otherKey', { other: 'value' }); expect(storage.getItem('sessionStorage', 'otherKey')).not.toBeNull(); }); @@ -237,7 +211,7 @@ describe('ConsentAwareStorage', () => { storage = new ConsentAwareStorage(ConsentStatus.GRANTED); }); - it('should remove data from both memory and actual storage', () => { + it('should remove data from actual storage', () => { storage.setItem('localStorage', 'testKey', { key: 'value' }); storage.removeItem('localStorage', 'testKey'); @@ -308,148 +282,5 @@ describe('ConsentAwareStorage', () => { expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); }); - - it('should not affect storage when changing from GRANTED to REJECTED', () => { - storage = new ConsentAwareStorage(ConsentStatus.GRANTED); - - storage.setItem('localStorage', 'testKey', { key: 'value' }); - - // Verify data is stored - expect(storage.getItem('localStorage', 'testKey')).toEqual({ - key: 'value', - }); - - storage.setConsentStatus(ConsentStatus.REJECTED); - - // After changing to REJECTED, getItem should return null because data is not in memory - // (it was stored directly to actual storage when consent was GRANTED) - expect(storage.getItem('localStorage', 'testKey')).toBeNull(); - }); - - it('should not affect storage when changing from REJECTED to GRANTED', () => { - storage = new ConsentAwareStorage(ConsentStatus.REJECTED); - - storage.setItem('localStorage', 'testKey', { key: 'value' }); - storage.setConsentStatus(ConsentStatus.GRANTED); - - // No storage calls should have been made - expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); - }); - - it('should only persist on first transition from PENDING to GRANTED', () => { - storage = new ConsentAwareStorage(ConsentStatus.PENDING); - - storage.setItem('localStorage', 'testKey', { key: 'value' }); - storage.setConsentStatus(ConsentStatus.GRANTED); - - // Clear mock calls - jest.clearAllMocks(); - - // Change status again - storage.setConsentStatus(ConsentStatus.REJECTED); - storage.setConsentStatus(ConsentStatus.GRANTED); - - // Should not trigger persistence again - expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); - }); - }); - - describe('complex scenarios', () => { - it('should handle mixed storage types and multiple keys', () => { - storage = new ConsentAwareStorage(ConsentStatus.PENDING); - - // Store multiple items in different storage types - storage.setItem('localStorage', 'key1', { value: 1 }); - storage.setItem('localStorage', 'key2', { value: 2 }); - storage.setItem('sessionStorage', 'key1', { value: 3 }); - storage.setItem('sessionStorage', 'key2', { value: 4 }); - - // Verify all are in memory - expect(storage.getItem('localStorage', 'key1')).toEqual({ value: 1 }); - expect(storage.getItem('localStorage', 'key2')).toEqual({ value: 2 }); - expect(storage.getItem('sessionStorage', 'key1')).toEqual({ value: 3 }); - expect(storage.getItem('sessionStorage', 'key2')).toEqual({ value: 4 }); - - // Grant consent - storage.setConsentStatus(ConsentStatus.GRANTED); - - // Verify all are still accessible after persistence - expect(storage.getItem('localStorage', 'key1')).toEqual({ value: 1 }); - expect(storage.getItem('localStorage', 'key2')).toEqual({ value: 2 }); - expect(storage.getItem('sessionStorage', 'key1')).toEqual({ value: 3 }); - expect(storage.getItem('sessionStorage', 'key2')).toEqual({ value: 4 }); - }); - - it('should handle consent status changes during active usage', () => { - storage = new ConsentAwareStorage(ConsentStatus.PENDING); - - // Store some data - storage.setItem('localStorage', 'key1', { value: 1 }); - - // Grant consent - storage.setConsentStatus(ConsentStatus.GRANTED); - - // Store more data (should go directly to storage) - storage.setItem('localStorage', 'key2', { value: 2 }); - - // Verify both items are accessible from actual storage - expect(storage.getItem('localStorage', 'key1')).toEqual({ value: 1 }); - expect(storage.getItem('localStorage', 'key2')).toEqual({ value: 2 }); - }); - - it('should handle empty and null values correctly', () => { - storage = new ConsentAwareStorage(ConsentStatus.PENDING); - - storage.setItem('localStorage', 'nullKey', null); - storage.setItem('localStorage', 'emptyKey', ''); - storage.setItem('localStorage', 'zeroKey', 0); - storage.setItem('localStorage', 'falseKey', false); - - expect(storage.getItem('localStorage', 'nullKey')).toBeNull(); - expect(storage.getItem('localStorage', 'emptyKey')).toBe(''); - expect(storage.getItem('localStorage', 'zeroKey')).toBe(0); - expect(storage.getItem('localStorage', 'falseKey')).toBe(false); - - storage.setConsentStatus(ConsentStatus.GRANTED); - - // Verify values are still accessible after persistence - expect(storage.getItem('localStorage', 'nullKey')).toBeNull(); - expect(storage.getItem('localStorage', 'emptyKey')).toBe(''); - expect(storage.getItem('localStorage', 'zeroKey')).toBe(0); - expect(storage.getItem('localStorage', 'falseKey')).toBe(false); - }); - }); - - describe('type safety', () => { - beforeEach(() => { - storage = new ConsentAwareStorage(ConsentStatus.GRANTED); - }); - - it('should handle typed getItem correctly', () => { - interface TestData { - name: string; - count: number; - } - - const testData: TestData = { name: 'test', count: 42 }; - storage.setItem('localStorage', 'typedKey', testData); - - const retrieved = storage.getItem('localStorage', 'typedKey'); - expect(retrieved).toEqual(testData); - expect(retrieved?.name).toBe('test'); - expect(retrieved?.count).toBe(42); - }); - - it('should return null for non-existent typed items', () => { - interface TestData { - name: string; - } - - const retrieved = storage.getItem( - 'localStorage', - 'nonExistentKey', - ); - expect(retrieved).toBeNull(); - }); }); }); From 05b19658aa4b17164b2cd14e9b3ab31129c720c7 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:07:31 -0700 Subject: [PATCH 04/11] consent aware marketing cookies --- packages/experiment-tag/src/experiment.ts | 7 +- .../experiment-tag/src/storage/storage.ts | 57 ++++++- packages/experiment-tag/src/types.ts | 2 +- packages/experiment-tag/src/util/cookie.ts | 19 --- .../experiment-tag/test/experiment.test.ts | 139 ++++++++++++++++ packages/experiment-tag/test/storage.test.ts | 151 +++++++++++++++++- 6 files changed, 340 insertions(+), 35 deletions(-) delete mode 100644 packages/experiment-tag/src/util/cookie.ts diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 7b7e67ce..c54b6cce 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -36,7 +36,6 @@ import { RevertVariantsOptions, } from './types'; import { applyAntiFlickerCss } from './util/anti-flicker'; -import { setMarketingCookie } from './util/cookie'; import { getInjectUtils } from './util/inject-utils'; import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger'; import { patchRemoveChild } from './util/patch'; @@ -133,7 +132,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.consentOptions = this.config.consentOptions; } - // Initialize consent-aware storage this.storage = new ConsentAwareStorage(this.consentOptions.status); this.initialFlags.forEach((flag: EvaluationFlag) => { @@ -536,7 +534,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient { public setConsentStatus(consentStatus: ConsentStatus) { this.consentOptions.status = consentStatus; - // Update storage consent status to handle persistence behavior this.storage.setConsentStatus(consentStatus); } @@ -594,7 +591,9 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // set previous url - relevant for SPA if redirect happens before push/replaceState is complete this.previousUrl = this.globalScope.location.href; - setMarketingCookie(this.apiKey).then(); + this.storage.setMarketingCookie(this.apiKey).catch((error) => { + console.warn('Failed to set marketing cookie:', error); + }); // perform redirection if (this.customRedirectHandler) { this.customRedirectHandler(targetUrl); diff --git a/packages/experiment-tag/src/storage/storage.ts b/packages/experiment-tag/src/storage/storage.ts index 759a235b..6bf2afc6 100644 --- a/packages/experiment-tag/src/storage/storage.ts +++ b/packages/experiment-tag/src/storage/storage.ts @@ -1,3 +1,5 @@ +import { CampaignParser, CookieStorage, MKTG } from '@amplitude/analytics-core'; +import type { Campaign } from '@amplitude/analytics-core'; import { getGlobalScope } from '@amplitude/experiment-core'; import { ConsentStatus } from '../types'; @@ -74,6 +76,7 @@ const getStorage = (storageType: StorageType): Storage | null => { */ export class ConsentAwareStorage { private inMemoryStorage: Map> = new Map(); + private inMemoryMarketingCookies: Map = new Map(); private consentStatus: ConsentStatus = ConsentStatus.PENDING; constructor(initialConsentStatus: ConsentStatus) { @@ -98,9 +101,34 @@ export class ConsentAwareStorage { } } this.inMemoryStorage.clear(); + this.persistMarketingCookies().then(); + this.inMemoryMarketingCookies.clear(); } } + /** + * Persist marketing cookies from memory to actual cookies + */ + private async persistMarketingCookies(): Promise { + for (const [ + storageKey, + campaign, + ] of this.inMemoryMarketingCookies.entries()) { + try { + const cookieStorage = new CookieStorage({ + sameSite: 'Lax', + }); + await cookieStorage.set(storageKey, campaign); + } catch (error) { + console.warn( + `Failed to persist marketing cookie for key ${storageKey}:`, + error, + ); + } + } + this.inMemoryMarketingCookies.clear(); + } + /** * Get a JSON value from storage with consent awareness */ @@ -121,13 +149,13 @@ export class ConsentAwareStorage { * Set a JSON value in storage with consent awareness */ public setItem(storageType: StorageType, key: string, value: unknown): void { - if (this.consentStatus === ConsentStatus.PENDING) { + if (this.consentStatus === ConsentStatus.GRANTED) { + setStorageItem(storageType, key, value); + } else { if (!this.inMemoryStorage.has(storageType)) { this.inMemoryStorage.set(storageType, new Map()); } this.inMemoryStorage.get(storageType)?.set(key, value); - } else if (this.consentStatus === ConsentStatus.GRANTED) { - setStorageItem(storageType, key, value); } } @@ -147,4 +175,27 @@ export class ConsentAwareStorage { } } } + + /** + * Set marketing cookie with consent awareness + * Parses current campaign data from URL and referrer, then stores it in the marketing cookie + */ + public async setMarketingCookie(apiKey: string): Promise { + try { + const parser = new CampaignParser(); + const storageKey = `AMP_${MKTG}_ORIGINAL_${apiKey.substring(0, 10)}`; + const campaign = await parser.parse(); + + if (this.consentStatus === ConsentStatus.GRANTED) { + const cookieStorage = new CookieStorage({ + sameSite: 'Lax', + }); + await cookieStorage.set(storageKey, campaign); + } else { + this.inMemoryMarketingCookies.set(storageKey, campaign); + } + } catch (error) { + console.warn('Failed to set marketing cookie:', error); + } + } } diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index 521847e6..dc426eb0 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -55,7 +55,7 @@ export type ConsentOptions = { export interface WebExperimentConfig extends ExperimentConfig { /** - * Determines whether the default implementation for handling navigation will be used + * Determines whether the default implementation for handling navigation will be used * If this is set to false, for single-page applications: * 1. The variant actions applied will be based on the context (user, page URL) when the web experiment script was loaded * 2. Custom handling of navigation {@link setRedirectHandler} should be implemented such that variant actions applied on the site reflect the latest context diff --git a/packages/experiment-tag/src/util/cookie.ts b/packages/experiment-tag/src/util/cookie.ts deleted file mode 100644 index 2042b693..00000000 --- a/packages/experiment-tag/src/util/cookie.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { CampaignParser } from '@amplitude/analytics-core'; -import { CookieStorage } from '@amplitude/analytics-core'; -import { MKTG } from '@amplitude/analytics-core'; -import type { Campaign } from '@amplitude/analytics-core'; - -/** - * Utility function to generate and set marketing cookie - * Parses current campaign data from URL and referrer, then stores it in the marketing cookie - */ -export async function setMarketingCookie(apiKey: string) { - const storage = new CookieStorage({ - sameSite: 'Lax', - }); - - const parser = new CampaignParser(); - const storageKey = `AMP_${MKTG}_ORIGINAL_${apiKey.substring(0, 10)}`; - const campaign = await parser.parse(); - await storage.set(storageKey, campaign); -} diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 059882be..776675ae 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -3,6 +3,7 @@ import { safeGlobal } from '@amplitude/experiment-core'; import { ExperimentClient } from '@amplitude/experiment-js-client'; import { Base64 } from 'js-base64'; import { DefaultWebExperimentClient } from 'src/experiment'; +import { ConsentStatus } from 'src/types'; import * as antiFlickerUtils from 'src/util/anti-flicker'; import * as uuid from 'src/util/uuid'; import { stringify } from 'ts-jest'; @@ -1228,6 +1229,144 @@ describe('initializeExperiment', () => { expect(mockGlobal.sessionStorage.getItem(redirectStorageKey)).toBeNull(); }); + describe('consent status initialization and storage persistence', () => { + let mockConsentAwareStorage: any; + + beforeEach(() => { + // Mock ConsentAwareStorage + mockConsentAwareStorage = { + setItem: jest.fn(), + getItem: jest.fn(), + removeItem: jest.fn(), + setConsentStatus: jest.fn(), + }; + }); + + it('should initialize experiment with PENDING consent and store data in memory only', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + client.start(); + + expect(mockExposure).toHaveBeenCalledWith('test'); + + // With PENDING consent, data should be stored in memory only, not in actual localStorage + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + }); + + it('should initialize experiment with GRANTED consent and store data directly in actual storage', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.GRANTED, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + client.start(); + + expect(mockExposure).toHaveBeenCalledWith('test'); + expect(mockGlobal.localStorage.setItem).toHaveBeenCalledWith( + 'EXP_' + stringify(apiKey), + JSON.stringify({ web_exp_id: 'mock' }), + ); + }); + + it('should handle consent status change from PENDING to GRANTED during experiment lifecycle', () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + client.start(); + + expect(mockExposure).toHaveBeenCalledWith('test'); + + // Clear any previous localStorage calls from start() + jest.clearAllMocks(); + + // Change consent status to GRANTED + client.setConsentStatus(ConsentStatus.GRANTED); + + // Trigger some action that would cause storage operations + client.applyVariants(); + + // Verify that previously stored data is now persisted to actual storage + expect(mockGlobal.localStorage.setItem).toHaveBeenCalled(); + }); + + it('should handle consent status change from PENDING to REJECTED during experiment lifecycle', () => { + // Start with PENDING consent + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createMutateFlag('test', 'treatment', [DEFAULT_MUTATE_SCOPE]), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + client.start(); + + expect(mockExposure).toHaveBeenCalledWith('test'); + + // Clear any previous localStorage calls from start() + jest.clearAllMocks(); + + client.setConsentStatus(ConsentStatus.REJECTED); + + const experimentStorageKey = `EXP_${stringify(apiKey).slice(0, 10)}`; + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalledWith( + experimentStorageKey, + expect.stringContaining('web_exp_id'), + ); + + expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); + }); + }); + describe('remote evaluation - flag already stored in session storage', () => { const sessionStorageMock = () => { let store = {}; diff --git a/packages/experiment-tag/test/storage.test.ts b/packages/experiment-tag/test/storage.test.ts index f3703d9f..eadc8ed7 100644 --- a/packages/experiment-tag/test/storage.test.ts +++ b/packages/experiment-tag/test/storage.test.ts @@ -1,11 +1,25 @@ +import { CampaignParser, CookieStorage } from '@amplitude/analytics-core'; import * as coreUtil from '@amplitude/experiment-core'; import { ConsentAwareStorage } from '../src/storage/storage'; import { ConsentStatus } from '../src/types'; -// Mock the getGlobalScope function const spyGetGlobalScope = jest.spyOn(coreUtil, 'getGlobalScope'); +// Mock CampaignParser and CookieStorage +jest.mock('@amplitude/analytics-core', () => ({ + CampaignParser: jest.fn(), + CookieStorage: jest.fn(), + MKTG: 'MKTG', +})); + +const MockCampaignParser = CampaignParser as jest.MockedClass< + typeof CampaignParser +>; +const MockCookieStorage = CookieStorage as jest.MockedClass< + typeof CookieStorage +>; + describe('ConsentAwareStorage', () => { let mockGlobal: any; let storage: ConsentAwareStorage; @@ -110,18 +124,20 @@ describe('ConsentAwareStorage', () => { storage = new ConsentAwareStorage(ConsentStatus.REJECTED); }); - it('should not store data anywhere', () => { - storage.setItem('localStorage', 'testKey', { key: 'value' }); + it('should store data in memory but not in actual storage', () => { + const testData = { key: 'value' }; + storage.setItem('localStorage', 'testKey', testData); expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); - expect(storage.getItem('localStorage', 'testKey')).toBeNull(); + expect(storage.getItem('localStorage', 'testKey')).toEqual(testData); }); - it('should not store data in sessionStorage', () => { - storage.setItem('sessionStorage', 'testKey', { key: 'value' }); + it('should store data in memory for sessionStorage but not in actual storage', () => { + const testData = { key: 'value' }; + storage.setItem('sessionStorage', 'testKey', testData); expect(mockGlobal.sessionStorage.setItem).not.toHaveBeenCalled(); - expect(storage.getItem('sessionStorage', 'testKey')).toBeNull(); + expect(storage.getItem('sessionStorage', 'testKey')).toEqual(testData); }); }); @@ -171,8 +187,17 @@ describe('ConsentAwareStorage', () => { storage = new ConsentAwareStorage(ConsentStatus.REJECTED); }); - it('should return null and not access actual storage', () => { + it('should return data from memory and not access actual storage', () => { + const testData = { key: 'value' }; + storage.setItem('localStorage', 'testKey', testData); + const retrieved = storage.getItem('localStorage', 'testKey'); + expect(retrieved).toEqual(testData); + expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); + }); + + it('should return null for non-existent keys', () => { + const retrieved = storage.getItem('localStorage', 'nonExistentKey'); expect(retrieved).toBeNull(); expect(mockGlobal.localStorage.getItem).not.toHaveBeenCalled(); }); @@ -283,4 +308,114 @@ describe('ConsentAwareStorage', () => { expect(mockGlobal.localStorage.setItem).not.toHaveBeenCalled(); }); }); + + describe('setMarketingCookie', () => { + let mockCampaignParser: any; + let mockCookieStorage: any; + const mockCampaign = { utm_source: 'test', utm_medium: 'test' }; + const testApiKey = 'test-api-key-1234567890'; + + beforeEach(() => { + mockCampaignParser = { + parse: jest.fn().mockResolvedValue(mockCampaign), + }; + mockCookieStorage = { + set: jest.fn().mockResolvedValue(undefined), + }; + + MockCampaignParser.mockImplementation(() => mockCampaignParser); + MockCookieStorage.mockImplementation(() => mockCookieStorage); + }); + + it('should set marketing cookie directly when consent is GRANTED', async () => { + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + + await storage.setMarketingCookie(testApiKey); + + expect(MockCampaignParser).toHaveBeenCalledTimes(1); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); + expect(MockCookieStorage).toHaveBeenCalledWith({ sameSite: 'Lax' }); + expect(mockCookieStorage.set).toHaveBeenCalledWith( + 'AMP_MKTG_ORIGINAL_test-api-k', + mockCampaign, + ); + }); + + it('should store marketing cookie in memory when consent is PENDING', async () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + await storage.setMarketingCookie(testApiKey); + + expect(MockCampaignParser).toHaveBeenCalledTimes(1); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); + expect(MockCookieStorage).not.toHaveBeenCalled(); + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + }); + + it('should persist marketing cookies when consent changes from PENDING to GRANTED', async () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + // Set marketing cookie while consent is pending + await storage.setMarketingCookie(testApiKey); + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + + // Change consent to granted + storage.setConsentStatus(ConsentStatus.GRANTED); + + // Wait for async persistence to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(MockCookieStorage).toHaveBeenCalledWith({ sameSite: 'Lax' }); + expect(mockCookieStorage.set).toHaveBeenCalledWith( + 'AMP_MKTG_ORIGINAL_test-api-k', + mockCampaign, + ); + }); + + it('should handle marketing cookie errors gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const error = new Error('Cookie error'); + mockCookieStorage.set.mockRejectedValue(error); + + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + + await storage.setMarketingCookie(testApiKey); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to set marketing cookie:', + error, + ); + + consoleSpy.mockRestore(); + }); + + it('should handle campaign parser errors gracefully', async () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + const error = new Error('Parser error'); + mockCampaignParser.parse.mockRejectedValue(error); + + storage = new ConsentAwareStorage(ConsentStatus.GRANTED); + + await storage.setMarketingCookie(testApiKey); + + expect(consoleSpy).toHaveBeenCalledWith( + 'Failed to set marketing cookie:', + error, + ); + + consoleSpy.mockRestore(); + }); + + it('should clear marketing cookies when consent changes from PENDING to REJECTED', async () => { + storage = new ConsentAwareStorage(ConsentStatus.PENDING); + + await storage.setMarketingCookie(testApiKey); + + storage.setConsentStatus(ConsentStatus.REJECTED); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + }); + }); }); From 93a9de88741d4069816619e2ac15c99bc925b81f Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:19:26 -0700 Subject: [PATCH 05/11] clean up comments --- packages/experiment-tag/test/experiment.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 776675ae..5c2303b6 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1320,18 +1320,13 @@ describe('initializeExperiment', () => { // Clear any previous localStorage calls from start() jest.clearAllMocks(); - // Change consent status to GRANTED client.setConsentStatus(ConsentStatus.GRANTED); - // Trigger some action that would cause storage operations - client.applyVariants(); - // Verify that previously stored data is now persisted to actual storage expect(mockGlobal.localStorage.setItem).toHaveBeenCalled(); }); it('should handle consent status change from PENDING to REJECTED during experiment lifecycle', () => { - // Start with PENDING consent const mockGlobal = newMockGlobal({ experimentConfig: { consentOptions: { From f87086bd2d89fb488d07fadf646d53176fc03b9b Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:33:37 -0700 Subject: [PATCH 06/11] add marketing cookies test --- .../experiment-tag/test/experiment.test.ts | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 5c2303b6..1cdc7263 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -1,3 +1,4 @@ +import { CampaignParser, CookieStorage } from '@amplitude/analytics-core'; import * as experimentCore from '@amplitude/experiment-core'; import { safeGlobal } from '@amplitude/experiment-core'; import { ExperimentClient } from '@amplitude/experiment-js-client'; @@ -27,6 +28,20 @@ jest.mock('src/util/messenger', () => { }; }); +jest.mock('@amplitude/analytics-core', () => ({ + ...jest.requireActual('@amplitude/analytics-core'), + CampaignParser: jest.fn(), + CookieStorage: jest.fn(), + MKTG: 'MKTG', +})); + +const MockCampaignParser = CampaignParser as jest.MockedClass< + typeof CampaignParser +>; +const MockCookieStorage = CookieStorage as jest.MockedClass< + typeof CookieStorage +>; + const newMockGlobal = (overrides?: Record) => { const createStorageMock = () => { let store: Record = {}; @@ -1362,6 +1377,135 @@ describe('initializeExperiment', () => { }); }); + describe('marketing cookie with different consent status', () => { + let mockCampaignParser: any; + let mockCookieStorage: any; + const mockCampaign = { utm_source: 'test', utm_medium: 'test' }; + + beforeEach(() => { + mockCampaignParser = { + parse: jest.fn().mockResolvedValue(mockCampaign), + }; + mockCookieStorage = { + set: jest.fn().mockResolvedValue(undefined), + }; + + MockCampaignParser.mockImplementation(() => mockCampaignParser); + MockCookieStorage.mockImplementation(() => mockCookieStorage); + }); + + it('should set marketing cookie directly during redirect when consent is GRANTED', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.GRANTED, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createRedirectFlag( + 'test', + 'treatment', + 'http://test.com/2', + undefined, + DEFAULT_REDIRECT_SCOPE, + ), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(mockGlobal.location.replace).toHaveBeenCalledWith( + 'http://test.com/2', + ); + + expect(MockCampaignParser).toHaveBeenCalledTimes(1); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); + expect(MockCookieStorage).toHaveBeenCalledWith({ sameSite: 'Lax' }); + expect(mockCookieStorage.set).toHaveBeenCalledWith( + `AMP_MKTG_ORIGINAL_${stringify(apiKey).substring(0, 10)}`, + mockCampaign, + ); + }); + + it('should store marketing cookie in memory during redirect when consent is PENDING', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.PENDING, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createRedirectFlag( + 'test', + 'treatment', + 'http://test.com/2', + undefined, + DEFAULT_REDIRECT_SCOPE, + ), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(mockGlobal.location.replace).toHaveBeenCalledWith( + 'http://test.com/2', + ); + + expect(MockCampaignParser).toHaveBeenCalledTimes(1); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); + expect(MockCookieStorage).not.toHaveBeenCalled(); + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + }); + + it('should not set marketing cookie during redirect when consent is REJECTED', async () => { + const mockGlobal = newMockGlobal({ + experimentConfig: { + consentOptions: { + status: ConsentStatus.REJECTED, + }, + }, + }); + mockGetGlobalScope.mockReturnValue(mockGlobal as any); + + const client = DefaultWebExperimentClient.getInstance( + stringify(apiKey), + JSON.stringify([ + createRedirectFlag( + 'test', + 'treatment', + 'http://test.com/2', + undefined, + DEFAULT_REDIRECT_SCOPE, + ), + ]), + JSON.stringify(DEFAULT_PAGE_OBJECTS), + ); + + await client.start(); + + expect(mockGlobal.location.replace).toHaveBeenCalledWith( + 'http://test.com/2', + ); + + expect(MockCampaignParser).toHaveBeenCalledTimes(1); + expect(mockCampaignParser.parse).toHaveBeenCalledTimes(1); + expect(MockCookieStorage).not.toHaveBeenCalled(); + expect(mockCookieStorage.set).not.toHaveBeenCalled(); + }); + }); + describe('remote evaluation - flag already stored in session storage', () => { const sessionStorageMock = () => { let store = {}; From 9c2428db4c9ac1b4cef95414c089139f139e9494 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:41:09 -0700 Subject: [PATCH 07/11] remove test --- packages/experiment-tag/test/storage.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/experiment-tag/test/storage.test.ts b/packages/experiment-tag/test/storage.test.ts index eadc8ed7..8a4f9aca 100644 --- a/packages/experiment-tag/test/storage.test.ts +++ b/packages/experiment-tag/test/storage.test.ts @@ -405,17 +405,5 @@ describe('ConsentAwareStorage', () => { consoleSpy.mockRestore(); }); - - it('should clear marketing cookies when consent changes from PENDING to REJECTED', async () => { - storage = new ConsentAwareStorage(ConsentStatus.PENDING); - - await storage.setMarketingCookie(testApiKey); - - storage.setConsentStatus(ConsentStatus.REJECTED); - - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(mockCookieStorage.set).not.toHaveBeenCalled(); - }); }); }); From cbc703eca8a3c92a72fa395bf3c6bc1d66b01fc8 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 2 Oct 2025 11:50:20 -0700 Subject: [PATCH 08/11] simplify --- packages/experiment-tag/src/experiment.ts | 4 +--- packages/experiment-tag/src/storage/storage.ts | 1 - 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index c54b6cce..8ce5947e 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -591,9 +591,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // set previous url - relevant for SPA if redirect happens before push/replaceState is complete this.previousUrl = this.globalScope.location.href; - this.storage.setMarketingCookie(this.apiKey).catch((error) => { - console.warn('Failed to set marketing cookie:', error); - }); + this.storage.setMarketingCookie(this.apiKey).then(); // perform redirection if (this.customRedirectHandler) { this.customRedirectHandler(targetUrl); diff --git a/packages/experiment-tag/src/storage/storage.ts b/packages/experiment-tag/src/storage/storage.ts index 6bf2afc6..5fed6a3f 100644 --- a/packages/experiment-tag/src/storage/storage.ts +++ b/packages/experiment-tag/src/storage/storage.ts @@ -102,7 +102,6 @@ export class ConsentAwareStorage { } this.inMemoryStorage.clear(); this.persistMarketingCookies().then(); - this.inMemoryMarketingCookies.clear(); } } From 720379c18705678a15af1e7a9fdb70b1095253d7 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 2 Oct 2025 13:04:29 -0700 Subject: [PATCH 09/11] refactor storage --- packages/experiment-tag/src/experiment.ts | 2 +- .../src/storage/consent-aware-storage.ts | 140 ++++++++++++++++++ .../experiment-tag/src/storage/storage.ts | 134 +---------------- packages/experiment-tag/src/util/messenger.ts | 2 +- packages/experiment-tag/test/storage.test.ts | 5 +- 5 files changed, 145 insertions(+), 138 deletions(-) create mode 100644 packages/experiment-tag/src/storage/consent-aware-storage.ts diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 8ce5947e..962031fc 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -17,7 +17,7 @@ import mutate, { MutationController } from 'dom-mutator'; import { MessageBus } from './message-bus'; import { showPreviewModeModal } from './preview/preview'; -import { ConsentAwareStorage } from './storage/storage'; +import { ConsentAwareStorage } from './storage/consent-aware-storage'; import { PageChangeEvent, SubscriptionManager } from './subscriptions'; import { ConsentOptions, diff --git a/packages/experiment-tag/src/storage/consent-aware-storage.ts b/packages/experiment-tag/src/storage/consent-aware-storage.ts new file mode 100644 index 00000000..a716be1a --- /dev/null +++ b/packages/experiment-tag/src/storage/consent-aware-storage.ts @@ -0,0 +1,140 @@ +import { CampaignParser, CookieStorage, MKTG } from '@amplitude/analytics-core'; +import type { Campaign } from '@amplitude/analytics-core'; + +import { ConsentStatus } from '../types'; + +import { + getStorage, + getStorageItem, + removeStorageItem, + setStorageItem, + StorageType, +} from './storage'; + +/** + * Consent-aware storage manager that handles persistence based on consent status + */ +export class ConsentAwareStorage { + private inMemoryStorage: Map> = new Map(); + private inMemoryMarketingCookies: Map = new Map(); + private consentStatus: ConsentStatus; + + constructor(initialConsentStatus: ConsentStatus) { + this.consentStatus = initialConsentStatus; + } + + /** + * Set the consent status and handle persistence accordingly + */ + public setConsentStatus(consentStatus: ConsentStatus): void { + this.consentStatus = consentStatus; + + if (consentStatus === ConsentStatus.GRANTED) { + for (const [storageType, storageMap] of this.inMemoryStorage.entries()) { + for (const [key, value] of storageMap.entries()) { + try { + const jsonString = JSON.stringify(value); + getStorage(storageType)?.setItem(key, jsonString); + } catch (error) { + console.warn(`Failed to persist data for key ${key}:`, error); + } + } + } + this.inMemoryStorage.clear(); + this.persistMarketingCookies().then(); + } + } + + /** + * Persist marketing cookies from memory to actual cookies + */ + private async persistMarketingCookies(): Promise { + for (const [ + storageKey, + campaign, + ] of this.inMemoryMarketingCookies.entries()) { + try { + const cookieStorage = new CookieStorage({ + sameSite: 'Lax', + }); + await cookieStorage.set(storageKey, campaign); + } catch (error) { + console.warn( + `Failed to persist marketing cookie for key ${storageKey}:`, + error, + ); + } + } + this.inMemoryMarketingCookies.clear(); + } + + /** + * Get a JSON value from storage with consent awareness + */ + public getItem(storageType: StorageType, key: string): T | null { + if (this.consentStatus === ConsentStatus.GRANTED) { + return getStorageItem(storageType, key); + } + + const storageMap = this.inMemoryStorage.get(storageType); + if (storageMap && storageMap.has(key)) { + return storageMap.get(key) as T; + } + + return null; + } + + /** + * Set a JSON value in storage with consent awareness + */ + public setItem(storageType: StorageType, key: string, value: unknown): void { + if (this.consentStatus === ConsentStatus.GRANTED) { + setStorageItem(storageType, key, value); + } else { + if (!this.inMemoryStorage.has(storageType)) { + this.inMemoryStorage.set(storageType, new Map()); + } + this.inMemoryStorage.get(storageType)?.set(key, value); + } + } + + /** + * Remove a value from storage with consent awareness + */ + public removeItem(storageType: StorageType, key: string): void { + const storageMap = this.inMemoryStorage.get(storageType); + if (this.consentStatus === ConsentStatus.GRANTED) { + removeStorageItem(storageType, key); + return; + } + if (storageMap) { + storageMap.delete(key); + if (storageMap.size === 0) { + this.inMemoryStorage.delete(storageType); + } + } + } + + /** + * Set marketing cookie with consent awareness + * Parses current campaign data from URL and referrer, then stores it in the marketing cookie + */ + public async setMarketingCookie(apiKey: string): Promise { + try { + const parser = new CampaignParser(); + const storageKey = `AMP_${MKTG}_ORIGINAL_${apiKey.substring(0, 10)}`; + const campaign = await parser.parse(); + + if (this.consentStatus === ConsentStatus.GRANTED) { + const cookieStorage = new CookieStorage({ + sameSite: 'Lax', + }); + await cookieStorage.set(storageKey, campaign); + } else { + this.inMemoryMarketingCookies.set(storageKey, campaign); + } + } catch (error) { + console.warn('Failed to set marketing cookie:', error); + } + } +} diff --git a/packages/experiment-tag/src/storage/storage.ts b/packages/experiment-tag/src/storage/storage.ts index 5fed6a3f..29f19644 100644 --- a/packages/experiment-tag/src/storage/storage.ts +++ b/packages/experiment-tag/src/storage/storage.ts @@ -1,9 +1,5 @@ -import { CampaignParser, CookieStorage, MKTG } from '@amplitude/analytics-core'; -import type { Campaign } from '@amplitude/analytics-core'; import { getGlobalScope } from '@amplitude/experiment-core'; -import { ConsentStatus } from '../types'; - export type StorageType = 'localStorage' | 'sessionStorage'; /** @@ -63,138 +59,10 @@ export const removeStorageItem = ( } }; -const getStorage = (storageType: StorageType): Storage | null => { +export const getStorage = (storageType: StorageType): Storage | null => { const globalScope = getGlobalScope(); if (!globalScope) { return null; } return globalScope[storageType]; }; - -/** - * Consent-aware storage manager that handles persistence based on consent status - */ -export class ConsentAwareStorage { - private inMemoryStorage: Map> = new Map(); - private inMemoryMarketingCookies: Map = new Map(); - private consentStatus: ConsentStatus = ConsentStatus.PENDING; - - constructor(initialConsentStatus: ConsentStatus) { - this.consentStatus = initialConsentStatus; - } - - /** - * Set the consent status and handle persistence accordingly - */ - public setConsentStatus(consentStatus: ConsentStatus): void { - this.consentStatus = consentStatus; - - if (consentStatus === ConsentStatus.GRANTED) { - for (const [storageType, storageMap] of this.inMemoryStorage.entries()) { - for (const [key, value] of storageMap.entries()) { - try { - const jsonString = JSON.stringify(value); - getStorage(storageType)?.setItem(key, jsonString); - } catch (error) { - console.warn(`Failed to persist data for key ${key}:`, error); - } - } - } - this.inMemoryStorage.clear(); - this.persistMarketingCookies().then(); - } - } - - /** - * Persist marketing cookies from memory to actual cookies - */ - private async persistMarketingCookies(): Promise { - for (const [ - storageKey, - campaign, - ] of this.inMemoryMarketingCookies.entries()) { - try { - const cookieStorage = new CookieStorage({ - sameSite: 'Lax', - }); - await cookieStorage.set(storageKey, campaign); - } catch (error) { - console.warn( - `Failed to persist marketing cookie for key ${storageKey}:`, - error, - ); - } - } - this.inMemoryMarketingCookies.clear(); - } - - /** - * Get a JSON value from storage with consent awareness - */ - public getItem(storageType: StorageType, key: string): T | null { - if (this.consentStatus === ConsentStatus.GRANTED) { - return getStorageItem(storageType, key); - } - - const storageMap = this.inMemoryStorage.get(storageType); - if (storageMap && storageMap.has(key)) { - return storageMap.get(key) as T; - } - - return null; - } - - /** - * Set a JSON value in storage with consent awareness - */ - public setItem(storageType: StorageType, key: string, value: unknown): void { - if (this.consentStatus === ConsentStatus.GRANTED) { - setStorageItem(storageType, key, value); - } else { - if (!this.inMemoryStorage.has(storageType)) { - this.inMemoryStorage.set(storageType, new Map()); - } - this.inMemoryStorage.get(storageType)?.set(key, value); - } - } - - /** - * Remove a value from storage with consent awareness - */ - public removeItem(storageType: StorageType, key: string): void { - const storageMap = this.inMemoryStorage.get(storageType); - if (this.consentStatus === ConsentStatus.GRANTED) { - removeStorageItem(storageType, key); - return; - } - if (storageMap) { - storageMap.delete(key); - if (storageMap.size === 0) { - this.inMemoryStorage.delete(storageType); - } - } - } - - /** - * Set marketing cookie with consent awareness - * Parses current campaign data from URL and referrer, then stores it in the marketing cookie - */ - public async setMarketingCookie(apiKey: string): Promise { - try { - const parser = new CampaignParser(); - const storageKey = `AMP_${MKTG}_ORIGINAL_${apiKey.substring(0, 10)}`; - const campaign = await parser.parse(); - - if (this.consentStatus === ConsentStatus.GRANTED) { - const cookieStorage = new CookieStorage({ - sameSite: 'Lax', - }); - await cookieStorage.set(storageKey, campaign); - } else { - this.inMemoryMarketingCookies.set(storageKey, campaign); - } - } catch (error) { - console.warn('Failed to set marketing cookie:', error); - } - } -} diff --git a/packages/experiment-tag/src/util/messenger.ts b/packages/experiment-tag/src/util/messenger.ts index 1cf0bb19..db9509ea 100644 --- a/packages/experiment-tag/src/util/messenger.ts +++ b/packages/experiment-tag/src/util/messenger.ts @@ -1,6 +1,6 @@ import { getGlobalScope } from '@amplitude/experiment-core'; -import { getStorageItem } from '../storage/storage'; +import { getStorageItem } from '../storage/consent-aware-storage'; interface VisualEditorSession { injectSrc: string; diff --git a/packages/experiment-tag/test/storage.test.ts b/packages/experiment-tag/test/storage.test.ts index 8a4f9aca..0e3eff17 100644 --- a/packages/experiment-tag/test/storage.test.ts +++ b/packages/experiment-tag/test/storage.test.ts @@ -1,8 +1,7 @@ import { CampaignParser, CookieStorage } from '@amplitude/analytics-core'; import * as coreUtil from '@amplitude/experiment-core'; - -import { ConsentAwareStorage } from '../src/storage/storage'; -import { ConsentStatus } from '../src/types'; +import { ConsentAwareStorage } from 'src/storage/consent-aware-storage'; +import { ConsentStatus } from 'src/types'; const spyGetGlobalScope = jest.spyOn(coreUtil, 'getGlobalScope'); From 9c8af0f3eb7445d97c780bb04dc0c213d26888ff Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 2 Oct 2025 13:15:00 -0700 Subject: [PATCH 10/11] fix import --- packages/experiment-tag/src/util/messenger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/experiment-tag/src/util/messenger.ts b/packages/experiment-tag/src/util/messenger.ts index db9509ea..1cf0bb19 100644 --- a/packages/experiment-tag/src/util/messenger.ts +++ b/packages/experiment-tag/src/util/messenger.ts @@ -1,6 +1,6 @@ import { getGlobalScope } from '@amplitude/experiment-core'; -import { getStorageItem } from '../storage/consent-aware-storage'; +import { getStorageItem } from '../storage/storage'; interface VisualEditorSession { injectSrc: string; From 58b395040f94389c2588d81befd851fee0957255 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Fri, 3 Oct 2025 15:01:57 -0700 Subject: [PATCH 11/11] fix init --- packages/experiment-tag/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/experiment-tag/src/index.ts b/packages/experiment-tag/src/index.ts index fc3464ac..f58648d0 100644 --- a/packages/experiment-tag/src/index.ts +++ b/packages/experiment-tag/src/index.ts @@ -14,7 +14,7 @@ export const initialize = ( config: WebExperimentConfig, ): void => { if ( - getGlobalScope()?.globalScope.experimentConfig.consentOptions.status === + getGlobalScope()?.experimentConfig.consentOptions.status === ConsentStatus.REJECTED ) { return;