diff --git a/docs/src/api/class-screencast.md b/docs/src/api/class-screencast.md index ba9817177514e..90959a011424a 100644 --- a/docs/src/api/class-screencast.md +++ b/docs/src/api/class-screencast.md @@ -62,9 +62,12 @@ The quality of the image, between 0-100. - `height` <[int]> Max frame height in pixels. Specifies the 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. If not specified the size will be equal to page viewport scaled down to fit into 800×800. +A page is captured only once, and all consumers share that single capture. Tracing captures screenshots +this way as well, so with tracing or the `recordVideo` context option already active this option is +ignored, and both the frames and any recorded video use the size of the running capture. + ## async method: Screencast.stop * since: v1.59 diff --git a/docs/src/api/class-tracing.md b/docs/src/api/class-tracing.md index e181129ccff5f..ab10a8450dbcf 100644 --- a/docs/src/api/class-tracing.md +++ b/docs/src/api/class-tracing.md @@ -139,6 +139,24 @@ given name prefix inside the [`option: BrowserType.launch.tracesDir`] directory To specify the final trace zip file name, you need to pass `path` option to [`method: Tracing.stop`] instead. +### option: Tracing.start.screencast +* since: v1.63 +* langs: js +- `screencast` <[boolean]|[Object]> + - `size` ?<[Object]> Dimensions of the captured screenshots. Each screenshot is scaled down to preserve the page's + aspect ratio and may be smaller than these bounds. If not specified the size will be equal to `viewport` scaled + down to fit into 800x800. Optional. + - `width` <[int]> Max screenshot width in pixels. + - `height` <[int]> Max screenshot height in pixels. + - `quality` ?<[int]> The quality of the screenshots, between 0-100. Defaults to 90. Optional. + +Whether to capture screenshots during tracing. Screenshots are used to build a timeline preview. +Passing `true` is a shortcut for `{}`. Takes precedence over [`option: Tracing.start.screenshots`]. + +A page is captured only once, and tracing shares that capture with [`method: Screencast.start`] and video recording. +The trace viewer renders the timeline preview at a fixed size, so these settings only matter to those other +consumers: raise them to keep tracing from capping what the others receive. + ### option: Tracing.start.screenshots * since: v1.12 - `screenshots` <[boolean]> diff --git a/docs/src/test-api/class-testoptions.md b/docs/src/test-api/class-testoptions.md index 5e8dfa649381c..6134e9a637962 100644 --- a/docs/src/test-api/class-testoptions.md +++ b/docs/src/test-api/class-testoptions.md @@ -683,6 +683,11 @@ export default defineConfig({ - type: <[Object]|[TraceMode]<"off"|"on"|"retain-on-failure"|"on-first-retry"|"retain-on-first-failure"|"retain-on-failure-and-retries">> - `mode` <[TraceMode]<"off"|"on"|"retain-on-failure"|"on-first-retry"|"on-all-retries"|"retain-on-first-failure"|"retain-on-failure-and-retries">> Trace recording mode. - `attachments` ?<[boolean]> Whether to include test attachments. Defaults to true. Optional. + - `screencast` ?<[boolean]|[Object]> Whether to capture screenshots during tracing. Screenshots are used to build a timeline preview. Passing `true` is a shortcut for `{}`. Takes precedence over `screenshots`. Optional. + - `size` ?<[Object]> Dimensions of the captured screenshots. Each screenshot is scaled down to preserve the page's aspect ratio and may be smaller than these bounds. If not specified the size will be equal to `viewport` scaled down to fit into 800x800. Optional. + - `width` <[int]> Max screenshot width in pixels. + - `height` <[int]> Max screenshot height in pixels. + - `quality` ?<[int]> The quality of the screenshots, between 0-100. Defaults to 90. Optional. - `screenshots` ?<[boolean]> Whether to capture screenshots during tracing. Screenshots are used to build a timeline preview. Defaults to true. Optional. - `snapshots` ?<[boolean]|[Object]> Which snapshots to capture on every action. Passing `true` is a shortcut for `{ dom: true }`. Defaults to true. Optional. - `dom` ?<[boolean]> Capture DOM snapshot on every action and record network activity. Optional. diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 19310a9c322b2..f4f5766b5d52e 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -23683,6 +23683,40 @@ export interface Tracing { */ name?: string; + /** + * Whether to capture screenshots during tracing. Screenshots are used to build a timeline preview. Passing `true` is + * a shortcut for `{}`. Takes precedence over + * [`screenshots`](https://playwright.dev/docs/api/class-tracing#tracing-start-option-screenshots). + * + * A page is captured only once, and tracing shares that capture with + * [screencast.start([options])](https://playwright.dev/docs/api/class-screencast#screencast-start) and video + * recording. The trace viewer renders the timeline preview at a fixed size, so these settings only matter to those + * other consumers: raise them to keep tracing from capping what the others receive. + */ + screencast?: boolean|{ + /** + * Dimensions of the captured screenshots. Each screenshot is scaled down to preserve the page's aspect ratio and may + * be smaller than these bounds. If not specified the size will be equal to `viewport` scaled down to fit into + * 800x800. Optional. + */ + size?: { + /** + * Max screenshot width in pixels. + */ + width: number; + + /** + * Max screenshot height in pixels. + */ + height: number; + }; + + /** + * The quality of the screenshots, between 0-100. Defaults to 90. Optional. + */ + quality?: number; + }; + /** * Whether to capture screenshots during tracing. Screenshots are used to build a timeline preview. */ diff --git a/packages/playwright-core/src/client/channels.d.ts b/packages/playwright-core/src/client/channels.d.ts index 52b5210534c0d..4e892dd09b163 100644 --- a/packages/playwright-core/src/client/channels.d.ts +++ b/packages/playwright-core/src/client/channels.d.ts @@ -5051,6 +5051,11 @@ export type TracingTracingStartParams = { snapshotAria?: boolean, snapshotScreen?: boolean, screencast?: boolean, + screencastSize?: { + width: number, + height: number, + }, + screencastQuality?: number, live?: boolean, }; export type TracingTracingStartOptions = { @@ -5059,6 +5064,11 @@ export type TracingTracingStartOptions = { snapshotAria?: boolean, snapshotScreen?: boolean, screencast?: boolean, + screencastSize?: { + width: number, + height: number, + }, + screencastQuality?: number, live?: boolean, }; export type TracingTracingStartResult = void; diff --git a/packages/playwright-core/src/client/tracing.ts b/packages/playwright-core/src/client/tracing.ts index b0257c5258191..ac88da92889e2 100644 --- a/packages/playwright-core/src/client/tracing.ts +++ b/packages/playwright-core/src/client/tracing.ts @@ -42,17 +42,21 @@ export class Tracing extends ChannelOwner implements ap super(parent, type, guid, initializer); } - async start(options: { name?: string, title?: string, snapshots?: boolean | { dom?: boolean, aria?: boolean, screen?: boolean }, screenshots?: boolean, sources?: boolean, live?: boolean } = {}) { + async start(options: { name?: string, title?: string, snapshots?: boolean | { dom?: boolean, aria?: boolean, screen?: boolean }, screencast?: boolean | { size?: { width: number, height: number }, quality?: number }, screenshots?: boolean, sources?: boolean, live?: boolean } = {}) { await this._wrapApiCall(async () => { this._includeSources = !!options.sources; this._isLive = !!options.live; const snapshots = typeof options.snapshots === 'object' ? options.snapshots : { dom: options.snapshots }; + const screencastOption = options.screencast ?? options.screenshots; + const screencast = typeof screencastOption === 'object' ? screencastOption : {}; await this._channel.tracingStart({ name: options.name, snapshotDom: snapshots.dom, snapshotAria: snapshots.aria, snapshotScreen: snapshots.screen, - screencast: options.screenshots, + screencast: !!screencastOption, + screencastSize: screencast.size, + screencastQuality: screencast.quality, live: options.live, }, kNoTimeout); const { traceName } = await this._channel.tracingStartChunk({ name: options.name, title: options.title }, kNoTimeout); diff --git a/packages/playwright-core/src/server/channels.d.ts b/packages/playwright-core/src/server/channels.d.ts index 87fec7205bc8e..b7fbee94374c4 100644 --- a/packages/playwright-core/src/server/channels.d.ts +++ b/packages/playwright-core/src/server/channels.d.ts @@ -5052,6 +5052,11 @@ export type TracingTracingStartParams = { snapshotAria?: boolean, snapshotScreen?: boolean, screencast?: boolean, + screencastSize?: { + width: number, + height: number, + }, + screencastQuality?: number, live?: boolean, }; export type TracingTracingStartOptions = { @@ -5060,6 +5065,11 @@ export type TracingTracingStartOptions = { snapshotAria?: boolean, snapshotScreen?: boolean, screencast?: boolean, + screencastSize?: { + width: number, + height: number, + }, + screencastQuality?: number, live?: boolean, }; export type TracingTracingStartResult = void; diff --git a/packages/playwright-core/src/server/trace/recorder/tracing.ts b/packages/playwright-core/src/server/trace/recorder/tracing.ts index 5df9d1cf42d4b..c8732eb513d47 100644 --- a/packages/playwright-core/src/server/trace/recorder/tracing.ts +++ b/packages/playwright-core/src/server/trace/recorder/tracing.ts @@ -64,6 +64,8 @@ export type TracerOptions = { snapshotAria?: boolean; snapshotScreen?: boolean; screencast?: boolean; + screencastSize?: types.Size; + screencastQuality?: number; live?: boolean; }; @@ -746,7 +748,7 @@ export class Tracing extends SdkObject implements InstrumentationListener, Snaps this._appendResource(file, params.buffer); this._appendTraceEvent(event); }; - this._pageTracingRecorders.set(page, new ScreencastTracingRecorder(page.screencast, onFrame)); + this._pageTracingRecorders.set(page, new ScreencastTracingRecorder(page.screencast, onFrame, this._state!.options.screencastSize, this._state!.options.screencastQuality)); } private _appendTraceEvent(event: trace.TraceEvent) { @@ -846,9 +848,11 @@ class ScreencastTracingRecorder { private _pendingAck: ManualPromise | undefined; private _timer: NodeJS.Timeout | undefined; - constructor(screencast: Screencast, onFrame: (frame: types.ScreencastFrame) => void) { + constructor(screencast: Screencast, onFrame: (frame: types.ScreencastFrame) => void, size: types.Size | undefined, quality: number | undefined) { this._screencast = screencast; this._client = { + size, + quality, onFrame: (frame: types.ScreencastFrame) => { const time = monotonicTime(); diff --git a/packages/playwright-core/src/server/videoRecorder.ts b/packages/playwright-core/src/server/videoRecorder.ts index 65f47b30fedc3..43e9c89dbd695 100644 --- a/packages/playwright-core/src/server/videoRecorder.ts +++ b/packages/playwright-core/src/server/videoRecorder.ts @@ -57,10 +57,11 @@ export class VideoRecorder { size: options.size, }; + // Encode into the size of the running capture: it may differ from the requested one when + // another client (e.g. tracing) started the screencast first, and padding a smaller frame + // into the requested size would leave gray borders. const { size } = this._screencast.addClient(this._client); - // For video files only, prioritize encoding into the given size, regardless of the actual pixel data. - const videoSize = options.size ?? size; - this._videoRecorder = new FfmpegVideoRecorder(ffmpegPath, videoSize, outputFile, this._screencast.page.delegate); + this._videoRecorder = new FfmpegVideoRecorder(ffmpegPath, size, outputFile, this._screencast.page.delegate); this._artifact = new Artifact(this._screencast.page.browserContext, outputFile); return this._artifact; } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 19310a9c322b2..f4f5766b5d52e 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -23683,6 +23683,40 @@ export interface Tracing { */ name?: string; + /** + * Whether to capture screenshots during tracing. Screenshots are used to build a timeline preview. Passing `true` is + * a shortcut for `{}`. Takes precedence over + * [`screenshots`](https://playwright.dev/docs/api/class-tracing#tracing-start-option-screenshots). + * + * A page is captured only once, and tracing shares that capture with + * [screencast.start([options])](https://playwright.dev/docs/api/class-screencast#screencast-start) and video + * recording. The trace viewer renders the timeline preview at a fixed size, so these settings only matter to those + * other consumers: raise them to keep tracing from capping what the others receive. + */ + screencast?: boolean|{ + /** + * Dimensions of the captured screenshots. Each screenshot is scaled down to preserve the page's aspect ratio and may + * be smaller than these bounds. If not specified the size will be equal to `viewport` scaled down to fit into + * 800x800. Optional. + */ + size?: { + /** + * Max screenshot width in pixels. + */ + width: number; + + /** + * Max screenshot height in pixels. + */ + height: number; + }; + + /** + * The quality of the screenshots, between 0-100. Defaults to 90. Optional. + */ + quality?: number; + }; + /** * Whether to capture screenshots during tracing. Screenshots are used to build a timeline preview. */ diff --git a/packages/playwright/src/worker/testTracing.ts b/packages/playwright/src/worker/testTracing.ts index 52487a80aab4e..1daa10ae1ccf5 100644 --- a/packages/playwright/src/worker/testTracing.ts +++ b/packages/playwright/src/worker/testTracing.ts @@ -38,7 +38,7 @@ const version: trace.VERSION = 8; let traceOrdinal = 0; type TraceFixtureValue = PlaywrightWorkerOptions['trace'] | undefined; -type TraceOptions = { screenshots: boolean, snapshots: boolean | { dom?: boolean, aria?: boolean, screen?: boolean }, sources: boolean, attachments: boolean, live: boolean, mode: TraceMode }; +type TraceOptions = { screencast?: boolean | { size?: { width: number, height: number }, quality?: number }, screenshots: boolean, snapshots: boolean | { dom?: boolean, aria?: boolean, screen?: boolean }, sources: boolean, attachments: boolean, live: boolean, mode: TraceMode }; export class TestTracing { private _testInfo: TestInfoImpl; diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index 550c2dc1adc3c..f89fd4c330a8f 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -7191,7 +7191,7 @@ export interface PlaywrightWorkerOptions { * * Learn more about [recording trace](https://playwright.dev/docs/test-use-options#recording-options). */ - trace: TraceMode | /** deprecated */ 'retry-with-trace' | { mode: TraceMode, snapshots?: boolean | { dom?: boolean, aria?: boolean, screen?: boolean }, screenshots?: boolean, sources?: boolean, attachments?: boolean }; + trace: TraceMode | /** deprecated */ 'retry-with-trace' | { mode: TraceMode, snapshots?: boolean | { dom?: boolean, aria?: boolean, screen?: boolean }, screencast?: boolean | { size?: { width: number, height: number }, quality?: number }, screenshots?: boolean, sources?: boolean, attachments?: boolean }; /** * Whether to record video for each test. Defaults to `'off'`. The initial run of a test is the "first run"; * subsequent runs caused by [retries](https://playwright.dev/docs/test-retries) are "retries". diff --git a/packages/protocol/spec/tracing.yml b/packages/protocol/spec/tracing.yml index a33e659cce54a..62bf5ad5a6365 100644 --- a/packages/protocol/spec/tracing.yml +++ b/packages/protocol/spec/tracing.yml @@ -26,6 +26,12 @@ Tracing: snapshotAria: boolean? snapshotScreen: boolean? screencast: boolean? + screencastSize: + type: object? + properties: + width: int + height: int + screencastQuality: int? live: boolean? tracingStartChunk: diff --git a/packages/protocol/src/validator.ts b/packages/protocol/src/validator.ts index cf7181036d41f..97233397aa80d 100644 --- a/packages/protocol/src/validator.ts +++ b/packages/protocol/src/validator.ts @@ -3041,6 +3041,11 @@ scheme.TracingTracingStartParams = tObject({ snapshotAria: tOptional(tBoolean), snapshotScreen: tOptional(tBoolean), screencast: tOptional(tBoolean), + screencastSize: tOptional(tObject({ + width: tInt, + height: tInt, + })), + screencastQuality: tOptional(tInt), live: tOptional(tBoolean), }); scheme.TracingTracingStartResult = tOptional(tObject({})); diff --git a/tests/library/screencast.spec.ts b/tests/library/screencast.spec.ts index d66e3712d459b..f074aac934305 100644 --- a/tests/library/screencast.spec.ts +++ b/tests/library/screencast.spec.ts @@ -253,7 +253,8 @@ test('start should finish when page is closed', async ({ browser }, testInfo) => await context.close(); }); -test('empty video', async ({ browser }, testInfo) => { +test('empty video', async ({ browser, trace }, testInfo) => { + test.skip(trace === 'on', 'tracing keeps the capture running, so the video receives a frame and is not empty'); const size = { width: 800, height: 800 }; const context = await browser.newContext({ viewport: size }); const page = await context.newPage(); @@ -278,6 +279,48 @@ test('start dispose stops recording', async ({ browser }, testInfo) => { await context.close(); }); +test('start size is ignored while tracing is active', async ({ browser, trace }, testInfo) => { + test.skip(trace === 'on', 'the test starts its own tracing'); + test.slow(); + + const context = await browser.newContext({ viewport: { width: 1600, height: 1200 } }); + await context.tracing.start({ screenshots: true }); + const page = await context.newPage(); + + const videoPath = testInfo.outputPath('video.webm'); + // Tracing already captures at the viewport scaled down to 800x600, so this size is ignored. + await page.screencast.start({ path: videoPath, size: { width: 1000, height: 750 } }); + await page.evaluate(() => document.body.style.backgroundColor = 'red'); + await ensureSomeFrames(page); + await page.screencast.stop(); + + await context.tracing.stop(); + await context.close(); + // The video must fill its frame rather than pad the smaller capture with gray. + expectRedFrames(videoPath, { width: 800, height: 600 }); +}); + +test('tracing screenshots size sets the shared capture size', async ({ browser, trace }, testInfo) => { + test.skip(trace === 'on', 'the test starts its own tracing'); + test.slow(); + + // Sizing the tracing screencast is the way to lift the cap it would otherwise put on the capture. + const size = { width: 1000, height: 750 }; + const context = await browser.newContext({ viewport: { width: 1600, height: 1200 } }); + await context.tracing.start({ screencast: { size } }); + const page = await context.newPage(); + + const videoPath = testInfo.outputPath('video.webm'); + await page.screencast.start({ path: videoPath }); + await page.evaluate(() => document.body.style.backgroundColor = 'red'); + await ensureSomeFrames(page); + await page.screencast.stop(); + + await context.tracing.stop(); + await context.close(); + expectRedFrames(videoPath, size); +}); + type Pixel = { r: number, g: number, b: number, alpha: number }; type PixelPredicate = (pixel: Pixel) => boolean; diff --git a/tests/library/tracing.spec.ts b/tests/library/tracing.spec.ts index d0e2464559be0..0f9a165b38170 100644 --- a/tests/library/tracing.spec.ts +++ b/tests/library/tracing.spec.ts @@ -539,6 +539,54 @@ for (const params of [ }); } +browserTest('should produce screencast frames of the requested size', async ({ video, contextFactory, browserName, headless }, testInfo) => { + browserTest.skip(video === 'on', 'Same screencast resolution conflicts'); + browserTest.fixme(browserName === 'firefox' && !headless, 'Image size is different'); + + // Without an explicit size this viewport would be captured at 800x600. + const size = { width: 1000, height: 750 }; + const context = await contextFactory({ viewport: { width: 1600, height: 1200 } }); + await context.tracing.start({ screencast: { size } }); + const page = await context.newPage(); + await page.setContent(``); + for (let i = 0; i < 10; ++i) + await rafraf(page); + await context.tracing.stop({ path: testInfo.outputPath('trace.zip') }); + + const { events, resources } = await parseTraceRaw(testInfo.outputPath('trace.zip')); + const frames = events.filter(e => e.type === 'screencast-frame'); + expect(frames.length).toBeGreaterThan(0); + const image = jpegjs.decode(resources.get(frames[frames.length - 1].file)); + expect(image.width).toBe(size.width); + expect(image.height).toBe(size.height); +}); + +browserTest('should respect screencast quality', async ({ video, contextFactory, browserName }, testInfo) => { + browserTest.skip(video === 'on', 'Same screencast resolution conflicts'); + browserTest.skip(browserName === 'webkit', 'WebKit ignores the screencast quality'); + + const content = `${Array.from({ length: 400 }, (_, i) => + `
`).join('')}`; + + const lastFrameSize = async (quality: number) => { + const context = await contextFactory({ viewport: { width: 800, height: 600 } }); + await context.tracing.start({ screencast: { quality } }); + const page = await context.newPage(); + await page.setContent(content); + for (let i = 0; i < 10; ++i) + await rafraf(page); + const file = testInfo.outputPath(`trace-${quality}.zip`); + await context.tracing.stop({ path: file }); + await context.close(); + const { events, resources } = await parseTraceRaw(file); + const frames = events.filter(e => e.type === 'screencast-frame'); + expect(frames.length).toBeGreaterThan(0); + return resources.get(frames[frames.length - 1].file).byteLength; + }; + + expect(await lastFrameSize(5) * 2).toBeLessThan(await lastFrameSize(95)); +}); + test('should include interrupted actions', async ({ context, page, server }, testInfo) => { await context.tracing.start({ screenshots: true, snapshots: true }); await page.goto(server.EMPTY_PAGE); diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index 6c1b5456c64dc..ee28e42b068aa 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -270,7 +270,7 @@ export interface PlaywrightWorkerOptions { connectOptions: ConnectOptions | undefined; reuseContext: boolean; screenshot: ScreenshotMode | { mode: ScreenshotMode } & Pick; - trace: TraceMode | /** deprecated */ 'retry-with-trace' | { mode: TraceMode, snapshots?: boolean | { dom?: boolean, aria?: boolean, screen?: boolean }, screenshots?: boolean, sources?: boolean, attachments?: boolean }; + trace: TraceMode | /** deprecated */ 'retry-with-trace' | { mode: TraceMode, snapshots?: boolean | { dom?: boolean, aria?: boolean, screen?: boolean }, screencast?: boolean | { size?: { width: number, height: number }, quality?: number }, screenshots?: boolean, sources?: boolean, attachments?: boolean }; video: VideoMode | /** deprecated */ 'retry-with-video' | { mode: VideoMode, size?: ViewportSize, show?: { actions?: { duration?: number, position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number, cursor?: 'none' | 'pointer' }, test?: { level?: 'file' | 'title' | 'step', position?: 'top-left' | 'top' | 'top-right' | 'bottom-left' | 'bottom' | 'bottom-right', fontSize?: number } } }; }