/** * The recording sidecar's pure parts: the display above the pool's, a private PulseAudio with one * null sink, Chromium sized to the recording in kiosk mode, ffmpeg grabbing exactly that display and * that sink with capped threads — and a probe's verdict on sound. No processes are spawned here. */ import { describe, it, expect } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { sizeOf, xvfbArgs, pulseArgs, chromeArgs, ffmpegArgs, allocDisplay, capabilities, recordingsRoot, profilePrefs, writePrefs, DISPLAY_LOW, SINK } from '../src/recorder/sidecar.js'; describe('recording sidecar', () => { it('the throw-away profile starts with nothing that draws over the video: no translate bubble, no permission prompts', () => { const p = profilePrefs(); expect(p.translate.enabled).toBe(false); expect(p.profile.default_content_setting_values.notifications).toBe(2); const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prof-')); expect(writePrefs(dir)).toBe(true); expect(JSON.parse(fs.readFileSync(path.join(dir, 'Default', 'Preferences'), 'utf8')).translate.enabled).toBe(false); }); it('says what this machine can do, and where recordings go on a laptop (beside the profiles folder)', () => { const c = capabilities(); expect(c.tools).toHaveProperty('Xvfb'); expect(c.tools).toHaveProperty('pulseaudio'); expect(c.tools).toHaveProperty('ffmpeg'); expect(typeof c.ok).toBe('boolean'); if (!c.ok) expect(c.hint).toMatch(/docker|apt install/); const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gb-')); const saved = { R: process.env.RECORDINGS_DIR, P: process.env.PROFILE_DIR }; try { delete process.env.RECORDINGS_DIR; process.env.PROFILE_DIR = path.join(base, 'profiles'); if (!fs.existsSync('/recordings')) expect(recordingsRoot()).toBe(path.join(base, 'recordings')); process.env.RECORDINGS_DIR = path.join(base, 'elsewhere'); expect(recordingsRoot()).toBe(path.join(base, 'elsewhere')); } finally { if (saved.R) process.env.RECORDINGS_DIR = saved.R; else delete process.env.RECORDINGS_DIR; if (saved.P) process.env.PROFILE_DIR = saved.P; else delete process.env.PROFILE_DIR; } }); it('the quality ladder: 720p30 by default, 1080p30, 1080p60 — frame rate and audio follow the rung', () => { expect(sizeOf('720p')).toMatchObject({ width: 1280, height: 720, fps: 30, audioKbps: 160 }); expect(sizeOf('1080p')).toMatchObject({ width: 1920, height: 1080, fps: 30 }); expect(sizeOf('1080p60')).toMatchObject({ width: 1920, height: 1080, fps: 60, audioKbps: 192 }); expect(sizeOf('4k')).toMatchObject({ width: 1280, height: 720 }); const a = ffmpegArgs({ display: 101, size: sizeOf('1080p60'), out: '/tmp/x.mp4' }); expect(a[a.indexOf('-framerate') + 1]).toBe('60'); expect(a[a.indexOf('-g') + 1]).toBe('120'); expect(a[a.indexOf('-b:a') + 1]).toBe('192k'); }); it('the display is its own, sized to the recording, and never the pool\'s :99', () => { const a = xvfbArgs(101, sizeOf('720p')); expect(a[0]).toBe(':101'); expect(a).toContain('1280x720x24'); expect(a).toContain('-nolisten'); expect(DISPLAY_LOW).toBeGreaterThan(99); }); it('picks the first free display number, skipping ones an X server or this process holds', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'x11-')); fs.mkdirSync(path.join(dir, '.X11-unix'), { recursive: true }); fs.writeFileSync(path.join(dir, '.X11-unix', 'X100'), ''); fs.writeFileSync(path.join(dir, '.X102-lock'), ''); expect(allocDisplay(new Set([101]), dir)).toBe(103); expect(allocDisplay(new Set(), dir)).toBe(101); }); it('pulse is private: no default config, its own socket, one null sink named for the recording', () => { const a = pulseArgs('/tmp/pulse-abc'); expect(a).toContain('-n'); expect(a).toContain('--daemonize=no'); expect(a).toContain('--exit-idle-time=-1'); expect(a.find((x) => x.includes('module-native-protocol-unix'))).toContain('socket=/tmp/pulse-abc/native'); expect(a.find((x) => x.includes('module-null-sink'))).toContain(`sink_name=${SINK}`); }); it('chromium keeps the pool\'s flags but takes the recording\'s size, kiosk and autoplay', () => { const a = chromeArgs(['--no-sandbox', '--window-size=1280,800', '--disable-dev-shm-usage'], sizeOf('1080p')); expect(a).toContain('--no-sandbox'); expect(a).toContain('--disable-dev-shm-usage'); expect(a).not.toContain('--window-size=1280,800'); expect(a).toContain('--window-size=1920,1080'); expect(a).toContain('--kiosk'); expect(a).toContain('--autoplay-policy=no-user-gesture-required'); }); it('ffmpeg grabs that display and that sink, capped threads, mp4 with a duration or hls segments', () => { const mp4 = ffmpegArgs({ display: 101, size: sizeOf('720p'), out: '/tmp/x.mp4', seconds: 20 }); const i = mp4.indexOf('-i'); expect(mp4[i + 1]).toBe(':101'); expect(mp4).toContain('x11grab'); expect(mp4).toContain('pulse'); expect(mp4).toContain(`${SINK}.monitor`); expect(mp4[mp4.indexOf('-threads') + 1]).toBe('2'); expect(mp4[mp4.indexOf('-t') + 1]).toBe('20'); expect(mp4[mp4.length - 1]).toBe('/tmp/x.mp4'); expect(mp4).toContain('libx264'); expect(mp4).toContain('aac'); const hls = ffmpegArgs({ display: 102, size: sizeOf('720p'), out: '/recordings/r1', mode: 'hls' }); expect(hls).toContain('hls'); expect(hls[hls.indexOf('-hls_time') + 1]).toBe('10'); expect(hls[hls.length - 1]).toBe('/recordings/r1/index.m3u8'); expect(hls).not.toContain('-t'); }); });