From 36707334e06d70d2f507db012a3abd19a8189a45 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 19 Mar 2026 15:44:53 -0600 Subject: [PATCH] feat(api): add Screencast.stop() and enforce single listener Replace the disposable pattern from screencast.start() with an explicit screencast.stop() method. Only one listener is allowed at a time; starting a second one throws 'Screencast is already started'. --- docs/src/api/class-screencast.md | 9 ++++- packages/playwright-client/types/types.d.ts | 10 +++-- .../playwright-core/src/client/screencast.ts | 23 +++++------ .../tools/dashboard/dashboardController.ts | 9 ++--- packages/playwright-core/types/types.d.ts | 10 +++-- tests/library/screencast.spec.ts | 38 +++++++------------ 6 files changed, 50 insertions(+), 49 deletions(-) diff --git a/docs/src/api/class-screencast.md b/docs/src/api/class-screencast.md index 52ad9d6f213d3..1c5f1b4580549 100644 --- a/docs/src/api/class-screencast.md +++ b/docs/src/api/class-screencast.md @@ -13,11 +13,11 @@ Starts capturing screencast frames. **Usage** ```js -const disposable = await page.screencast.start(({ data }) => { +await page.screencast.start(({ data }) => { console.log(`frame size: ${data.length}`); }, { preferredSize: { width: 800, height: 600 } }); // ... perform actions ... -await disposable.dispose(); +await page.screencast.stop(); ``` ### param: Screencast.start.onFrame @@ -39,3 +39,8 @@ Specifies the preferred maximum dimensions of screencast frames. The actual fram If a screencast is already active (e.g. started by tracing or video recording), the existing configuration takes precedence and the frame size may exceed these bounds or this option may be ignored. Defaults to 800×800. + +## async method: Screencast.stop +* since: v1.59 + +Stops the screencast started with [`method: Screencast.start`]. diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 6d792fc9a2e0a..f9cc94cca2462 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -16163,11 +16163,11 @@ export interface Screencast { * **Usage** * * ```js - * const disposable = await page.screencast.start(({ data }) => { + * await page.screencast.start(({ data }) => { * console.log(`frame size: ${data.length}`); * }, { preferredSize: { width: 800, height: 600 } }); * // ... perform actions ... - * await disposable.dispose(); + * await page.screencast.stop(); * ``` * * @param onFrame Callback that receives JPEG-encoded frame data. @@ -16179,7 +16179,11 @@ export interface Screencast { height: number; }; }): Promise; - + /** + * Stops the screencast started with + * [screencast.start(onFrame[, options])](https://playwright.dev/docs/api/class-screencast#screencast-start). + */ + stop(): Promise; } type DeviceDescriptor = { diff --git a/packages/playwright-core/src/client/screencast.ts b/packages/playwright-core/src/client/screencast.ts index f0e2081c7226d..335b9046cadcb 100644 --- a/packages/playwright-core/src/client/screencast.ts +++ b/packages/playwright-core/src/client/screencast.ts @@ -21,24 +21,25 @@ import type { Page } from './page'; export class Screencast implements api.Screencast { private readonly _page: Page; - private _listeners = new Set<(frame: { data: Buffer }) => any>(); + private _onFrame: ((frame: { data: Buffer }) => Promise) | null = null; constructor(page: Page) { this._page = page; this._page._channel.on('screencastFrame', ({ data }) => { - for (const listener of this._listeners) - void listener({ data }); + void this._onFrame?.({ data }); }); } async start(onFrame: (frame: { data: Buffer }) => Promise|any, options: { preferredSize?: { width: number, height: number } } = {}): Promise { - this._listeners.add(onFrame); - if (this._listeners.size === 1) - await this._page._channel.startScreencast(options); - return new DisposableStub(async () => { - this._listeners.delete(onFrame); - if (!this._listeners.size) - await this._page._channel.stopScreencast(); - }); + if (this._onFrame) + throw new Error('Screencast is already started'); + this._onFrame = onFrame; + await this._page._channel.startScreencast(options); + return new DisposableStub(() => this.stop()); + } + + async stop(): Promise { + this._onFrame = null; + await this._page._channel.stopScreencast(); } } diff --git a/packages/playwright-core/src/tools/dashboard/dashboardController.ts b/packages/playwright-core/src/tools/dashboard/dashboardController.ts index 8aa84e5698fc4..99ab317fc822e 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardController.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardController.ts @@ -32,7 +32,6 @@ export class DashboardConnection implements Transport, DashboardChannel { private _lastFrameData: string | null = null; private _lastViewportSize: { width: number, height: number } | null = null; private _pageListeners: { dispose: () => Promise }[] = []; - private _screencastSession: { dispose: () => Promise } | null = null; private _contextListeners: { dispose: () => Promise }[] = []; private _eventListeners = new Map>(); @@ -180,8 +179,7 @@ export class DashboardConnection implements Transport, DashboardChannel { if (this.selectedPage) { this._pageListeners.forEach(d => d.dispose()); this._pageListeners = []; - await this._screencastSession?.dispose(); - this._screencastSession = null; + await this.selectedPage.screencast.stop(); } this.selectedPage = page; @@ -204,7 +202,7 @@ export class DashboardConnection implements Transport, DashboardChannel { ); const preferredSize = { width: 1280, height: 800 }; - this._screencastSession = await page.screencast.start( + await page.screencast.start( ({ data }) => this._writeFrame(data, page.viewportSize()?.width ?? 0, page.viewportSize()?.height ?? 0), { preferredSize }, ); @@ -215,8 +213,7 @@ export class DashboardConnection implements Transport, DashboardChannel { return; this._pageListeners.forEach(d => d.dispose()); this._pageListeners = []; - this._screencastSession?.dispose().catch(() => {}); - this._screencastSession = null; + this.selectedPage.screencast.stop().catch(() => {}); this.selectedPage = null; this._lastFrameData = null; this._lastViewportSize = null; diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 6d792fc9a2e0a..f9cc94cca2462 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -16163,11 +16163,11 @@ export interface Screencast { * **Usage** * * ```js - * const disposable = await page.screencast.start(({ data }) => { + * await page.screencast.start(({ data }) => { * console.log(`frame size: ${data.length}`); * }, { preferredSize: { width: 800, height: 600 } }); * // ... perform actions ... - * await disposable.dispose(); + * await page.screencast.stop(); * ``` * * @param onFrame Callback that receives JPEG-encoded frame data. @@ -16179,7 +16179,11 @@ export interface Screencast { height: number; }; }): Promise; - + /** + * Stops the screencast started with + * [screencast.start(onFrame[, options])](https://playwright.dev/docs/api/class-screencast#screencast-start). + */ + stop(): Promise; } type DeviceDescriptor = { diff --git a/tests/library/screencast.spec.ts b/tests/library/screencast.spec.ts index 9944b6bb4dac5..8fb3191bedc7d 100644 --- a/tests/library/screencast.spec.ts +++ b/tests/library/screencast.spec.ts @@ -27,11 +27,11 @@ test('screencast.start delivers frames via onFrame callback', async ({ browser, const frames: Buffer[] = []; const preferredSize = { width: 500, height: 400 }; - const disposable = await page.screencast.start(({ data }) => frames.push(data), { preferredSize }); + await page.screencast.start(({ data }) => frames.push(data), { preferredSize }); await page.goto(server.EMPTY_PAGE); await page.evaluate(() => document.body.style.backgroundColor = 'red'); await rafraf(page, 100); - await disposable.dispose(); + await page.screencast.stop(); expect(frames.length).toBeGreaterThan(0); for (const frame of frames) { @@ -47,24 +47,14 @@ test('screencast.start delivers frames via onFrame callback', async ({ browser, await context.close(); }); -test('supports multiple onFrame listeners', async ({ browser, server, trace }) => { - test.skip(trace === 'on', 'trace=on has different screencast image configuration'); - +test('start throws if screencast is already started', async ({ browser }) => { const context = await browser.newContext({ viewport: { width: 500, height: 400 } }); const page = await context.newPage(); - const frames1: Buffer[] = []; - const frames2: Buffer[] = []; - const disposable1 = await page.screencast.start(({ data }) => frames1.push(data), { preferredSize: { width: 500, height: 400 } }); - const disposable2 = await page.screencast.start(({ data }) => frames2.push(data)); - - await page.goto(server.EMPTY_PAGE); - await rafraf(page, 100); - await disposable1.dispose(); - await disposable2.dispose(); + await page.screencast.start(() => {}); + await expect(page.screencast.start(() => {})).rejects.toThrow('Screencast is already started'); - expect(frames1.length).toBeGreaterThan(0); - expect(frames2.length).toBeGreaterThan(0); + await page.screencast.stop(); await context.close(); }); @@ -74,11 +64,11 @@ test('start allows restart with different options after stop', async ({ browser, const context = await browser.newContext({ viewport: { width: 500, height: 400 } }); const page = await context.newPage(); - const disposable1 = await page.screencast.start(() => {}, { preferredSize: { width: 500, height: 400 } }); - await disposable1.dispose(); + await page.screencast.start(() => {}, { preferredSize: { width: 500, height: 400 } }); + await page.screencast.stop(); // Different options should succeed once the previous screencast is stopped. - const disposable2 = await page.screencast.start(() => {}, { preferredSize: { width: 320, height: 240 } }); - await disposable2.dispose(); + await page.screencast.start(() => {}, { preferredSize: { width: 320, height: 240 } }); + await page.screencast.stop(); await context.close(); }); @@ -92,10 +82,10 @@ test('start reuses existing screencast when video recording is running', async ( await page.video().start({ size: videoSize }); const frames: Buffer[] = []; - const disposable = await page.screencast.start(({ data }) => frames.push(data), { preferredSize: { width: 320, height: 240 } }); + await page.screencast.start(({ data }) => frames.push(data), { preferredSize: { width: 320, height: 240 } }); await page.goto(server.EMPTY_PAGE); await rafraf(page, 100); - await disposable.dispose(); + await page.screencast.stop(); expect(frames.length).toBeGreaterThan(0); for (const frame of frames) { @@ -118,11 +108,11 @@ test('start returns a disposable that stops screencast', async ({ browser, serve const page = await context.newPage(); const frames: Buffer[] = []; - const disposable = await page.screencast.start(({ data }) => frames.push(data), { preferredSize: { width: 500, height: 400 } }); + await page.screencast.start(({ data }) => frames.push(data), { preferredSize: { width: 500, height: 400 } }); await page.goto(server.EMPTY_PAGE); await page.evaluate(() => document.body.style.backgroundColor = 'red'); await rafraf(page, 100); - await disposable.dispose(); + await page.screencast.stop(); const frameCountAfterDispose = frames.length; expect(frameCountAfterDispose).toBeGreaterThan(0);