maestro/src/engine/browser-recorder.test.ts
2026-06-03 05:08:00 +00:00

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();
});
});