// @vitest-environment jsdom import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { AISTHETIX_INSTANCE, applyConsentDecision, MODAL_OVERLAY_CLASS, NO_CAPTURE_CLASS, SUPPORTED_PERSON_PROFILES, isRecording, replayConfigured, resetReplayForTests, revokeReplay, startReplay, stopReplay, } from './replay'; const CONFIG = { key: 'phc_browser', host: 'https://ingest.aisthetix.fyi', enabled: true, environment: 'staging', store: 'store_1', }; interface FakeSdk { init: ReturnType; startSessionRecording: ReturnType; stopSessionRecording: ReturnType; register: ReturnType; opt_out_capturing: ReturnType; opt_in_capturing: ReturnType; } let sdk: FakeSdk; let appended: HTMLScriptElement[]; /** * Stand in for the CDN: capture the injected script tag and, when the test * chooses, resolve it by installing a fake SDK and firing `onload`. */ function interceptScriptInjection(installSdk: boolean) { appended = []; const original = document.head.appendChild.bind(document.head); vi.spyOn(document.head, 'appendChild').mockImplementation(((node: Node) => { if (node instanceof HTMLScriptElement) { appended.push(node); queueMicrotask(() => { if (installSdk) { (window as unknown as { posthog?: FakeSdk }).posthog = sdk; node.onload?.(new Event('load')); } else { node.onerror?.(new Event('error')); } }); return node; } return original(node); }) as typeof document.head.appendChild); } beforeEach(() => { resetReplayForTests(); delete (window as unknown as { posthog?: unknown }).posthog; sdk = { // posthog-js's `init(key, options, name)` returns the NAMED instance. init: vi.fn(() => sdk), startSessionRecording: vi.fn(), stopSessionRecording: vi.fn(), register: vi.fn(), opt_out_capturing: vi.fn(), opt_in_capturing: vi.fn(), }; }); afterEach(() => { vi.restoreAllMocks(); }); describe('whether replay runs at all', () => { it('is off without a key', () => { expect(replayConfigured({ ...CONFIG, key: '' })).toBe(false); }); it('is off when the server switch is off', () => { expect(replayConfigured({ ...CONFIG, enabled: false })).toBe(false); }); it('is off without an ingest host', () => { expect(replayConfigured({ ...CONFIG, host: '' })).toBe(false); }); it('is off when there is no configuration at all', () => { expect(replayConfigured(null)).toBe(false); expect(replayConfigured(undefined)).toBe(false); }); it('is on when key, host and switch are all present', () => { expect(replayConfigured(CONFIG)).toBe(true); }); }); describe('loading the recorder', () => { it('requests nothing before the modal opens and consent is granted', async () => { interceptScriptInjection(true); await startReplay(CONFIG, () => false); expect(appended, 'no script request without both gates').toHaveLength(0); expect(isRecording()).toBe(false); }); it('requests nothing when replay is switched off, whatever the modal does', async () => { interceptScriptInjection(true); await startReplay({ ...CONFIG, enabled: false }, () => true); expect(appended).toHaveLength(0); }); it('loads from our own proxy, never from posthog.com', async () => { interceptScriptInjection(true); await startReplay(CONFIG, () => true); expect(appended).toHaveLength(1); expect(appended[0].src).toBe('https://ingest.aisthetix.fyi/static/array.js'); expect(appended[0].src).not.toContain('posthog.com'); }); it('tells theme optimizers to leave the injected script alone', async () => { interceptScriptInjection(true); await startReplay(CONFIG, () => true); expect(appended[0].getAttribute('data-no-optimize')).toBe('1'); expect(appended[0].getAttribute('data-cfasync')).toBe('false'); }); it('loads once even if the modal is opened repeatedly', async () => { interceptScriptInjection(true); await startReplay(CONFIG, () => true); stopReplay(); await startReplay(CONFIG, () => true); expect(appended).toHaveLength(1); }); it('gives up quietly when the script cannot be loaded', async () => { interceptScriptInjection(false); await startReplay(CONFIG, () => true); expect(isRecording()).toBe(false); }); }); describe('how the recorder is configured', () => { beforeEach(async () => { interceptScriptInjection(true); await startReplay(CONFIG, () => true); }); it('keeps nothing on the shopper device', () => { const options = sdk.init.mock.calls[0][1]; expect(options.persistence).toBe('memory'); }); it('asks for no person profile using a value the SDK actually accepts', () => { // posthog-js accepts `always` and `identified_only`, and nothing else. An // unrecognised literal is not rejected — it silently falls back, so the // config would read as a privacy guarantee while meaning whatever the // default happens to be that release. const options = sdk.init.mock.calls[0][1]; expect(SUPPORTED_PERSON_PROFILES).toContain(options.person_profiles); // The storefront never calls identify(), so this is "no profile, ever" — // stated in the SDK's own vocabulary. expect(options.person_profiles).toBe('identified_only'); }); it('captures nothing by itself', () => { const options = sdk.init.mock.calls[0][1]; expect(options.autocapture).toBe(false); expect(options.capture_pageview).toBe(false); expect(options.capture_pageleave).toBe(false); }); it('never starts recording at init — only when the modal explicitly asks', () => { const options = sdk.init.mock.calls[0][1]; expect(options.disable_session_recording).toBe(true); }); it('masks every input', () => { const recording = sdk.init.mock.calls[0][1].session_recording; expect(recording.maskAllInputs).toBe(true); }); it('blocks every image, canvas and video — the shopper is in all of them', () => { const recording = sdk.init.mock.calls[0][1].session_recording; expect(recording.blockSelector).toContain('img'); expect(recording.blockSelector).toContain('canvas'); expect(recording.blockSelector).toContain('video'); expect(recording.blockSelector).toContain(NO_CAPTURE_CLASS); }); it('blocks everything the merchant put on the page, and keeps the modal itself', () => { // The selector is evaluated against a real DOM rather than read as a // string: what matters is which nodes rrweb will refuse to record, and only // `matches()` answers that. document.body.innerHTML = `
Storefront navigation

Free shipping over 50 EUR

Chat transcript
try-on result
`; const { blockSelector } = sdk.init.mock.calls[0][1].session_recording as { blockSelector: string }; for (const id of ['theme-header', 'marketing', 'newsletter', 'live-chat']) { expect(document.getElementById(id)!.matches(blockSelector), `${id} is the merchant's, not ours`).toBe(true); } // rrweb blocks a node's whole subtree, so a descendant is covered when one // of its ancestors matches — which is what `closest` asks. expect( document.getElementById('email')!.closest(blockSelector), 'and so is whatever a shopper typed into their newsletter field' ).not.toBeNull(); const overlay = document.querySelector(`.${MODAL_OVERLAY_CLASS}`)!; expect(overlay.matches(blockSelector), 'the modal is the one thing we may record').toBe(false); expect(document.getElementById('modal-title')!.matches(blockSelector), 'and its own chrome with it').toBe(false); expect(document.getElementById('result')!.matches(blockSelector), 'but never an image inside it').toBe(true); }); it('records no cross-origin iframe, which is where a merchant chat widget lives', () => { const recording = sdk.init.mock.calls[0][1].session_recording; expect(recording.recordCrossOriginIframes).toBe(false); }); it('sends through our proxy', () => { expect(sdk.init.mock.calls[0][1].api_host).toBe('https://ingest.aisthetix.fyi'); }); it('labels the recording as WooCommerce, which is the platform it is on', () => { // A replay filed under `shopify` is a replay nobody looking at WooCommerce // will ever find, and one that quietly inflates every Shopify count it // lands in. expect(sdk.register).toHaveBeenCalledWith(expect.objectContaining({ platform: 'woocommerce' })); }); it('registers the store and environment, so a replay can be found', () => { expect(sdk.register).toHaveBeenCalledWith( expect.objectContaining({ store: 'store_1', environment: 'staging', surface: 'storefront' }) ); }); }); describe( 'the sanitizer posthog-js is initialised with', () => { beforeEach( async () => { interceptScriptInjection( true ); await startReplay( CONFIG, () => true ); } ); it( 'is installed as before_send, which is the only hook that sees $snapshot', () => { // capture_pageview: false stops the automatic pageview EVENT. It does not // stop posthog-js attaching $current_url and friends to everything else, // replay traffic included — and none of that passes through the brain's // sanitizer. const options = sdk.init.mock.calls[ 0 ][ 1 ]; expect( typeof options.before_send ).toBe( 'function' ); } ); it( 'strips the query and fragment from a replay snapshot going out', () => { const beforeSend = sdk.init.mock.calls[ 0 ][ 1 ].before_send as ( e: unknown ) => unknown; const sent = beforeSend( { event: '$snapshot', properties: { $session_id: 's1', $current_url: 'https://shop.example.com/product/tee?add-to-cart=12&s=blue#f', $referrer: 'https://mail.example.com/i?to=shopper%40example.com', $snapshot_data: [ { type: 4, data: { href: 'https://shop.example.com/product/tee?add-to-cart=12' } }, ], }, } ) as { properties: Record< string, unknown > }; expect( sent.properties.$current_url ).toBe( 'https://shop.example.com/product/tee' ); expect( sent.properties.$referrer ).toBe( 'https://mail.example.com/i' ); const snapshot = sent.properties.$snapshot_data as Array< { data: Record< string, unknown > } >; expect( snapshot[ 0 ].data.href ).toBe( 'https://shop.example.com/product/tee' ); expect( JSON.stringify( sent ) ).not.toContain( 'shopper%40example.com' ); } ); it( 'strips them from an ordinary capture too', () => { const beforeSend = sdk.init.mock.calls[ 0 ][ 1 ].before_send as ( e: unknown ) => unknown; const sent = beforeSend( { event: 'tryon_modal_opened', properties: { $current_url: 'https://shop.example.com/p?q=1' }, $set_once: { $initial_referrer: 'https://ads.example.com/c?click_id=abc' }, } ) as { properties: Record< string, unknown >; $set_once: Record< string, unknown > }; expect( sent.properties.$current_url ).toBe( 'https://shop.example.com/p' ); expect( sent.$set_once.$initial_referrer ).toBe( 'https://ads.example.com/c' ); } ); } ); describe('the recording lifecycle', () => { it('starts when the modal is open and consent holds', async () => { interceptScriptInjection(true); await startReplay(CONFIG, () => true); expect(sdk.startSessionRecording).toHaveBeenCalledTimes(1); expect(isRecording()).toBe(true); }); it('does not start if the modal closed while the SDK was loading', async () => { interceptScriptInjection(true); let open = true; const promise = startReplay(CONFIG, () => open); open = false; // shopper closed the modal mid-load await promise; expect(sdk.startSessionRecording).not.toHaveBeenCalled(); expect(isRecording()).toBe(false); }); it('does not start if consent was withdrawn while the SDK was loading', async () => { interceptScriptInjection(true); let allowed = true; const promise = startReplay(CONFIG, () => allowed); allowed = false; await promise; expect(sdk.startSessionRecording).not.toHaveBeenCalled(); }); it('stops when the modal closes', async () => { interceptScriptInjection(true); await startReplay(CONFIG, () => true); stopReplay(); expect(sdk.stopSessionRecording).toHaveBeenCalledTimes(1); expect(isRecording()).toBe(false); }); it('is safe to stop when nothing is recording', () => { expect(() => stopReplay()).not.toThrow(); expect(sdk.stopSessionRecording).not.toHaveBeenCalled(); }); it('does not start twice for one modal run', async () => { interceptScriptInjection(true); await startReplay(CONFIG, () => true); await startReplay(CONFIG, () => true); expect(sdk.startSessionRecording).toHaveBeenCalledTimes(1); }); it('opts out entirely when consent is withdrawn', async () => { interceptScriptInjection(true); await startReplay(CONFIG, () => true); revokeReplay(); expect(sdk.stopSessionRecording).toHaveBeenCalled(); expect(sdk.opt_out_capturing).toHaveBeenCalled(); expect(isRecording()).toBe(false); }); it('survives an SDK that throws on stop', async () => { interceptScriptInjection(true); await startReplay(CONFIG, () => true); sdk.stopSessionRecording.mockImplementation(() => { throw new Error('recorder already torn down'); }); expect(() => stopReplay()).not.toThrow(); expect(isRecording()).toBe(false); }); }); describe('consent that changes while the modal is open', () => { it('starts recording on a grant that arrives after the modal opened', async () => { // The shopper opened the modal before answering the banner, then accepted. // Nothing had started, and nothing was listening for the moment it could. interceptScriptInjection(true); let granted = false; await startReplay(CONFIG, () => granted); expect(isRecording(), 'nothing may run before the grant').toBe(false); granted = true; await applyConsentDecision(true, CONFIG, () => granted); expect(sdk.startSessionRecording).toHaveBeenCalledTimes(1); expect(isRecording()).toBe(true); }); it('stops and opts out when consent is withdrawn', async () => { interceptScriptInjection(true); await startReplay(CONFIG, () => true); await applyConsentDecision(false, CONFIG, () => false); expect(sdk.stopSessionRecording).toHaveBeenCalled(); expect(sdk.opt_out_capturing).toHaveBeenCalled(); expect(isRecording()).toBe(false); }); it('opts back in before recording again when the same shopper re-grants', async () => { // opt_out_capturing is sticky: without an explicit opt-in the SDK keeps // dropping everything, so a re-grant would look like it worked and record // nothing at all. interceptScriptInjection(true); let granted = true; await startReplay(CONFIG, () => granted); granted = false; await applyConsentDecision(false, CONFIG, () => granted); expect(sdk.opt_out_capturing).toHaveBeenCalledTimes(1); granted = true; await applyConsentDecision(true, CONFIG, () => granted); expect(sdk.opt_in_capturing, 'the SDK must be told to send again').toHaveBeenCalledTimes(1); expect(sdk.opt_in_capturing.mock.invocationCallOrder[0]).toBeLessThan( sdk.startSessionRecording.mock.invocationCallOrder[1] ); expect(isRecording()).toBe(true); }); it('does not start on a grant that arrives after the modal closed', async () => { interceptScriptInjection(true); await startReplay(CONFIG, () => false); await applyConsentDecision(true, CONFIG, () => false); expect(sdk.startSessionRecording).not.toHaveBeenCalled(); expect(isRecording()).toBe(false); }); }); describe( 'a merchant who already runs PostHog on their storefront', () => { /** * The merchant's own instance, configured for the merchant's own project. * * No masking, no block selector, their key, their proxy. Recording through * it would start a full-page recorder on someone else's configuration — our * `before_send`, our memory persistence, our masks and the modal * `blockSelector` would never be installed at all. */ function preloadHostileDefault() { const hostile = { __loaded: true, init: vi.fn(), startSessionRecording: vi.fn(), stopSessionRecording: vi.fn(), register: vi.fn(), capture: vi.fn(), opt_out_capturing: vi.fn(), opt_in_capturing: vi.fn(), }; ( window as unknown as { posthog?: unknown } ).posthog = hostile; return hostile; } it( 'never starts a recording on the merchant\'s instance', async () => { const hostile = preloadHostileDefault(); hostile.init.mockImplementation( () => sdk ); await startReplay( CONFIG, () => true ); expect( hostile.startSessionRecording ).not.toHaveBeenCalled(); expect( hostile.register ).not.toHaveBeenCalled(); expect( sdk.startSessionRecording ).toHaveBeenCalledTimes( 1 ); } ); it( 'asks for a NAMED instance rather than reusing the default one', async () => { const hostile = preloadHostileDefault(); hostile.init.mockImplementation( () => sdk ); await startReplay( CONFIG, () => true ); const [ key, options, name ] = hostile.init.mock.calls[ 0 ]; expect( key ).toBe( 'phc_browser' ); expect( name ).toBe( AISTHETIX_INSTANCE ); expect( options.api_host ).toBe( 'https://ingest.aisthetix.fyi' ); expect( typeof options.before_send ).toBe( 'function' ); expect( options.persistence ).toBe( 'memory' ); expect( options.person_profiles ).toBe( 'identified_only' ); expect( options.session_recording.maskAllInputs ).toBe( true ); expect( options.session_recording.blockSelector ).toContain( MODAL_OVERLAY_CLASS ); } ); it( 'stops and opts out on OUR instance, not the merchant\'s', async () => { const hostile = preloadHostileDefault(); hostile.init.mockImplementation( () => sdk ); await startReplay( CONFIG, () => true ); revokeReplay(); expect( hostile.stopSessionRecording ).not.toHaveBeenCalled(); expect( hostile.opt_out_capturing ).not.toHaveBeenCalled(); expect( sdk.stopSessionRecording ).toHaveBeenCalled(); expect( sdk.opt_out_capturing ).toHaveBeenCalled(); } ); it( 'does not re-fetch the library when the merchant already loaded it', async () => { const hostile = preloadHostileDefault(); hostile.init.mockImplementation( () => sdk ); interceptScriptInjection( true ); await startReplay( CONFIG, () => true ); expect( appended ).toHaveLength( 0 ); } ); it( 'still creates the named instance when we loaded the library ourselves', async () => { interceptScriptInjection( true ); await startReplay( CONFIG, () => true ); expect( sdk.init ).toHaveBeenCalledTimes( 1 ); expect( sdk.init.mock.calls[ 0 ][ 2 ] ).toBe( AISTHETIX_INSTANCE ); } ); } );