import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IncrementalSource, RecordingEventType, RecordingPluginName } from '../events'; import type { CreateUploaderOptions } from './types'; vi.mock('./worker/worker-script', () => ({ workerScript: 'console.log("worker")', })); const DEFAULTS: CreateUploaderOptions = { apiUrl: 'https://api.example', clientSecret: 'secret', }; function fakeSessionStorage(): Storage { const store: Record = {}; return { getItem: (key: string) => store[key] ?? null, setItem: (key: string, value: string) => { store[key] = value; }, removeItem: (key: string) => { delete store[key]; }, clear: () => Object.keys(store).forEach(k => delete store[k]), get length() { return Object.keys(store).length; }, key: (i: number) => Object.keys(store)[i] ?? null, }; } class StubWorker { onerror: ((e: Event) => void) | null = null; onmessage: ((e: MessageEvent) => void) | null = null; postMessage() {} } function installWorkerStubs(opts?: { workerThrows?: boolean; sharedWorkerThrows?: boolean }) { (globalThis as Record).Worker = opts?.workerThrows ? class { constructor() { throw new DOMException('Blocked', 'SecurityError'); } } : StubWorker; if (opts?.sharedWorkerThrows) { (globalThis as Record).SharedWorker = class { constructor() { throw new DOMException('Blocked', 'SecurityError'); } }; } else { (globalThis as Record).SharedWorker = class { onerror: ((e: Event) => void) | null = null; port = { start() {}, onmessage: null as ((e: MessageEvent) => void) | null, postMessage() {}, }; }; } } function installBrowserGlobals() { vi.stubGlobal('window', { addEventListener() {}, devicePixelRatio: 1, innerHeight: 800, innerWidth: 1200, location: { origin: 'https://example.com', pathname: '/' }, screen: { height: 800, width: 1200 }, }); vi.stubGlobal('document', { addEventListener() {}, referrer: '', visibilityState: 'visible', }); vi.stubGlobal('navigator', { language: 'en', userAgent: 'test', }); } describe('createUploader', () => { const blobUrls: string[] = []; beforeEach(() => { globalThis.sessionStorage = fakeSessionStorage(); if (!globalThis.crypto) { (globalThis as Record).crypto = {}; } if (!globalThis.crypto.randomUUID) { globalThis.crypto.randomUUID = () => '00000000-0000-0000-0000-000000000000' as `${string}-${string}-${string}-${string}-${string}`; } if (!globalThis.crypto.subtle?.digest) { (globalThis.crypto as Record).subtle = { digest: async () => new ArrayBuffer(32), }; } vi.spyOn(URL, 'createObjectURL').mockImplementation(() => { const url = `blob:http://localhost/${blobUrls.length}`; blobUrls.push(url); return url; }); }); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); blobUrls.length = 0; delete (globalThis as Record).SharedWorker; }); async function loadCreateUploader() { vi.resetModules(); vi.doMock('./worker-hash', () => ({ WORKER_HASH: 'expected-hash' })); // .js extension is required by Node16 module resolution for dynamic imports // (static `import` lines work without it because the package is CJS — see package.json). // eslint-disable-next-line es/no-dynamic-import const mod = await import('./create-uploader.js'); return mod.createUploader; } describe('blob: fallback', () => { it('uses data: URL when Worker constructor succeeds', async () => { installWorkerStubs(); const createUploader = await loadCreateUploader(); const logs: string[] = []; await expect( createUploader({ ...DEFAULTS, workerMode: 'dedicated', debugLogger: m => logs.push(m), _welcomeTimeoutMs: 0, }), ).rejects.toThrow(/welcome timeout/); expect(blobUrls).toHaveLength(0); expect(logs.some(l => l.includes('via data'))).toBe(true); }); it('falls back to blob: when data: Worker throws', async () => { let constructedUrl: string | undefined; (globalThis as Record).Worker = class { onerror: ((e: Event) => void) | null = null; onmessage: ((e: MessageEvent) => void) | null = null; postMessage() {} constructor(url: string) { if (url.startsWith('data:')) { throw new DOMException('Blocked', 'SecurityError'); } constructedUrl = url; } }; delete (globalThis as Record).SharedWorker; const createUploader = await loadCreateUploader(); const logs: string[] = []; await expect( createUploader({ ...DEFAULTS, workerMode: 'dedicated', debugLogger: m => logs.push(m), _welcomeTimeoutMs: 0, }), ).rejects.toThrow(/welcome timeout/); expect(blobUrls).toHaveLength(1); expect(constructedUrl).toBe(blobUrls[0]); expect(logs.some(l => l.includes('falling back to blob:'))).toBe(true); expect(logs.some(l => l.includes('via blob'))).toBe(true); }); it('falls back to blob: dedicated worker when SharedWorker data: throws', async () => { (globalThis as Record).SharedWorker = class { constructor() { throw new DOMException('Blocked', 'SecurityError'); } }; let constructedUrl: string | undefined; (globalThis as Record).Worker = class { onerror: ((e: Event) => void) | null = null; onmessage: ((e: MessageEvent) => void) | null = null; postMessage() {} constructor(url: string) { constructedUrl = url; } }; const createUploader = await loadCreateUploader(); const logs: string[] = []; await expect( createUploader({ ...DEFAULTS, workerMode: 'shared', debugLogger: m => logs.push(m), _welcomeTimeoutMs: 0, }), ).rejects.toThrow(/welcome timeout/); expect(blobUrls).toHaveLength(1); expect(constructedUrl).toBe(blobUrls[0]); expect(logs.some(l => l.includes('falling back to blob:'))).toBe(true); }); it('falls back to blob: when SharedWorker onerror fires asynchronously (Chrome CSP)', async () => { let constructedUrl: string | undefined; (globalThis as Record).SharedWorker = class { onerror: ((e: Event) => void) | null = null; port = { start() {}, onmessage: null as ((e: MessageEvent) => void) | null, postMessage() {}, }; constructor() { setTimeout(() => this.onerror?.(new Event('error')), 0); } }; (globalThis as Record).Worker = class { onerror: ((e: Event) => void) | null = null; onmessage: ((e: MessageEvent) => void) | null = null; postMessage() {} constructor(url: string) { constructedUrl = url; } }; const createUploader = await loadCreateUploader(); const logs: string[] = []; // Await the full async chain: openWorkerPort detects the async onerror, // falls back to blob, then createUploader eventually hits the welcome timeout. const p = createUploader({ ...DEFAULTS, workerMode: 'shared', debugLogger: m => logs.push(m), _welcomeTimeoutMs: 50, }); await expect(p).rejects.toThrow(/welcome timeout/); expect(blobUrls).toHaveLength(1); expect(constructedUrl).toBe(blobUrls[0]); expect(logs.some(l => l.includes('falling back to blob:'))).toBe(true); }); it('throws with CSP hint when both data: and blob: are blocked', async () => { (globalThis as Record).Worker = class { constructor() { throw new DOMException('Blocked', 'SecurityError'); } }; delete (globalThis as Record).SharedWorker; const createUploader = await loadCreateUploader(); await expect(createUploader({ ...DEFAULTS, workerMode: 'dedicated' })).rejects.toThrow(/worker-src/); }); it('logs CSP hint via debugLogger when both data: and blob: are blocked', async () => { (globalThis as Record).Worker = class { constructor() { throw new DOMException('Blocked', 'SecurityError'); } }; delete (globalThis as Record).SharedWorker; const createUploader = await loadCreateUploader(); const logs: string[] = []; await expect( createUploader({ ...DEFAULTS, workerMode: 'dedicated', debugLogger: m => logs.push(m) }), ).rejects.toThrow(); expect(logs.some(l => l.includes('worker-load-failed') && l.includes('worker-src'))).toBe(true); }); it('does not fall back when a custom workerUrl is provided', async () => { (globalThis as Record).Worker = class { constructor() { throw new Error('custom URL not found'); } }; delete (globalThis as Record).SharedWorker; const createUploader = await loadCreateUploader(); await expect( createUploader({ ...DEFAULTS, workerMode: 'dedicated', workerUrl: 'https://cdn.example/worker.js' }), ).rejects.toThrow(); expect(blobUrls).toHaveLength(0); }); }); describe('worker load failure (async error)', () => { it('throws with CSP hint when dedicated Worker fires onerror', async () => { (globalThis as Record).Worker = class { onerror: ((e: Event) => void) | null = null; onmessage: ((e: MessageEvent) => void) | null = null; postMessage() {} constructor() { setTimeout(() => this.onerror?.(new Event('error')), 5); } }; delete (globalThis as Record).SharedWorker; const createUploader = await loadCreateUploader(); await expect(createUploader({ ...DEFAULTS, workerMode: 'dedicated' })).rejects.toThrow(/worker-src/); }); it('throws with CSP hint when SharedWorker fires onerror', async () => { (globalThis as Record).SharedWorker = class { onerror: ((e: Event) => void) | null = null; port = { start() {}, onmessage: null as ((e: MessageEvent) => void) | null, postMessage() {}, }; constructor() { setTimeout(() => this.onerror?.(new Event('error')), 5); } }; const createUploader = await loadCreateUploader(); await expect(createUploader({ ...DEFAULTS, workerMode: 'shared' })).rejects.toThrow(/worker-src/); }); }); describe('worker hash mismatch', () => { beforeEach(() => { (globalThis as Record).window = { addEventListener: vi.fn() }; (globalThis as Record).document = { addEventListener: vi.fn() }; }); afterEach(() => { delete (globalThis as Record).window; delete (globalThis as Record).document; }); function workerThatReplies(welcomeOverrides: Record = {}) { return class { onerror: ((e: Event) => void) | null = null; onmessage: ((e: MessageEvent) => void) | null = null; postMessage(m: unknown) { const msg = m as { type: string }; if (msg.type === 'hello') { setTimeout( () => this.onmessage?.({ data: { type: 'welcome', result: { sessionId: 'sess-1', sessionToken: 'tok-1' }, workerHash: 'stale-hash', ...welcomeOverrides, }, } as MessageEvent), 0, ); } } }; } async function loadCreateUploaderWithMockedContext() { vi.doMock('./client-context', () => ({ collectUserAgentContext: () => null })); return loadCreateUploader(); } it('logs a warning when workerUrl is set and hashes mismatch', async () => { (globalThis as Record).Worker = workerThatReplies(); delete (globalThis as Record).SharedWorker; const createUploader = await loadCreateUploaderWithMockedContext(); const logs: string[] = []; await createUploader({ ...DEFAULTS, workerMode: 'dedicated', workerUrl: '/confidence-worker.js', debugLogger: m => logs.push(m), }); expect(logs.some(l => l.includes('WORKER MISMATCH'))).toBe(true); }); it('does not warn when using data: URL (inlined worker)', async () => { (globalThis as Record).Worker = workerThatReplies(); delete (globalThis as Record).SharedWorker; const createUploader = await loadCreateUploaderWithMockedContext(); const logs: string[] = []; await createUploader({ ...DEFAULTS, workerMode: 'dedicated', debugLogger: m => logs.push(m), }); expect(logs.some(l => l.includes('WORKER MISMATCH'))).toBe(false); }); it('does not warn when hashes match', async () => { (globalThis as Record).Worker = workerThatReplies({ workerHash: 'expected-hash' }); delete (globalThis as Record).SharedWorker; const createUploader = await loadCreateUploaderWithMockedContext(); const logs: string[] = []; await createUploader({ ...DEFAULTS, workerMode: 'dedicated', workerUrl: '/confidence-worker.js', debugLogger: m => logs.push(m), }); expect(logs.some(l => l.includes('WORKER MISMATCH'))).toBe(false); }); it('warns when workerHash is absent (old worker)', async () => { (globalThis as Record).Worker = workerThatReplies({ workerHash: undefined }); delete (globalThis as Record).SharedWorker; const createUploader = await loadCreateUploaderWithMockedContext(); const logs: string[] = []; await createUploader({ ...DEFAULTS, workerMode: 'dedicated', workerUrl: '/confidence-worker.js', debugLogger: m => logs.push(m), }); expect(logs.some(l => l.includes('WORKER MISMATCH'))).toBe(true); }); }); describe('welcome timeout', () => { it('rejects when the worker never sends a welcome', async () => { (globalThis as Record).Worker = class { onerror: ((e: Event) => void) | null = null; onmessage: ((e: MessageEvent) => void) | null = null; postMessage() {} }; delete (globalThis as Record).SharedWorker; const createUploader = await loadCreateUploader(); await expect(createUploader({ ...DEFAULTS, workerMode: 'dedicated', _welcomeTimeoutMs: 50 })).rejects.toThrow( /welcome timeout/, ); }); }); it('marks active and passive frames for the backend inactivity timeout', async () => { const messages: unknown[] = []; installBrowserGlobals(); vi.stubGlobal( 'Worker', class { onerror: ((event: Event) => void) | null = null; onmessage: ((event: MessageEvent) => void) | null = null; postMessage(message: unknown) { messages.push(message); if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'hello') { queueMicrotask(() => this.onmessage?.( new MessageEvent('message', { data: { type: 'welcome', result: { sessionId: 'sessions/test', sessionToken: 'token' }, }, }), ), ); } } }, ); vi.stubGlobal('SharedWorker', undefined); const createUploader = await loadCreateUploader(); const uploader = await createUploader({ ...DEFAULTS, workerMode: 'dedicated' }); uploader?.({ type: RecordingEventType.Plugin, data: { plugin: RecordingPluginName.NetworkRequest }, }); uploader?.({ type: RecordingEventType.IncrementalSnapshot, data: { source: IncrementalSource.MouseInteraction }, }); expect(messages).toEqual( expect.arrayContaining([ expect.objectContaining({ type: 'frame', frame: expect.objectContaining({ userActivity: false }), }), expect.objectContaining({ type: 'frame', frame: expect.objectContaining({ userActivity: true }), }), ]), ); }); it('adopts a restarted worker session and resets the frame counter', async () => { const messages: unknown[] = []; const onSessionRestart = vi.fn(); const workers: StubWorker[] = []; installBrowserGlobals(); vi.stubGlobal( 'Worker', class extends StubWorker { constructor() { super(); workers.push(this); } override postMessage(message: unknown) { messages.push(message); if (typeof message === 'object' && message !== null && 'type' in message && message.type === 'hello') { queueMicrotask(() => this.onmessage?.( new MessageEvent('message', { data: { type: 'welcome', result: { sessionId: 'sessions/first', sessionToken: 'first-token' }, }, }), ), ); } } }, ); vi.stubGlobal('SharedWorker', undefined); const createUploader = await loadCreateUploader(); const uploader = await createUploader({ ...DEFAULTS, workerMode: 'dedicated', onSessionRestart, }); uploader?.({ type: RecordingEventType.Meta, data: {} }); workers[0]?.onmessage?.( new MessageEvent('message', { data: { type: 'session-restarted', result: { sessionId: 'sessions/second', sessionToken: 'second-token' }, adoptedFromSessionId: 'sessions/first', }, }), ); uploader?.({ type: RecordingEventType.Plugin, data: { plugin: RecordingPluginName.NetworkRequest }, }); expect(onSessionRestart).toHaveBeenCalledOnce(); expect(messages.at(-1)).toEqual({ type: 'frame', frame: expect.objectContaining({ eventCounter: 0, adoptedFromSessionId: 'sessions/first', data: { type: RecordingEventType.Plugin, data: { plugin: RecordingPluginName.NetworkRequest }, }, }), }); }); });