diff --git a/docs/src/api/class-screencast.md b/docs/src/api/class-screencast.md index 8ed62770bf254..52ad9d6f213d3 100644 --- a/docs/src/api/class-screencast.md +++ b/docs/src/api/class-screencast.md @@ -13,38 +13,29 @@ Starts capturing screencast frames. **Usage** ```js -await page.screencast.start(buffer => { - console.log(`frame size: ${buffer.length}`); -}, { maxSize: { width: 800, height: 600 } }); +const disposable = await page.screencast.start(({ data }) => { + console.log(`frame size: ${data.length}`); +}, { preferredSize: { width: 800, height: 600 } }); // ... perform actions ... -await page.screencast.stop(); +await disposable.dispose(); ``` ### param: Screencast.start.onFrame * since: v1.59 * langs: js -- `onFrame` <[function]\([Buffer]\): [Promise|any]> +- `onFrame` <[function]\([Object]\): [Promise]> + - `data` <[Buffer]> JPEG-encoded frame data. Callback that receives JPEG-encoded frame data. -### option: Screencast.start.maxSize +### option: Screencast.start.preferredSize * since: v1.59 -- `maxSize` ?<[Object]> +- `preferredSize` ?<[Object]> - `width` <[int]> Max frame width in pixels. - `height` <[int]> Max frame height in pixels. -Maximum screencast frame dimensions. The output frame may be smaller to preserve the page aspect ratio. Defaults to 800×800. +Specifies the preferred maximum dimensions of screencast frames. The actual frame is scaled to preserve the page’s aspect ratio and may be smaller than these bounds. +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. -## async method: Screencast.stop -* since: v1.59 - -Stops the screencast started with [`method: Screencast.start`]. - -**Usage** - -```js -await screencast.start(buffer => { /* handle frame */ }); -// ... perform actions ... -await screencast.stop(); -``` +Defaults to 800×800. diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index f4f8525bb0928..2c06394479663 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -16155,6 +16155,35 @@ export interface WebSocketRoute { [Symbol.asyncDispose](): Promise; } +/** + * Interface for capturing screencast frames from a page. + */ +export interface Screencast { + /** + * Starts capturing screencast frames. + * + * **Usage** + * + * ```js + * const disposable = await page.screencast.start(({ data }) => { + * console.log(`frame size: ${data.length}`); + * }, { preferredSize: { width: 800, height: 600 } }); + * // ... perform actions ... + * await disposable.dispose(); + * ``` + * + * @param onFrame Callback that receives JPEG-encoded frame data. + * @param options + */ + start(onFrame: ((frame: { data: Buffer }) => Promise|any), options?: { + preferredSize?: { + width: number; + height: number; + }; + }): Promise; + +} + type DeviceDescriptor = { viewport: ViewportSize; userAgent: string; @@ -21639,60 +21668,6 @@ export interface Route { request(): Request; } -/** - * Interface for capturing screencast frames from a page. - */ -export interface Screencast { - /** - * Starts capturing screencast frames. - * - * **Usage** - * - * ```js - * await page.screencast.start(buffer => { - * console.log(`frame size: ${buffer.length}`); - * }, { maxSize: { width: 800, height: 600 } }); - * // ... perform actions ... - * await page.screencast.stop(); - * ``` - * - * @param onFrame Callback that receives JPEG-encoded frame data. - * @param options - */ - start(onFrame: ((buffer: Buffer) => Promise|any), options?: { - /** - * Maximum screencast frame dimensions. The output frame may be smaller to preserve the page aspect ratio. Defaults to - * 800×800. - */ - maxSize?: { - /** - * Max frame width in pixels. - */ - width: number; - - /** - * Max frame height in pixels. - */ - height: number; - }; - }): Promise; - - /** - * Stops the screencast started with - * [screencast.start(onFrame[, options])](https://playwright.dev/docs/api/class-screencast#screencast-start). - * - * **Usage** - * - * ```js - * await screencast.start(buffer => { /* handle frame *\/ }); - * // ... perform actions ... - * await screencast.stop(); - * ``` - * - */ - stop(): Promise; -} - /** * Selectors can be used to install custom selector engines. See [extensibility](https://playwright.dev/docs/extensibility) for more * information. diff --git a/packages/playwright-core/src/client/screencast.ts b/packages/playwright-core/src/client/screencast.ts index d4b29516ba38c..f0e2081c7226d 100644 --- a/packages/playwright-core/src/client/screencast.ts +++ b/packages/playwright-core/src/client/screencast.ts @@ -21,23 +21,24 @@ import type { Page } from './page'; export class Screencast implements api.Screencast { private readonly _page: Page; - private _onFrame: ((buffer: Buffer) => any) | null = null; + private _listeners = new Set<(frame: { data: Buffer }) => any>(); constructor(page: Page) { this._page = page; this._page._channel.on('screencastFrame', ({ data }) => { - this._onFrame?.(data); + for (const listener of this._listeners) + void listener({ data }); }); } - async start(onFrame: (buffer: Buffer) => any, options: { maxSize?: { width: number, height: number } } = {}): Promise { - 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(); + 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(); + }); } } diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 3870bad4d7f8c..b94e90e840769 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1550,7 +1550,7 @@ scheme.PagePickLocatorResult = tObject({ scheme.PageCancelPickLocatorParams = tOptional(tObject({})); scheme.PageCancelPickLocatorResult = tOptional(tObject({})); scheme.PageStartScreencastParams = tObject({ - maxSize: tOptional(tObject({ + preferredSize: tOptional(tObject({ width: tInt, height: tInt, })), diff --git a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts index c25ae1798c293..1db511c353c6a 100644 --- a/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/pageDispatcher.ts @@ -366,7 +366,7 @@ export class PageDispatcher extends Dispatcher { if (this._screencastListener) throw new Error('Screencast is already running'); - const size = params.maxSize || { width: 800, height: 800 }; + const size = params.preferredSize || { width: 800, height: 800 }; this._screencastListener = (frame: ScreencastFrame) => { this._dispatchEvent('screencastFrame', { data: frame.buffer }); }; diff --git a/packages/playwright-core/src/server/screencast.ts b/packages/playwright-core/src/server/screencast.ts index 6e59e42d6a358..85dc056f8a415 100644 --- a/packages/playwright-core/src/server/screencast.ts +++ b/packages/playwright-core/src/server/screencast.ts @@ -31,8 +31,6 @@ export class Screencast { private _videoRecorder: VideoRecorder | null = null; private _videoId: string | null = null; private _listeners = new Set(); - private _screencastOptions: { width: number, height: number, quality: number } | null = null; - // Aiming at 25 fps by default - each frame is 40ms, but we give some slack with 35ms. // When throttling for tracing, 200ms between frames, except for 10 frames around the action. private _frameThrottler = new FrameThrottler(10, 35, 200); @@ -47,9 +45,7 @@ export class Screencast { } startForTracing(listener: ScreencastListener) { - // If screencast is already running, use the same options, it's ok for tracing. - const options = this._screencastOptions || { width: 800, height: 800, quality: 90 }; - this.startScreencast(listener, options).catch(e => debugLogger.log('error', e)); + this.startScreencast(listener, { width: 800, height: 800, quality: 90 }).catch(e => debugLogger.log('error', e)); this._frameThrottler.setThrottlingEnabled(true); } @@ -142,27 +138,15 @@ export class Screencast { } async startScreencast(listener: ScreencastListener, options: { width: number, height: number, quality: number }) { - if (this._screencastOptions) { - if (options.width !== this._screencastOptions.width || options.height !== this._screencastOptions.height || options.quality !== this._screencastOptions.quality) - throw new Error(`Screencast is already running with different options (${this._screencastOptions.width}x${this._screencastOptions.height} quality=${this._screencastOptions.quality})`); - } this._listeners.add(listener); - if (this._listeners.size === 1) { - this._screencastOptions = options; - await this._page.delegate.startScreencast({ - width: options.width, - height: options.height, - quality: options.quality, - }); - } + if (this._listeners.size === 1) + await this._page.delegate.startScreencast(options); } async stopScreencast(listener: ScreencastListener) { this._listeners.delete(listener); - if (!this._listeners.size) { - this._screencastOptions = null; + if (!this._listeners.size) await this._page.delegate.stopScreencast(); - } } onScreencastFrame(frame: types.ScreencastFrame) { diff --git a/packages/playwright-core/src/tools/dashboard/dashboardController.ts b/packages/playwright-core/src/tools/dashboard/dashboardController.ts index 1a7a99d8d3a00..8aa84e5698fc4 100644 --- a/packages/playwright-core/src/tools/dashboard/dashboardController.ts +++ b/packages/playwright-core/src/tools/dashboard/dashboardController.ts @@ -32,6 +32,7 @@ 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>(); @@ -179,7 +180,8 @@ export class DashboardConnection implements Transport, DashboardChannel { if (this.selectedPage) { this._pageListeners.forEach(d => d.dispose()); this._pageListeners = []; - await this.selectedPage.screencast.stop(); + await this._screencastSession?.dispose(); + this._screencastSession = null; } this.selectedPage = page; @@ -201,10 +203,10 @@ export class DashboardConnection implements Transport, DashboardChannel { }), ); - const maxSize = { width: 1280, height: 800 }; - await page.screencast.start( - data => this._writeFrame(data, page.viewportSize()?.width ?? 0, page.viewportSize()?.height ?? 0), - { maxSize }, + const preferredSize = { width: 1280, height: 800 }; + this._screencastSession = await page.screencast.start( + ({ data }) => this._writeFrame(data, page.viewportSize()?.width ?? 0, page.viewportSize()?.height ?? 0), + { preferredSize }, ); } @@ -213,7 +215,8 @@ export class DashboardConnection implements Transport, DashboardChannel { return; this._pageListeners.forEach(d => d.dispose()); this._pageListeners = []; - this.selectedPage.screencast.stop().catch(() => {}); + this._screencastSession?.dispose().catch(() => {}); + this._screencastSession = null; 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 f4f8525bb0928..2c06394479663 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -16155,6 +16155,35 @@ export interface WebSocketRoute { [Symbol.asyncDispose](): Promise; } +/** + * Interface for capturing screencast frames from a page. + */ +export interface Screencast { + /** + * Starts capturing screencast frames. + * + * **Usage** + * + * ```js + * const disposable = await page.screencast.start(({ data }) => { + * console.log(`frame size: ${data.length}`); + * }, { preferredSize: { width: 800, height: 600 } }); + * // ... perform actions ... + * await disposable.dispose(); + * ``` + * + * @param onFrame Callback that receives JPEG-encoded frame data. + * @param options + */ + start(onFrame: ((frame: { data: Buffer }) => Promise|any), options?: { + preferredSize?: { + width: number; + height: number; + }; + }): Promise; + +} + type DeviceDescriptor = { viewport: ViewportSize; userAgent: string; @@ -21639,60 +21668,6 @@ export interface Route { request(): Request; } -/** - * Interface for capturing screencast frames from a page. - */ -export interface Screencast { - /** - * Starts capturing screencast frames. - * - * **Usage** - * - * ```js - * await page.screencast.start(buffer => { - * console.log(`frame size: ${buffer.length}`); - * }, { maxSize: { width: 800, height: 600 } }); - * // ... perform actions ... - * await page.screencast.stop(); - * ``` - * - * @param onFrame Callback that receives JPEG-encoded frame data. - * @param options - */ - start(onFrame: ((buffer: Buffer) => Promise|any), options?: { - /** - * Maximum screencast frame dimensions. The output frame may be smaller to preserve the page aspect ratio. Defaults to - * 800×800. - */ - maxSize?: { - /** - * Max frame width in pixels. - */ - width: number; - - /** - * Max frame height in pixels. - */ - height: number; - }; - }): Promise; - - /** - * Stops the screencast started with - * [screencast.start(onFrame[, options])](https://playwright.dev/docs/api/class-screencast#screencast-start). - * - * **Usage** - * - * ```js - * await screencast.start(buffer => { /* handle frame *\/ }); - * // ... perform actions ... - * await screencast.stop(); - * ``` - * - */ - stop(): Promise; -} - /** * Selectors can be used to install custom selector engines. See [extensibility](https://playwright.dev/docs/extensibility) for more * information. diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 8512dcc0ddf77..235003ac0fefd 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -2684,13 +2684,13 @@ export type PageCancelPickLocatorParams = {}; export type PageCancelPickLocatorOptions = {}; export type PageCancelPickLocatorResult = void; export type PageStartScreencastParams = { - maxSize?: { + preferredSize?: { width: number, height: number, }, }; export type PageStartScreencastOptions = { - maxSize?: { + preferredSize?: { width: number, height: number, }, diff --git a/packages/protocol/src/protocol.yml b/packages/protocol/src/protocol.yml index 4c0ab686bce36..8a7792f49e9ce 100644 --- a/packages/protocol/src/protocol.yml +++ b/packages/protocol/src/protocol.yml @@ -2101,7 +2101,7 @@ Page: title: Start screencast group: configuration parameters: - maxSize: + preferredSize: type: object? properties: width: int diff --git a/tests/library/screencast.spec.ts b/tests/library/screencast.spec.ts index 9191361701cc3..9944b6bb4dac5 100644 --- a/tests/library/screencast.spec.ts +++ b/tests/library/screencast.spec.ts @@ -26,12 +26,12 @@ test('screencast.start delivers frames via onFrame callback', async ({ browser, const page = await context.newPage(); const frames: Buffer[] = []; - const maxSize = { width: 500, height: 400 }; - await page.screencast.start(frame => frames.push(frame), { maxSize }); + const preferredSize = { width: 500, height: 400 }; + const disposable = 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 page.screencast.stop(); + await disposable.dispose(); expect(frames.length).toBeGreaterThan(0); for (const frame of frames) { @@ -47,17 +47,24 @@ test('screencast.start delivers frames via onFrame callback', async ({ browser, await context.close(); }); -test('start throws if already running', async ({ browser, trace }) => { - test.skip(trace === 'on', 'trace=on enables screencast with different options'); +test('supports multiple onFrame listeners', async ({ browser, server, trace }) => { + test.skip(trace === 'on', 'trace=on has different screencast image configuration'); - const size = { width: 500, height: 400 }; - const context = await browser.newContext({ viewport: size }); + const context = await browser.newContext({ viewport: { width: 500, height: 400 } }); const page = await context.newPage(); - await page.screencast.start(() => {}, { maxSize: size }); - await expect(page.screencast.start(() => {}, { maxSize: { width: 320, height: 240 } })).rejects.toThrow('Screencast is already running'); + 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.screencast.stop(); + await page.goto(server.EMPTY_PAGE); + await rafraf(page, 100); + await disposable1.dispose(); + await disposable2.dispose(); + + expect(frames1.length).toBeGreaterThan(0); + expect(frames2.length).toBeGreaterThan(0); await context.close(); }); @@ -67,15 +74,15 @@ 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(); - await page.screencast.start(() => {}, { maxSize: { width: 500, height: 400 } }); - await page.screencast.stop(); + const disposable1 = await page.screencast.start(() => {}, { preferredSize: { width: 500, height: 400 } }); + await disposable1.dispose(); // Different options should succeed once the previous screencast is stopped. - await page.screencast.start(() => {}, { maxSize: { width: 320, height: 240 } }); - await page.screencast.stop(); + const disposable2 = await page.screencast.start(() => {}, { preferredSize: { width: 320, height: 240 } }); + await disposable2.dispose(); await context.close(); }); -test('start throws when video recording is running with different params', async ({ browser, trace }) => { +test('start reuses existing screencast when video recording is running', async ({ browser, server, trace }) => { test.skip(trace === 'on', 'trace=on enables screencast with different options'); const videoSize = { width: 500, height: 400 }; @@ -83,7 +90,23 @@ test('start throws when video recording is running with different params', async const page = await context.newPage(); await page.video().start({ size: videoSize }); - await expect(page.screencast.start(() => {}, { maxSize: { width: 320, height: 240 } })).rejects.toThrow('Screencast is already running with different options'); + + const frames: Buffer[] = []; + const disposable = 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(); + + expect(frames.length).toBeGreaterThan(0); + for (const frame of frames) { + // Each frame must be a valid JPEG (starts with FF D8) + expect(frame[0]).toBe(0xff); + expect(frame[1]).toBe(0xd8); + const { width, height } = jpegDimensions(frame); + // Frame should be scaled down to fit the maximum size. + expect(width).toBe(videoSize.width); + expect(height).toBe(videoSize.height); + } await page.video().stop(); await context.close(); @@ -95,7 +118,7 @@ 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(frame => frames.push(frame), { maxSize: { width: 500, height: 400 } }); + const disposable = 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); diff --git a/utils/generate_third_party_notice.js b/utils/generate_third_party_notice.js index 8d66a093709b5..afb1c69a7da57 100644 --- a/utils/generate_third_party_notice.js +++ b/utils/generate_third_party_notice.js @@ -27,7 +27,9 @@ async function checkDir(dir) { licenseText: '', } }, function(err, packages) { - if (err) + if (err && err.message.startsWith('No packages found')) + r(new Error(`No packages found in ${dir}`)); + else if (err) r(err); else f(packages); diff --git a/utils/generate_types/overrides.d.ts b/utils/generate_types/overrides.d.ts index 21bad32aa839b..2c4b2a0cfa23c 100644 --- a/utils/generate_types/overrides.d.ts +++ b/utils/generate_types/overrides.d.ts @@ -243,6 +243,15 @@ export interface WebSocketRoute { onClose(handler: (code: number | undefined, reason: string | undefined) => any): void; } +export interface Screencast { + start(onFrame: ((frame: { data: Buffer }) => Promise|any), options?: { + preferredSize?: { + width: number; + height: number; + }; + }): Promise; +} + type DeviceDescriptor = { viewport: ViewportSize; userAgent: string;