blob: 75992a869db7e969019f5ca4fa86fcc7368ed8b8 [file] [log] [blame] [edit]
import { Page, CDPSession } from 'puppeteer';
import * as fs from 'fs';
import * as path from 'path';
import { spawnSync } from 'child_process';
import { outputDir } from './paths';
/**
* Records a video of a Puppeteer page session using Chrome DevTools Protocol (CDP).
*
* It captures frames as PNGs and stitches them into an MP4 video using ffmpeg.
* Useful for debugging failing tests or verifying UI behavior.
*
* Usage:
* ```typescript
* const recorder = new ScreencastRecorder('screencast');
* await recorder.start(page);
* // ... perform actions ...
* await recorder.stop();
* ```
*
* To record a video, run the test with the following flag:
* bazelisk test --test_env=RECORD_VIDEO=true //perf/modules/explore-multi-sk/...
*
* The resulting mp4 video will be under this path:
* OUTPUT=$(bazelisk info output_path)
* $OUTPUT/k8-fastbuild/testlogs/perf/modules/{module_name}/{test_name}/test.outputs/screencast.mp4
*
* Example of module_name: explore-multi-sk
* Example of test_name: explore-multi-sk_puppeteer_test
*/
export class ScreencastRecorder {
private client: CDPSession | null = null;
private page: Page | null = null;
private frameCount = 0;
private sessionOutputDir: string;
private prefix: string;
private fps: number;
constructor(testName: string, fps = 5) {
// Sanitize test name for file system
const safeTestName = testName.replace(/[^a-z0-9]/gi, '_').toLowerCase();
this.sessionOutputDir = path.join(outputDir(), 'screencasts', safeTestName);
this.prefix = safeTestName;
this.fps = fps;
}
async start(page: Page) {
this.page = page;
if (fs.existsSync(this.sessionOutputDir)) {
fs.rmSync(this.sessionOutputDir, { recursive: true, force: true });
}
fs.mkdirSync(this.sessionOutputDir, { recursive: true });
await this.enableUrlOverlay(page);
try {
this.client = await page.target().createCDPSession();
this.client.on('Page.screencastFrame', async (frameObject) => {
try {
const { data, sessionId } = frameObject;
const buffer = Buffer.from(data, 'base64');
// Use sequential numbering for ffmpeg
const filename = path.join(
this.sessionOutputDir,
`${this.prefix}_${this.frameCount.toString().padStart(6, '0')}.png`
);
fs.writeFileSync(filename, buffer);
await this.client?.send('Page.screencastFrameAck', { sessionId });
this.frameCount++;
} catch (e) {
console.error('Error handling screencast frame:', e);
}
});
await this.client.send('Page.startScreencast', {
format: 'png',
everyNthFrame: 1,
quality: 100,
});
console.log(`Screencast started. Frames will be saved to ${this.sessionOutputDir}`);
} catch (e) {
console.error('Failed to start screencast:', e);
}
}
async stop() {
// Wait for 3 seconds to capture final state
await new Promise((resolve) => setTimeout(resolve, 3000));
if (this.client) {
try {
await this.client.send('Page.stopScreencast');
await this.client.detach();
} catch (e) {
console.error('Error stopping screencast:', e);
} finally {
this.client = null;
}
}
// Capture explicit final frame to ensure the last state is visible
if (this.page && !this.page.isClosed()) {
try {
const filename = path.join(
this.sessionOutputDir,
`${this.prefix}_${this.frameCount.toString().padStart(6, '0')}.png`
);
await this.page.screenshot({ path: filename });
this.frameCount++;
} catch (e) {
console.error('Error taking final screenshot:', e);
}
}
console.log(`Screencast stopped. Saved ${this.frameCount} frames.`);
if (this.frameCount > 0) {
this.createVideo();
}
}
/**
* Injects a sticky header to display the current URL.
*/
private async enableUrlOverlay(page: Page) {
const injectStyles = async () => {
await page.evaluate(() => {
if (document.getElementById('puppeteer-url-overlay')) return;
const overlay = document.createElement('div');
overlay.id = 'puppeteer-url-overlay';
overlay.style.position = 'fixed';
// Move to top
overlay.style.top = '0';
overlay.style.left = '0';
overlay.style.width = '100%';
overlay.style.backgroundColor = '#ffeb3b'; // Yellow background
overlay.style.color = 'black';
overlay.style.zIndex = '999999';
overlay.style.fontFamily = 'monospace';
overlay.style.fontSize = '12px';
overlay.style.padding = '4px 8px';
// Border on bottom
overlay.style.borderBottom = '1px solid #ccc';
overlay.style.whiteSpace = 'nowrap';
overlay.style.overflow = 'hidden';
overlay.style.textOverflow = 'ellipsis';
overlay.style.opacity = '0.9';
overlay.textContent = window.location.href;
document.body.appendChild(overlay);
});
};
// Inject immediately
await injectStyles();
// Re-inject/Update on navigation
page.on('framenavigated', async (frame) => {
if (frame === page.mainFrame()) {
await injectStyles();
await page.evaluate(() => {
const overlay = document.getElementById('puppeteer-url-overlay');
if (overlay) overlay.textContent = window.location.href;
});
}
});
}
private createVideo() {
const videoPath = path.join(this.sessionOutputDir, '..', `${this.prefix}.mp4`);
try {
// Run ffmpeg
const args = [
'-framerate',
this.fps.toString(),
'-i',
path.join(this.sessionOutputDir, `${this.prefix}_%06d.png`),
'-c:v',
'libx264',
'-pix_fmt',
'yuv420p',
'-y',
videoPath,
];
console.log(`Generating video: ffmpeg ${args.join(' ')}`);
const result = spawnSync('ffmpeg', args);
if (result.error) {
console.error('Failed to run ffmpeg:', result.error);
} else if (result.status !== 0) {
console.error('ffmpeg failed:', result.stderr.toString());
} else {
console.log(`Video saved to ${videoPath}`);
}
} catch (e) {
console.error('Error creating video:', e);
}
}
}