diff --git a/packages/experiment-tag/src/behavioral-targeting/session-manager.ts b/packages/experiment-tag/src/behavioral-targeting/session-manager.ts index 42111900..b5c92669 100644 --- a/packages/experiment-tag/src/behavioral-targeting/session-manager.ts +++ b/packages/experiment-tag/src/behavioral-targeting/session-manager.ts @@ -1,5 +1,5 @@ import { isConsentWithheld } from '../consent/consent-gate'; -import { getTopLevelDomainSync, SyncJsonCookie } from '../util/cookie'; +import { getTopLevelDomain, SyncJsonCookie } from '../util/cookie'; /** * Default rolling inactivity window before a session rotates, mirroring @@ -146,7 +146,7 @@ export class SessionManager { } const resolved = typeof location !== 'undefined' && location.hostname - ? getTopLevelDomainSync(location.hostname) + ? getTopLevelDomain(location.hostname) : ''; // While consent is withheld the resolver returns an unprobed guess (a real // probe writes a throwaway cookie). Don't pin the guess: leaving the cache diff --git a/packages/experiment-tag/src/consent/consent-cookie-storage.ts b/packages/experiment-tag/src/consent/consent-cookie-storage.ts index acdc8f04..4aba1790 100644 --- a/packages/experiment-tag/src/consent/consent-cookie-storage.ts +++ b/packages/experiment-tag/src/consent/consent-cookie-storage.ts @@ -1,5 +1,3 @@ -import { CookieStorage } from '@amplitude/analytics-core'; - import { mergeIdentityCookieJson } from '../util/grant-flush-merge'; import { @@ -10,9 +8,8 @@ import { } from './consent-gate'; /** - * The part of analytics-core's `CookieStorage` that experiment-tag actually uses. - * Naming it lets call sites accept the consent wrapper and the raw storage - * interchangeably, and lets tests substitute a plain object. + * The faux asynchronous cookie backend the consent gate wraps. Implemented over + * `document.cookie` in `util/cookie.ts`; tests substitute a plain object. */ export interface AsyncCookieStore { get(key: string): Promise; @@ -27,8 +24,9 @@ const snapshotValue = (value: T): T => /** * Holds cookie writes in memory until consent arrives, then hands them to the * real storage. The counterpart to the gate in `util/storage.ts`, for the cookies - * that go through analytics-core rather than this package's own helpers: - * cross-subdomain identity, redirect impressions, and marketing attribution. + * that go through analytics-core's wire format rather than this package's own + * helpers: cross-subdomain identity, redirect impressions, and marketing + * attribution. * * Reads are gated with the writes, so a visitor who has not decided is not * re-identified from a cookie an earlier consented visit left behind. @@ -139,23 +137,3 @@ export class ConsentAwareCookieStorage implements AsyncCookieStore { }); } } - -type CookieStorageOptions = ConstructorParameters[0]; - -/** - * Builds the cookie storage every experiment-tag call site should use, so a - * new one cannot silently bypass the consent gate. Pass a function when an - * option needs a device probe (the cross-subdomain `domain`); it is evaluated - * on the first access that reaches real storage rather than frozen at - * construction while consent is withheld. - */ -export const createCookieStorage = ( - options?: - | CookieStorageOptions - | (() => Promise | CookieStorageOptions), -): AsyncCookieStore => - new ConsentAwareCookieStorage( - typeof options === 'function' - ? async () => new CookieStorage(await options()) - : new CookieStorage(options), - ); diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 4fb0b209..7ab227ec 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -25,10 +25,7 @@ import type { MutationController } from 'dom-mutator/dist/types'; import { BehavioralTargetingManager } from './behavioral-targeting'; import { getRelayUrl, RelayClient } from './behavioral-targeting/relay-client'; import { clearIfErasedElsewhere } from './consent/clear-data'; -import { - type AsyncCookieStore, - createCookieStorage, -} from './consent/consent-cookie-storage'; +import { type AsyncCookieStore } from './consent/consent-cookie-storage'; import { consentGate, isConsentPending, @@ -66,6 +63,7 @@ import { applyAntiFlickerCss, removeAntiFlickerCss } from './util/anti-flicker'; import { enrichUserWithCampaignData } from './util/campaign'; import { mergeWithWindowConfig } from './util/config'; import { + createCookieStorage, getTopLevelDomain, resolveCrossSubdomainObject, setMarketingCookie, @@ -589,13 +587,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.subscriptionManager.markUrlAsPublished(this.globalScope.location.href); this.messageBus.publish('url_change', { updateActivePages: true }); - // Warm the cross-subdomain cookie-domain cache. Must run after the - // synchronous url_change above, whose subscribers apply anti-flicker - // variants/redirects before this first await. While consent is withheld - // this returns an uncached guess (probing writes a cookie), so consumers - // resolve the domain lazily at write time instead of capturing this result. - await getTopLevelDomain(this.globalScope.location.hostname); - const experimentStorageName = `EXP_${this.apiKey.slice(0, 10)}`; const user = getStorageItem( @@ -621,18 +612,11 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // first_seen rather than a subdomain-local mint. The cookie domain is // resolved when the storage first reaches real cookies (post-grant for a // pending start), so it is never pinned to an unprobed guess. - const crossSubdomainCookieStorage = createCookieStorage( - async () => { - const domain = await getTopLevelDomain( - this.globalScope.location.hostname, - ); - return { - ...(domain && { domain }), - sameSite: 'Lax', - expirationDays: 365, - }; - }, - ); + const crossSubdomainCookieStorage = createCookieStorage({ + domain: getTopLevelDomain(this.globalScope.location.hostname), + sameSite: 'Lax', + expirationDays: 365, + }); const defaultUserProviderStorageKey = `${experimentStorageName}_DEFAULT_USER_PROVIDER`; const defaultUserProviderData = @@ -2055,15 +2039,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient { ) { const storage = createCookieStorage< Record - >(async () => { - const domain = await getTopLevelDomain( - this.globalScope.location.hostname, - ); - return { - ...(domain && { domain }), - sameSite: 'Lax', - expirationDays: 1 / 1440, // 1 minute - }; + >({ + domain: getTopLevelDomain(this.globalScope.location.hostname), + sameSite: 'Lax', + expirationDays: 1 / 1440, // 1 minute }); try { @@ -2150,14 +2129,9 @@ export class DefaultWebExperimentClient implements WebExperimentClient { if (this.config.redirectConfig?.encodeRedirectInCookie) { cookieStorage = createCookieStorage< Record - >(async () => { - const domain = await getTopLevelDomain( - this.globalScope.location.hostname, - ); - return { - ...(domain && { domain }), - sameSite: 'Lax', - }; + >({ + domain: getTopLevelDomain(this.globalScope.location.hostname), + sameSite: 'Lax', }); try { cookieImpressions = (await cookieStorage.get(storageKey)) || {}; @@ -2199,12 +2173,14 @@ export class DefaultWebExperimentClient implements WebExperimentClient { const cleanup = async () => { removeStorageItem('sessionStorage', storageKey); if (cookieStorage) { - await cookieStorage.remove(storageKey).catch((error) => { + try { + await cookieStorage.remove(storageKey); + } catch (error) { console.error( `Failed to remove redirect impressions from cookie ${storageKey}:`, error, ); - }); + } } }; diff --git a/packages/experiment-tag/src/util/campaign.ts b/packages/experiment-tag/src/util/campaign.ts index df4e4991..30dc3fb5 100644 --- a/packages/experiment-tag/src/util/campaign.ts +++ b/packages/experiment-tag/src/util/campaign.ts @@ -7,8 +7,7 @@ import { import { UTMParameters } from '@amplitude/analytics-core/lib/esm/types/campaign'; import { type ExperimentUser } from '@amplitude/experiment-js-client'; -import { createCookieStorage } from '../consent/consent-cookie-storage'; - +import { createCookieStorage } from './cookie'; import { getStorageItem, setStorageItem } from './storage'; /** diff --git a/packages/experiment-tag/src/util/cookie.ts b/packages/experiment-tag/src/util/cookie.ts index dde79f24..9b4d333d 100644 --- a/packages/experiment-tag/src/util/cookie.ts +++ b/packages/experiment-tag/src/util/cookie.ts @@ -1,9 +1,13 @@ -import { CampaignParser, CookieStorage, MKTG } from '@amplitude/analytics-core'; +import { + CampaignParser, + MKTG, + decodeCookieValue, +} from '@amplitude/analytics-core'; import type { Campaign } from '@amplitude/analytics-core'; import { - type AsyncCookieStore, - createCookieStorage, + AsyncCookieStore, + ConsentAwareCookieStorage, } from '../consent/consent-cookie-storage'; import { isConsentPending, @@ -128,7 +132,12 @@ const KNOWN_2LDS = [ 'workers.dev', ]; -let cachedDomain: string | undefined; +/** + * Cross-subdomain cookie domain per hostname, so {@link getTopLevelDomain} + * probes at most once per host. Keyed by hostname so a page (or a test file) + * that touches more than one never crosses them. + */ +const cachedDomains: Record = {}; /** * Synchronously probes whether a cookie can be written to `.` by @@ -191,29 +200,17 @@ function unprobedDomainGuess(hostname: string): string { * the first {@link getCookieDomainLevels} entry that accepts one, as a * leading-dot domain (e.g. `.example.com`), or `''` when none does. */ -export function getTopLevelDomainSync(hostname: string): string { +export function getTopLevelDomain(hostname: string): string { + if (hostname in cachedDomains) return cachedDomains[hostname]; if (isConsentWithheld()) { return unprobedDomainGuess(hostname); } for (const domain of getCookieDomainLevels(hostname)) { if (isDomainWritableSync(domain)) { - return '.' + domain; - } - } - return ''; -} - -export async function getTopLevelDomain(hostname: string): Promise { - if (cachedDomain !== undefined) return cachedDomain; - if (isConsentWithheld()) { - return unprobedDomainGuess(hostname); - } - for (const domain of getCookieDomainLevels(hostname)) { - if (await CookieStorage.isDomainWritable(domain)) { - return (cachedDomain = '.' + domain); + return (cachedDomains[hostname] = '.' + domain); } } - return (cachedDomain = ''); + return (cachedDomains[hostname] = ''); } /** @@ -319,6 +316,91 @@ export function deleteRawCookie(key: string, domain?: string): void { } } +/** + * Synchronous, format-compatible read of a value written by analytics-core's + * `CookieStorage` (base64 of URL-encoded JSON) without async CookieStore API. + * NOTE: CookieStorage filters duplicate cookie names by domain + * but this function using document.cookie does not have that ability + * Returns `undefined` when the cookie is absent or undecodable. + */ +export function readCookieStorageSync(key: string): T | undefined { + try { + const raw = readRawCookie(key); + if (raw === undefined) return undefined; + const decoded = decodeCookieValue(raw); + if (decoded === undefined) return undefined; + return JSON.parse(decoded) as T; + } catch { + return undefined; + } +} + +/** + * Synchronous write in analytics-core's base64 format of URL-encoded JSON + * Copy of its `CookieStorage.prototype.setSync` logic since it's not exported + */ +export function writeCookieStorageSync( + key: string, + value: T, + options: { + domain?: string; + sameSite?: string; + expirationDays?: number; + secure?: boolean; + } = {}, +): void { + if (typeof document === 'undefined') return; + try { + let cookie = `${key}=${btoa(encodeURIComponent(JSON.stringify(value)))}`; + if (options.expirationDays) { + const expires = new Date(); + expires.setTime( + expires.getTime() + options.expirationDays * 24 * 60 * 60 * 1000, + ); + cookie += `; expires=${expires.toUTCString()}`; + } + cookie += '; path=/'; + if (options.domain) cookie += `; domain=${options.domain}`; + if (options.secure) cookie += '; Secure'; + if (options.sameSite) cookie += `; SameSite=${options.sameSite}`; + document.cookie = cookie; + } catch { + /* blocked cookie I/O degrades silently */ + } +} + +/** Cookie-backed sync store, in analytics-core's wire format. */ +type CookieStorageOptions = { + domain?: string; + sameSite?: string; + expirationDays?: number; + secure?: boolean; +}; + +const documentCookieStore = ( + options: CookieStorageOptions = {}, +): AsyncCookieStore => ({ + get: async (key) => readCookieStorageSync(key), + set: async (key, value) => + writeCookieStorageSync(key, value, { + sameSite: 'Lax', + secure: location.protocol === 'https:', + ...options, + }), + remove: async (key) => deleteRawCookie(key, options.domain), +}); + +/** + * The single consent-gated cookie store every experiment-tag call site should + * use, so a new one cannot silently bypass the consent gate. Reads and writes + * are synchronous (`document.cookie`); the gate buffers pending writes and + * flushes them on grant. + */ +export const createCookieStorage = ( + options: CookieStorageOptions = {}, +): AsyncCookieStore => + new ConsentAwareCookieStorage(documentCookieStore(options)); + /** * Synchronous two-tier (cookie → in-memory) JSON store. The cookie is the * cross-tab / cross-subdomain source of truth; if writes are blocked (detected @@ -412,12 +494,9 @@ export class SyncJsonCookie { export async function setMarketingCookie(apiKey: string, hostname: string) { // Domain resolved lazily so a pending-time guess is not baked in; see // createCookieStorage. - const storage = createCookieStorage(async () => { - const domain = await getTopLevelDomain(hostname); - return { - sameSite: 'Lax', - ...(domain && { domain }), - }; + const storage = createCookieStorage({ + domain: getTopLevelDomain(hostname), + sameSite: 'Lax', }); const parser = new CampaignParser(); diff --git a/packages/experiment-tag/test/behavioral-targeting/session-manager.test.ts b/packages/experiment-tag/test/behavioral-targeting/session-manager.test.ts index dd7042f7..665e1f29 100644 --- a/packages/experiment-tag/test/behavioral-targeting/session-manager.test.ts +++ b/packages/experiment-tag/test/behavioral-targeting/session-manager.test.ts @@ -262,7 +262,7 @@ describe('SessionManager', () => { }); test('does not pin the unprobed guess: first post-grant write probes for real', () => { - const spy = jest.spyOn(cookieUtil, 'getTopLevelDomainSync'); + const spy = jest.spyOn(cookieUtil, 'getTopLevelDomain'); activateConsent('denied'); const manager = new SessionManager(testApiKey); // Denial cleanup falls through to a cookie delete, which resolves the diff --git a/packages/experiment-tag/test/consent/consent-pending-run.test.ts b/packages/experiment-tag/test/consent/consent-pending-run.test.ts index 497ae813..4d538e19 100644 --- a/packages/experiment-tag/test/consent/consent-pending-run.test.ts +++ b/packages/experiment-tag/test/consent/consent-pending-run.test.ts @@ -16,12 +16,25 @@ import { activateConsent } from './consent-test-util'; import { RelayClient } from 'src/behavioral-targeting/relay-client'; import { consentGate } from 'src/consent/consent-gate'; import { DefaultWebExperimentClient } from 'src/experiment'; +import { + deleteRawCookie, + readCookieStorageSync, + writeCookieStorageSync, +} from 'src/util/cookie'; // In-memory cookie store backing the mocked analytics-core CookieStorage. const cookieStore: Record = {}; const clearCookieStore = () => Object.keys(cookieStore).forEach((key) => delete cookieStore[key]); +const clearDocumentCookies = () => { + for (const cookie of document.cookie ? document.cookie.split('; ') : []) { + const eq = cookie.indexOf('='); + const key = eq === -1 ? cookie : cookie.slice(0, eq); + if (key) deleteRawCookie(key); + } +}; + jest.mock('@amplitude/analytics-core', () => { const actual = jest.requireActual('@amplitude/analytics-core'); const MockCookieStorage = jest.fn().mockImplementation(() => ({ @@ -141,6 +154,7 @@ describe('pending-run wiring', () => { jest.clearAllMocks(); consentGate.reset(); clearCookieStore(); + clearDocumentCookies(); mockRelayState.available = false; jest.spyOn(experimentCore, 'isLocalStorageAvailable').mockReturnValue(true); mockGlobal = createMockGlobal(); @@ -261,10 +275,14 @@ describe('pending-run wiring', () => { web_exp_id_v2: 'durable-v2', }), ); - cookieStore[`${expKey}_identity`] = JSON.stringify({ - web_exp_id_v2: 'durable-v2', - first_seen: '100', - }); + writeCookieStorageSync( + `${expKey}_identity`, + JSON.stringify({ + web_exp_id_v2: 'durable-v2', + first_seen: '100', + }), + { domain: '.test.com' }, + ); await newBehavioralClient().start(); await flushAsync(); @@ -328,7 +346,10 @@ describe('pending-run wiring', () => { ); // The cookie was rewritten with the durable values, so later loads // resolve the same identity instead of the pending-time mint. - expect(JSON.parse(cookieStore[`${expKey}_identity`])).toEqual({ + const identityCookie = readCookieStorageSync( + `${expKey}_identity`, + ); + expect(JSON.parse(identityCookie!)).toEqual({ web_exp_id_v2: 'durable-v2', first_seen: '1000', }); diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 29e57496..110a73b8 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -29,7 +29,8 @@ const flushAsyncWork = async () => { } }; -// Mock CookieStorage to use an in-memory store for testing +// In-memory stand-in for createCookieStorage (document.cookie is not what +// these tests inspect). const cookieStore: Record = {}; const getCookieStore = () => cookieStore; @@ -37,37 +38,21 @@ const clearCookieStore = () => { Object.keys(cookieStore).forEach((key) => delete cookieStore[key]); }; -jest.mock('@amplitude/analytics-core', () => { - const actual = jest.requireActual('@amplitude/analytics-core'); - - const MockCookieStorage = jest.fn().mockImplementation(() => ({ - get: jest.fn((key: string) => Promise.resolve(cookieStore[key])), - set: jest.fn((key: string, value: any) => { - cookieStore[key] = value; - return Promise.resolve(); - }), - remove: jest.fn((key: string) => { - delete cookieStore[key]; - return Promise.resolve(); - }), - getRaw: jest.fn((key: string) => - Promise.resolve(JSON.stringify(cookieStore[key])), - ), - isEnabled: jest.fn(() => Promise.resolve(true)), - reset: jest.fn(() => { - Object.keys(cookieStore).forEach((key) => delete cookieStore[key]); - return Promise.resolve(); - }), - })); - // isDomainWritable is a static method; return false so getTopLevelDomain - // resolves to '' in all tests (consistent cache, no jsdom cookie probing) - (MockCookieStorage as any).isDomainWritable = jest - .fn() - .mockResolvedValue(false); - +jest.mock('src/util/cookie', () => { + const actual = jest.requireActual('src/util/cookie'); return { ...actual, - CookieStorage: MockCookieStorage, + createCookieStorage: jest.fn(() => ({ + get: jest.fn((key: string) => Promise.resolve(cookieStore[key])), + set: jest.fn((key: string, value: any) => { + cookieStore[key] = value; + return Promise.resolve(); + }), + remove: jest.fn((key: string) => { + delete cookieStore[key]; + return Promise.resolve(); + }), + })), }; }); diff --git a/packages/experiment-tag/test/util/campaign.test.ts b/packages/experiment-tag/test/util/campaign.test.ts index c1d3f49b..5078e66b 100644 --- a/packages/experiment-tag/test/util/campaign.test.ts +++ b/packages/experiment-tag/test/util/campaign.test.ts @@ -1,7 +1,6 @@ import { type Campaign, CampaignParser, - CookieStorage, getStorageKey, } from '@amplitude/analytics-core'; import { type ExperimentUser } from '@amplitude/experiment-js-client'; @@ -10,16 +9,20 @@ import { enrichUserWithCampaignData, persistUrlParams, } from '../../src/util/campaign'; +import { createCookieStorage } from '../../src/util/cookie'; import * as storageUtils from '../../src/util/storage'; jest.mock('@amplitude/analytics-core', () => ({ Campaign: jest.fn(), CampaignParser: jest.fn(), - CookieStorage: jest.fn(), getStorageKey: jest.fn(), MKTG: 'MKTG', })); +jest.mock('../../src/util/cookie', () => ({ + createCookieStorage: jest.fn(), +})); + jest.mock('../../src/util/storage', () => ({ getStorageItem: jest.fn(), setStorageItem: jest.fn(), @@ -27,7 +30,10 @@ jest.mock('../../src/util/storage', () => ({ describe('campaign utilities', () => { let mockCampaignParser: jest.Mocked; - let mockCookieStorage: jest.Mocked>; + let mockCookieStorage: { + get: jest.Mock; + set: jest.Mock; + }; let mockGetStorageItem: jest.MockedFunction< typeof storageUtils.getStorageItem >; @@ -46,7 +52,7 @@ describe('campaign utilities', () => { mockCookieStorage = { get: jest.fn(), set: jest.fn(), - } as any; + }; mockGetStorageItem = storageUtils.getStorageItem as jest.MockedFunction< typeof storageUtils.getStorageItem @@ -59,9 +65,7 @@ describe('campaign utilities', () => { >; (CampaignParser as jest.Mock).mockImplementation(() => mockCampaignParser); - (CookieStorage as unknown as jest.Mock).mockImplementation( - () => mockCookieStorage, - ); + (createCookieStorage as jest.Mock).mockReturnValue(mockCookieStorage); }); describe('enrichUserWithCampaignData', () => { @@ -323,7 +327,7 @@ describe('campaign utilities', () => { describe('fetchCampaignData (internal function behavior)', () => { const apiKey = 'test-api-key'; - it('should call CampaignParser and CookieStorage correctly', async () => { + it('should call CampaignParser and createCookieStorage correctly', async () => { const expectedCampaign: Partial = { utm_source: 'test' }; const expectedPreviousCampaign: Partial = { utm_medium: 'previous', @@ -340,9 +344,7 @@ describe('campaign utilities', () => { expect(CampaignParser).toHaveBeenCalledWith(); expect(mockCampaignParser.parse).toHaveBeenCalledWith(); - // Constructed through createCookieStorage, which forwards its (absent) - // options argument. - expect(CookieStorage).toHaveBeenCalledWith(undefined); + expect(createCookieStorage).toHaveBeenCalledWith(); expect(getStorageKey).toHaveBeenCalledWith(apiKey, 'MKTG'); expect(mockCookieStorage.get).toHaveBeenCalledWith('test-storage-key'); }); diff --git a/packages/experiment-tag/test/util/cookie.test.ts b/packages/experiment-tag/test/util/cookie.test.ts index 1f291665..908d4859 100644 --- a/packages/experiment-tag/test/util/cookie.test.ts +++ b/packages/experiment-tag/test/util/cookie.test.ts @@ -5,10 +5,11 @@ import { deleteRawCookie, getCookieDomainLevels, getTopLevelDomain, - getTopLevelDomainSync, + readCookieStorageSync, readRawCookie, resolveCrossSubdomainObject, SyncJsonCookie, + writeCookieStorageSync, writeRawCookie, } from '../../src/util/cookie'; import { activateConsent } from '../consent/consent-test-util'; @@ -139,30 +140,29 @@ describe('top-level domain resolution under consent', () => { afterEach(() => { consentGate.reset(); jest.restoreAllMocks(); + clearAllCookies(); }); - it('returns the unprobed guess without probing while consent is pending', async () => { + it('returns the unprobed guess without probing while consent is pending', () => { activateConsent('pending'); - const probe = jest.spyOn(CookieStorage, 'isDomainWritable'); - expect(await getTopLevelDomain('app.example.com')).toBe('.example.com'); - expect(probe).not.toHaveBeenCalled(); + const setCookie = jest.spyOn(document, 'cookie', 'set'); + expect(getTopLevelDomain('pending.app.example.com')).toBe('.example.com'); + expect(setCookie).not.toHaveBeenCalled(); }); - it('sync variant skips the probe cookie while consent is withheld', () => { + it('skips the probe cookie while consent is withheld', () => { activateConsent('denied'); // In this jsdom page a `.example.com` probe cookie can never be written, // so the ungated path would walk every level and return ''. Getting the // guess back proves the probe never ran. - expect(getTopLevelDomainSync('app.example.com')).toBe('.example.com'); + expect(getTopLevelDomain('denied.app.example.com')).toBe('.example.com'); }); - it('probes for real once consent is granted', async () => { + it('probes for real once consent is granted', () => { activateConsent('granted'); - const probe = jest - .spyOn(CookieStorage, 'isDomainWritable') - .mockResolvedValue(true); - expect(await getTopLevelDomain('app.example.com')).toBe('.example.com'); - expect(probe).toHaveBeenCalled(); + const setCookie = jest.spyOn(document, 'cookie', 'set'); + getTopLevelDomain('writable.app.example.com'); + expect(setCookie).toHaveBeenCalled(); }); }); @@ -290,3 +290,20 @@ describe('SyncJsonCookie', () => { } }); }); + +describe('cookie storage sync helpers', () => { + afterEach(clearAllCookies); + + // stay compatible with analytics-core's CookieStorage (base64 wire format). + it('reads a value written by analytics-core CookieStorage', async () => { + await new CookieStorage().set('fmt', JSON.stringify({ x: 1 })); + expect(readCookieStorageSync('fmt')).toBe(JSON.stringify({ x: 1 })); + }); + + it('writes a value readable by analytics-core CookieStorage', async () => { + writeCookieStorageSync('fmt2', JSON.stringify({ y: 2 })); + expect(await new CookieStorage().get('fmt2')).toBe( + JSON.stringify({ y: 2 }), + ); + }); +});