diff --git a/tests/soak/core/screencaster.ts b/tests/soak/core/screencaster.ts new file mode 100644 index 0000000..2c3725d --- /dev/null +++ b/tests/soak/core/screencaster.ts @@ -0,0 +1,89 @@ +/** + * Screencaster — CDP Page.startScreencast wrapper for live video. + * + * Attach a CDP session to a Playwright Page, start emitting JPEG + * frames at the configured frame rate, forward each frame to a + * callback, detach on stop. Used by the dashboard's click-to-watch + * feature: when a user clicks a player tile, the runner calls + * `start(key, page, frame => ws.send(...))`; when they close the + * modal it calls `stop(key)`. + */ + +import type { Page, CDPSession } from 'playwright-core'; +import type { Logger } from './types'; + +export interface ScreencastOptions { + format?: 'jpeg' | 'png'; + quality?: number; + maxWidth?: number; + maxHeight?: number; + everyNthFrame?: number; +} + +export type FrameCallback = (jpegBase64: string) => void; + +export class Screencaster { + private sessions = new Map(); + + constructor(private logger: Logger) {} + + /** + * Attach a CDP session to the given page and start forwarding frames. + * If a screencast is already running for this sessionKey, no-op. + */ + async start( + sessionKey: string, + page: Page, + onFrame: FrameCallback, + opts: ScreencastOptions = {}, + ): Promise { + if (this.sessions.has(sessionKey)) { + this.logger.warn('screencast_already_running', { sessionKey }); + return; + } + const client = await page.context().newCDPSession(page); + this.sessions.set(sessionKey, client); + + client.on('Page.screencastFrame', async (evt: { data: string; sessionId: number }) => { + try { + onFrame(evt.data); + await client.send('Page.screencastFrameAck', { sessionId: evt.sessionId }); + } catch (err) { + this.logger.warn('screencast_frame_error', { + sessionKey, + error: err instanceof Error ? err.message : String(err), + }); + } + }); + + await client.send('Page.startScreencast', { + format: opts.format ?? 'jpeg', + quality: opts.quality ?? 60, + maxWidth: opts.maxWidth ?? 640, + maxHeight: opts.maxHeight ?? 360, + everyNthFrame: opts.everyNthFrame ?? 2, + }); + this.logger.info('screencast_started', { sessionKey }); + } + + async stop(sessionKey: string): Promise { + const client = this.sessions.get(sessionKey); + if (!client) return; + try { + await client.send('Page.stopScreencast'); + await client.detach(); + } catch (err) { + this.logger.warn('screencast_stop_error', { + sessionKey, + error: err instanceof Error ? err.message : String(err), + }); + } + this.sessions.delete(sessionKey); + this.logger.info('screencast_stopped', { sessionKey }); + } + + async stopAll(): Promise { + const keys = Array.from(this.sessions.keys()); + await Promise.all(keys.map((k) => this.stop(k))); + } +}