import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { createBrowserRecorder } from './browser-recorder.js'; import { logger } from '../logger.js'; describe('browser-recorder', () => { let root: string; beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'br-')); }); afterEach(() => { rmSync(root, { recursive: true, force: true }); }); // 1. enable then record then bufferSize → 1 it('bufferSize is 1 after enable + record', () => { const r = createBrowserRecorder(); r.enable('t1', 'my-session'); r.record('t1', { type: 'goto', url: 'https://example.com' }); expect(r.bufferSize('t1')).toBe(1); }); // 2. record without prior enable is a no-op it('record without enable is a no-op', () => { const r = createBrowserRecorder(); expect(() => r.record('t1', { type: 'click', selector: '#btn' })).not.toThrow(); expect(r.bufferSize('t1')).toBe(0); }); // 3. record stamps ts as a parseable ISO string it('record stamps ts as a parseable ISO string', () => { const r = createBrowserRecorder(); r.enable('t1', 'sess'); const before = Date.now(); r.record('t1', { type: 'click', selector: '#btn' }); const after = Date.now(); // Access the stored action via flush output const path = r.flush('t1', root, 'owner1'); const data = JSON.parse(readFileSync(path!, 'utf-8')); const ts = data.actions[0].ts as string; const parsed = new Date(ts).getTime(); expect(parsed).toBeGreaterThanOrEqual(before); expect(parsed).toBeLessThanOrEqual(after + 100); // small margin for slow machines }); // 4. recordTo returns the label after enable, null without enable it('recordTo returns label after enable and null before', () => { const r = createBrowserRecorder(); expect(r.recordTo('t1')).toBeNull(); r.enable('t1', 'label-abc'); expect(r.recordTo('t1')).toBe('label-abc'); }); // 5. flush writes the expected JSON file with the expected shape it('flush writes a valid JSON file with correct shape', () => { const r = createBrowserRecorder(); r.enable('t1', 'session-1'); r.record('t1', { type: 'goto', url: 'https://example.com' }); r.record('t1', { type: 'click', selector: '#btn', originalRef: 'e3' }); const path = r.flush('t1', root, 'owner1'); expect(path).not.toBeNull(); expect(existsSync(path!)).toBe(true); const data = JSON.parse(readFileSync(path!, 'utf-8')); expect(data.recordTo).toBe('session-1'); expect(typeof data.capturedAt).toBe('string'); expect(new Date(data.capturedAt).getTime()).toBeGreaterThan(0); expect(Array.isArray(data.actions)).toBe(true); expect(data.actions).toHaveLength(2); expect(data.actions[0].type).toBe('goto'); expect(data.actions[0].url).toBe('https://example.com'); expect(typeof data.actions[0].ts).toBe('string'); expect(data.actions[1].type).toBe('click'); expect(data.actions[1].originalRef).toBe('e3'); // Verify it's located at the expected path under recordings/ expect(path).toBe(join(root, 'owner1', 'recordings', 'session-1.json')); }); // 6. flush idempotency — second flush returns null it('second flush immediately after returns null', () => { const r = createBrowserRecorder(); r.enable('t1', 'sess'); r.record('t1', { type: 'wait', ms: 500 }); const first = r.flush('t1', root, 'owner1'); expect(first).not.toBeNull(); const second = r.flush('t1', root, 'owner1'); expect(second).toBeNull(); }); // 7. flush returns null when buffer is empty (no file created) it('flush returns null with empty buffer and creates no file', () => { const r = createBrowserRecorder(); r.enable('t1', 'sess'); // no records const path = r.flush('t1', root, 'owner1'); expect(path).toBeNull(); // The recordings dir may or may not exist, but the json file must not const would_be_path = join(root, 'owner1', 'recordings', 'sess.json'); expect(existsSync(would_be_path)).toBe(false); }); // 8. cancel clears the buffer (subsequent flush returns null) it('cancel clears buffer so subsequent flush returns null', () => { const r = createBrowserRecorder(); r.enable('t1', 'sess'); r.record('t1', { type: 'click', selector: '#x' }); r.cancel('t1'); expect(r.bufferSize('t1')).toBe(0); const path = r.flush('t1', root, 'owner1'); expect(path).toBeNull(); }); // 9. cancel idempotency — safe to call multiple times it('cancel is idempotent', () => { const r = createBrowserRecorder(); r.enable('t1', 'sess'); r.record('t1', { type: 'click', selector: '#x' }); expect(() => { r.cancel('t1'); r.cancel('t1'); r.cancel('t1'); }).not.toThrow(); }); // ── recordTo validation tests ───────────────────────────────────────────────── // 9b. enable with traversal name '../escape' is a no-op it('enable with traversal recordTo "../escape" is a no-op', () => { const r = createBrowserRecorder(); r.enable('t1', '../escape'); expect(r.recordTo('t1')).toBeNull(); }); // 9c. enable with spaces in recordTo is rejected it('enable with recordTo containing spaces is a no-op', () => { const r = createBrowserRecorder(); r.enable('t1', 'with spaces'); expect(r.recordTo('t1')).toBeNull(); }); // 9d. enable with name longer than 128 chars is rejected it('enable with recordTo longer than 128 chars is a no-op', () => { const r = createBrowserRecorder(); r.enable('t1', 'a'.repeat(129)); expect(r.recordTo('t1')).toBeNull(); }); // ── Buffer cap tests ────────────────────────────────────────────────────────── // 9e. record 5001 actions; buffer stays at 5000 it('buffer is capped at 5000 actions', () => { const r = createBrowserRecorder(); r.enable('t1', 'my-session'); for (let i = 0; i < 5001; i++) { r.record('t1', { type: 'click', selector: `#btn-${i}` }); } expect(r.bufferSize('t1')).toBe(5000); }); // 10. Two different taskIds have independent buffers it('two different taskIds have independent buffers', () => { const r = createBrowserRecorder(); r.enable('taskA', 'sess-a'); r.enable('taskB', 'sess-b'); r.record('taskA', { type: 'goto', url: 'https://a.com' }); r.record('taskA', { type: 'click', selector: '#a' }); r.record('taskB', { type: 'goto', url: 'https://b.com' }); expect(r.bufferSize('taskA')).toBe(2); expect(r.bufferSize('taskB')).toBe(1); const pathA = r.flush('taskA', root, 'owner1'); const pathB = r.flush('taskB', root, 'owner1'); const dataA = JSON.parse(readFileSync(pathA!, 'utf-8')); const dataB = JSON.parse(readFileSync(pathB!, 'utf-8')); expect(dataA.actions).toHaveLength(2); expect(dataA.actions[0].url).toBe('https://a.com'); expect(dataB.actions).toHaveLength(1); expect(dataB.actions[0].url).toBe('https://b.com'); // Verify no cross-contamination: taskB has no actions from taskA expect(dataB.actions.some((a: { selector?: string }) => a.selector === '#a')).toBe(false); }); // ── Fix 1: Per-action payload cap ────────────────────────────────────────────── it('truncates oversized string fields in recorded actions', () => { const r = createBrowserRecorder(); r.enable('t1', 'rec'); const huge = 'x'.repeat(20_000); r.record('t1', { type: 'fill', selector: huge, value: huge, frameChain: [] }); expect(r.bufferSize('t1')).toBe(1); const path = r.flush('t1', root, 'owner1'); expect(path).not.toBeNull(); const data = JSON.parse(readFileSync(path!, 'utf-8')); const action = data.actions[0]; // Verify selector is truncated and contains the notice expect(action.selector).toBeDefined(); expect(action.selector.length).toBeLessThan(10_000); // Well under original 20k expect(action.selector).toContain('…[truncated from'); // Verify value is also truncated expect(action.value).toBeDefined(); expect(action.value.length).toBeLessThan(10_000); expect(action.value).toContain('…[truncated from'); // Verify undefined fields are preserved as undefined expect(action.url).toBeUndefined(); }); // ── Fix 2: Invalid recordTo clears existing buffer ────────────────────────────── it('enable with invalid recordTo clears existing buffer', () => { const r = createBrowserRecorder(); r.enable('t1', 'rec'); r.record('t1', { type: 'goto', url: 'https://x.com', frameChain: [] }); expect(r.bufferSize('t1')).toBe(1); expect(r.recordTo('t1')).toBe('rec'); // Now enable with an invalid name r.enable('t1', '../escape'); expect(r.recordTo('t1')).toBeNull(); expect(r.bufferSize('t1')).toBe(0); // Verify flush returns null (buffer was cleared) const path = r.flush('t1', root, 'owner1'); expect(path).toBeNull(); }); // ── Fix 3: warnedBufferCap cleanup ──────────────────────────────────────────── it('cancel cleans up the cap-warn tracking across rearm cycles', () => { const r = createBrowserRecorder(); const warnSpy = vi.spyOn(logger, 'warn'); // Fill buffer to cap on first cycle r.enable('t1', 'sess'); for (let i = 0; i < 5001; i++) { r.record('t1', { type: 'click', selector: `#btn-${i}` }); } expect(r.bufferSize('t1')).toBe(5000); // Warning fires once const warnCount1 = warnSpy.mock.calls.filter((call) => call[0]?.includes('reached 5000-action cap') ).length; expect(warnCount1).toBe(1); // Cancel clears both buffer and warn tracking r.cancel('t1'); expect(r.bufferSize('t1')).toBe(0); // Re-enable and fill again r.enable('t1', 'sess'); for (let i = 0; i < 5001; i++) { r.record('t1', { type: 'click', selector: `#btn-${i}` }); } expect(r.bufferSize('t1')).toBe(5000); // Warning fires again (not suppressed because we cleaned up the tracking) const warnCount2 = warnSpy.mock.calls.filter((call) => call[0]?.includes('reached 5000-action cap') ).length; expect(warnCount2).toBe(2); warnSpy.mockRestore(); }); });