271 lines
10 KiB
TypeScript
271 lines
10 KiB
TypeScript
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();
|
|
});
|
|
});
|