diff --git a/docs/src/api/params.md b/docs/src/api/params.md index c59cb1e920f5e..000f6d929bc2c 100644 --- a/docs/src/api/params.md +++ b/docs/src/api/params.md @@ -1287,7 +1287,7 @@ relative path, then it is resolved relative to the current working directory. If saved to the disk. ## screenshot-option-type -- `type` <[ScreenshotType]<"png"|"jpeg">> +- `type` <[ScreenshotType]<"png"|"jpeg"|"webp">> Specify screenshot type, defaults to `png`. diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index d9902ca08932a..5e7cfe832c6d2 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -12313,7 +12313,7 @@ export interface ElementHandle extends JSHandle { /** * Specify screenshot type, defaults to `png`. */ - type?: "png"|"jpeg"; + type?: "png"|"jpeg"|"webp"; }): Promise; /** @@ -24124,7 +24124,7 @@ export interface LocatorScreenshotOptions { /** * Specify screenshot type, defaults to `png`. */ - type?: "png"|"jpeg"; + type?: "png"|"jpeg"|"webp"; } interface ElementHandleWaitForSelectorOptions { @@ -24797,7 +24797,7 @@ export interface PageScreenshotOptions { /** * Specify screenshot type, defaults to `png`. */ - type?: "png"|"jpeg"; + type?: "png"|"jpeg"|"webp"; } type Devices = { diff --git a/packages/playwright-core/src/client/elementHandle.ts b/packages/playwright-core/src/client/elementHandle.ts index 99e3c555d9411..1d81046381626 100644 --- a/packages/playwright-core/src/client/elementHandle.ts +++ b/packages/playwright-core/src/client/elementHandle.ts @@ -317,13 +317,15 @@ export async function convertInputFiles(platform: Platform, files: string | File return { payloads }; } -export function determineScreenshotType(options: { path?: string, type?: 'png' | 'jpeg' }): 'png' | 'jpeg' | undefined { +export function determineScreenshotType(options: { path?: string, type?: 'png' | 'jpeg' | 'webp' }): 'png' | 'jpeg' | 'webp' | undefined { if (options.path) { const mimeType = getMimeTypeForPath(options.path); if (mimeType === 'image/png') return 'png'; else if (mimeType === 'image/jpeg') return 'jpeg'; + else if (mimeType === 'image/webp') + return 'webp'; throw new Error(`path: unsupported mime type "${mimeType}"`); } return options.type; diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index 656817e780068..78b9bf147d019 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1872,7 +1872,7 @@ scheme.ElementHandleQuerySelectorAllResult = tObject({ }); scheme.ElementHandleScreenshotParams = tObject({ timeout: tFloat, - type: tOptional(tEnum(['png', 'jpeg'])), + type: tOptional(tEnum(['png', 'jpeg', 'webp'])), quality: tOptional(tInt), omitBackground: tOptional(tBoolean), caret: tOptional(tEnum(['hide', 'initial'])), @@ -2430,7 +2430,7 @@ scheme.PageExpectScreenshotResult = tObject({ }); scheme.PageScreenshotParams = tObject({ timeout: tFloat, - type: tOptional(tEnum(['png', 'jpeg'])), + type: tOptional(tEnum(['png', 'jpeg', 'webp'])), quality: tOptional(tInt), fullPage: tOptional(tBoolean), clip: tOptional(tType('Rect')), diff --git a/packages/playwright-core/src/server/bidi/bidiPage.ts b/packages/playwright-core/src/server/bidi/bidiPage.ts index 6637b0d46ee96..49e34a88628a6 100644 --- a/packages/playwright-core/src/server/bidi/bidiPage.ts +++ b/packages/playwright-core/src/server/bidi/bidiPage.ts @@ -479,6 +479,8 @@ export class BidiPage implements PageDelegate { } async takeScreenshot(progress: Progress, format: string, documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, scale: 'css' | 'device'): Promise { + if (format === 'webp') + throw new Error('webp screenshots are not supported via WebDriver BiDi'); const rect = (documentRect || viewportRect)!; const { data } = await progress.race(this._session.send('browsingContext.captureScreenshot', { context: this._session.sessionId, diff --git a/packages/playwright-core/src/server/chromium/crPage.ts b/packages/playwright-core/src/server/chromium/crPage.ts index 19556b0536e8e..5fd57f88fce63 100644 --- a/packages/playwright-core/src/server/chromium/crPage.ts +++ b/packages/playwright-core/src/server/chromium/crPage.ts @@ -247,7 +247,7 @@ export class CRPage implements PageDelegate { await this._mainFrameSession._client.send('Emulation.setDefaultBackgroundColorOverride', { color }); } - async takeScreenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, scale: 'css' | 'device'): Promise { + async takeScreenshot(progress: Progress, format: 'png' | 'jpeg' | 'webp', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, scale: 'css' | 'device'): Promise { const { visualViewport, contentSize, cssContentSize } = await progress.race(this._mainFrameSession._client.send('Page.getLayoutMetrics')); if (!documentRect) { documentRect = { diff --git a/packages/playwright-core/src/server/firefox/ffPage.ts b/packages/playwright-core/src/server/firefox/ffPage.ts index c269a97763069..301c81c3a4e79 100644 --- a/packages/playwright-core/src/server/firefox/ffPage.ts +++ b/packages/playwright-core/src/server/firefox/ffPage.ts @@ -455,7 +455,7 @@ export class FFPage implements PageDelegate { throw new Error('Not implemented'); } - async takeScreenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, scale: 'css' | 'device'): Promise { + async takeScreenshot(progress: Progress, format: 'png' | 'jpeg' | 'webp', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, scale: 'css' | 'device'): Promise { if (!documentRect) { const scrollOffset = await this._page.mainFrame().waitForFunctionValueInUtility(progress, () => ({ x: window.scrollX, y: window.scrollY })); documentRect = { @@ -466,7 +466,7 @@ export class FFPage implements PageDelegate { }; } const { data } = await progress.race(this._session.send('Page.screenshot', { - mimeType: ('image/' + format) as ('image/png' | 'image/jpeg'), + mimeType: ('image/' + format) as ('image/png' | 'image/jpeg' | 'image/webp'), clip: documentRect, quality, omitDeviceScaleFactor: scale === 'css', diff --git a/packages/playwright-core/src/server/screenshotter.ts b/packages/playwright-core/src/server/screenshotter.ts index c29e9e0b7c5e7..bccd5dde0ca44 100644 --- a/packages/playwright-core/src/server/screenshotter.ts +++ b/packages/playwright-core/src/server/screenshotter.ts @@ -35,7 +35,7 @@ declare global { } export type ScreenshotOptions = { - type?: 'png' | 'jpeg'; + type?: 'png' | 'jpeg' | 'webp'; quality?: number; omitBackground?: boolean; animations?: 'disabled' | 'allow'; @@ -297,7 +297,7 @@ export class Screenshotter { } } - private async _screenshot(progress: Progress, format: 'png' | 'jpeg', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, fitsViewport: boolean, options: ScreenshotOptions): Promise { + private async _screenshot(progress: Progress, format: 'png' | 'jpeg' | 'webp', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, fitsViewport: boolean, options: ScreenshotOptions): Promise { if ((options as any).__testHookBeforeScreenshot) await progress.race((options as any).__testHookBeforeScreenshot()); @@ -307,7 +307,7 @@ export class Screenshotter { const cleanupHighlight = await this._maskElements(progress, options); try { - const quality = format === 'jpeg' ? options.quality ?? 80 : undefined; + const quality = format === 'jpeg' ? options.quality ?? 80 : format === 'webp' ? options.quality : undefined; const buffer = await this._page.delegate.takeScreenshot(progress, format, documentRect, viewportRect, quality, fitsViewport, options.scale || 'device'); await progress.race(cleanupHighlight()); if (shouldSetDefaultBackground) @@ -353,12 +353,12 @@ function trimClipToSize(clip: types.Rect, size: types.Size): types.Rect { return result; } -export function validateScreenshotOptions(options: ScreenshotOptions): 'png' | 'jpeg' { - let format: 'png' | 'jpeg' | null = null; +export function validateScreenshotOptions(options: ScreenshotOptions): 'png' | 'jpeg' | 'webp' { + let format: 'png' | 'jpeg' | 'webp' | null = null; // options.type takes precedence over inferring the type from options.path // because it may be a 0-length file with no extension created beforehand (i.e. as a temp file). if (options.type) { - assert(options.type === 'png' || options.type === 'jpeg', 'Unknown options.type value: ' + options.type); + assert(options.type === 'png' || options.type === 'jpeg' || options.type === 'webp', 'Unknown options.type value: ' + options.type); format = options.type; } @@ -366,7 +366,7 @@ export function validateScreenshotOptions(options: ScreenshotOptions): 'png' | ' format = 'png'; if (options.quality !== undefined) { - assert(format === 'jpeg', 'options.quality is unsupported for the ' + format + ' screenshots'); + assert(format !== 'png', 'options.quality is unsupported for the ' + format + ' screenshots'); assert(typeof options.quality === 'number', 'Expected options.quality to be a number but found ' + (typeof options.quality)); assert(Number.isInteger(options.quality), 'Expected options.quality to be an integer'); assert(options.quality >= 0 && options.quality <= 100, 'Expected options.quality to be between 0 and 100 (inclusive), got ' + options.quality); diff --git a/packages/playwright-core/src/server/webkit/wkPage.ts b/packages/playwright-core/src/server/webkit/wkPage.ts index 6a2540390e809..0f027d24939ad 100644 --- a/packages/playwright-core/src/server/webkit/wkPage.ts +++ b/packages/playwright-core/src/server/webkit/wkPage.ts @@ -15,8 +15,6 @@ * limitations under the License. */ -import { PNG } from 'pngjs'; -import jpegjs from 'jpeg-js'; import { headersArrayToObject, headersObjectToArray } from '@isomorphic/headers'; import { splitErrorMessage } from '@isomorphic/stackTrace'; import { eventsHelper } from '@utils/eventsHelper'; @@ -870,16 +868,15 @@ export class WKPage implements PageDelegate { } async takeScreenshot(progress: Progress, format: string, documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, quality: number | undefined, fitsViewport: boolean, scale: 'css' | 'device'): Promise { + if (format === 'webp' && process.platform === 'darwin') + throw new Error('webp screenshots are not supported in WebKit on macOS'); const rect = (documentRect || viewportRect)!; const omitDeviceScaleFactor = scale === 'css'; this.validateScreenshotDimension(rect.width, omitDeviceScaleFactor); this.validateScreenshotDimension(rect.height, omitDeviceScaleFactor); - const result = await progress.race(this._session.send('Page.snapshotRect', { ...rect, coordinateSystem: documentRect ? 'Page' : 'Viewport', omitDeviceScaleFactor })); - const prefix = 'data:image/png;base64,'; - let buffer: Buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64'); - if (format === 'jpeg') - buffer = jpegjs.encode(PNG.sync.read(buffer), quality).data; - return buffer; + const result = await progress.race(this._session.send('Page.snapshotRect', { ...rect, coordinateSystem: documentRect ? 'Page' : 'Viewport', omitDeviceScaleFactor, format: format as 'png' | 'jpeg' | 'webp', quality })); + // Strip 'data:image/png;base64,' prefix. + return Buffer.from(result.dataURL.substring(result.dataURL.indexOf(',') + 1), 'base64'); } async getContentFrame(handle: dom.ElementHandle): Promise { diff --git a/packages/playwright-core/src/tools/backend/response.ts b/packages/playwright-core/src/tools/backend/response.ts index db10fb696f5cd..a562e1dc984e8 100644 --- a/packages/playwright-core/src/tools/backend/response.ts +++ b/packages/playwright-core/src/tools/backend/response.ts @@ -58,7 +58,7 @@ export class Response { readonly toolName: string; readonly toolArgs: Record; private _clientWorkspace: string; - private _imageResults: { data: Buffer, imageType: 'png' | 'jpeg' }[] = []; + private _imageResults: { data: Buffer, imageType: 'png' | 'jpeg' | 'webp' }[] = []; private _raw: boolean; private _json: boolean; private _writtenFiles = new Set(); @@ -127,7 +127,7 @@ export class Response { this.addTextResult(`- [${title}](${relativeName})`); } - async registerImageResult(data: Buffer, imageType: 'png' | 'jpeg') { + async registerImageResult(data: Buffer, imageType: 'png' | 'jpeg' | 'webp') { this._imageResults.push({ data, imageType }); } @@ -219,7 +219,7 @@ export class Response { if (this._context.config.imageResponses !== 'omit') { for (const imageResult of this._imageResults) { const scaledData = scaleImageToFitMessage(imageResult.data, imageResult.imageType); - content.push({ type: 'image', data: scaledData.toString('base64'), mimeType: imageResult.imageType === 'png' ? 'image/png' : 'image/jpeg' }); + content.push({ type: 'image', data: scaledData.toString('base64'), mimeType: `image/${imageResult.imageType}` }); } } diff --git a/packages/playwright-core/src/tools/backend/screenshot.ts b/packages/playwright-core/src/tools/backend/screenshot.ts index d2aeea504a2d3..69f61eaca229d 100644 --- a/packages/playwright-core/src/tools/backend/screenshot.ts +++ b/packages/playwright-core/src/tools/backend/screenshot.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import path from 'path'; + import jpegjs from 'jpeg-js'; import { PNG } from 'pngjs'; import * as z from 'zod'; @@ -25,12 +27,26 @@ import { optionalElementSchema } from './snapshot'; import type * as playwright from '../../..'; +type ImageFormat = 'png' | 'jpeg' | 'webp'; + const screenshotSchema = optionalElementSchema.extend({ - type: z.enum(['png', 'jpeg']).default('png').describe('Image format for the screenshot. Default is png.'), - filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified. Prefer relative file names to stay within the output directory.'), + type: z.enum(['png', 'jpeg', 'webp']).optional().describe('Image format for the screenshot. If unset, inferred from the filename extension, otherwise png.'), + filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg|webp}` if not specified. Prefer relative file names to stay within the output directory.'), fullPage: z.boolean().optional().describe('When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Cannot be used with element screenshots.'), }); +function inferTypeFromFilename(filename: string | undefined): ImageFormat | undefined { + if (!filename) + return undefined; + switch (path.extname(filename).toLowerCase()) { + case '.png': return 'png'; + case '.jpg': + case '.jpeg': return 'jpeg'; + case '.webp': return 'webp'; + } + return undefined; +} + const screenshot = defineTabTool({ capability: 'core', schema: { @@ -45,10 +61,10 @@ const screenshot = defineTabTool({ if (params.fullPage && params.target) throw new Error('fullPage cannot be used with element screenshots.'); - const fileType = params.type || 'png'; + const fileType: ImageFormat = params.type ?? inferTypeFromFilename(params.filename) ?? 'png'; const options: playwright.PageScreenshotOptions = { type: fileType, - quality: fileType === 'png' ? undefined : 90, + quality: fileType === 'jpeg' ? 90 : undefined, scale: 'css', ...tab.actionTimeoutOptions, ...(params.fullPage !== undefined && { fullPage: params.fullPage }) @@ -72,10 +88,14 @@ const screenshot = defineTabTool({ } }); -export function scaleImageToFitMessage(buffer: Buffer, imageType: 'png' | 'jpeg'): Buffer { +export function scaleImageToFitMessage(buffer: Buffer, imageType: 'png' | 'jpeg' | 'webp'): Buffer { // https://docs.claude.com/en/docs/build-with-claude/vision#evaluate-image-size // Not more than 1.15 megapixel, linear size not more than 1568. + // No Node-side webp decoder is bundled, so we can't scale webp screenshots. + if (imageType === 'webp') + return buffer; + const image = imageType === 'png' ? PNG.sync.read(buffer) : jpegjs.decode(buffer, { maxMemoryUsageInMB: 512 }); const pixels = image.width * image.height; diff --git a/packages/playwright-core/src/tools/cli-daemon/commands.ts b/packages/playwright-core/src/tools/cli-daemon/commands.ts index 689f7b0e358a9..2fc6cce6ebc7a 100644 --- a/packages/playwright-core/src/tools/cli-daemon/commands.ts +++ b/packages/playwright-core/src/tools/cli-daemon/commands.ts @@ -789,11 +789,12 @@ const screenshot = declareCommand({ target: z.string().optional().describe(elementTargetDescription), }), options: z.object({ - filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg}` if not specified.'), + filename: z.string().optional().describe('File name to save the screenshot to. Defaults to `page-{timestamp}.{png|jpeg|webp}` if not specified.'), + type: z.enum(['png', 'jpeg', 'webp']).optional().describe('Image format. If unset, inferred from the filename extension, otherwise png.'), ['full-page']: z.boolean().optional().describe('When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport.'), }), toolName: 'browser_take_screenshot', - toolParams: ({ target, filename, ['full-page']: fullPage }) => ({ filename, target, fullPage }), + toolParams: ({ target, filename, type, ['full-page']: fullPage }) => ({ filename, target, type, fullPage }), }); const pdfSave = declareCommand({ diff --git a/packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts b/packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts index 0074b5d5cdf7d..7590700a16382 100644 --- a/packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts +++ b/packages/playwright-core/src/tools/cli-daemon/helpGenerator.ts @@ -56,7 +56,10 @@ function generateCommandHelp(command: AnyCommandSchema) { const optionsShape = (command.options as zodType.ZodObject).shape; for (const [name, schema] of Object.entries(optionsShape)) { const zodSchema = schema as zodType.ZodTypeAny; - const description = (zodSchema.description ?? '').toLowerCase(); + let description = (zodSchema.description ?? '').toLowerCase(); + const unwrapped = unwrapZodType(zodSchema); + if (unwrapped instanceof z.ZodEnum) + description = `${description} (one of: ${unwrapped.options.join(', ')})`.trim(); lines.push(formatWithGap(` --${name}`, description)); } } diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index d9902ca08932a..5e7cfe832c6d2 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -12313,7 +12313,7 @@ export interface ElementHandle extends JSHandle { /** * Specify screenshot type, defaults to `png`. */ - type?: "png"|"jpeg"; + type?: "png"|"jpeg"|"webp"; }): Promise; /** @@ -24124,7 +24124,7 @@ export interface LocatorScreenshotOptions { /** * Specify screenshot type, defaults to `png`. */ - type?: "png"|"jpeg"; + type?: "png"|"jpeg"|"webp"; } interface ElementHandleWaitForSelectorOptions { @@ -24797,7 +24797,7 @@ export interface PageScreenshotOptions { /** * Specify screenshot type, defaults to `png`. */ - type?: "png"|"jpeg"; + type?: "png"|"jpeg"|"webp"; } type Devices = { diff --git a/packages/protocol/spec/handles.yml b/packages/protocol/spec/handles.yml index 1a4a77fb4c693..61fbee34e878c 100644 --- a/packages/protocol/spec/handles.yml +++ b/packages/protocol/spec/handles.yml @@ -405,6 +405,7 @@ ElementHandle: literals: - png - jpeg + - webp quality: int? $mixin: CommonScreenshotOptions returns: diff --git a/packages/protocol/spec/page.yml b/packages/protocol/spec/page.yml index c1b077c04bf07..4091f0cfa39b5 100644 --- a/packages/protocol/spec/page.yml +++ b/packages/protocol/spec/page.yml @@ -210,6 +210,7 @@ Page: literals: - png - jpeg + - webp quality: int? fullPage: boolean? clip: Rect? diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 02409015ca5a4..12d2b8bcfbc0e 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -3377,7 +3377,7 @@ export type ElementHandleQuerySelectorAllResult = { }; export type ElementHandleScreenshotParams = { timeout: number, - type?: 'png' | 'jpeg', + type?: 'png' | 'jpeg' | 'webp', quality?: number, omitBackground?: boolean, caret?: 'hide' | 'initial', @@ -3391,7 +3391,7 @@ export type ElementHandleScreenshotParams = { style?: string, }; export type ElementHandleScreenshotOptions = { - type?: 'png' | 'jpeg', + type?: 'png' | 'jpeg' | 'webp', quality?: number, omitBackground?: boolean, caret?: 'hide' | 'initial', @@ -4334,7 +4334,7 @@ export type PageExpectScreenshotResult = { }; export type PageScreenshotParams = { timeout: number, - type?: 'png' | 'jpeg', + type?: 'png' | 'jpeg' | 'webp', quality?: number, fullPage?: boolean, clip?: Rect, @@ -4350,7 +4350,7 @@ export type PageScreenshotParams = { style?: string, }; export type PageScreenshotOptions = { - type?: 'png' | 'jpeg', + type?: 'png' | 'jpeg' | 'webp', quality?: number, fullPage?: boolean, clip?: Rect, diff --git a/tests/mcp/cli-save-as.spec.ts b/tests/mcp/cli-save-as.spec.ts index b566c0c022d9c..c1b57c6d7cb80 100644 --- a/tests/mcp/cli-save-as.spec.ts +++ b/tests/mcp/cli-save-as.spec.ts @@ -46,6 +46,17 @@ test('screenshot --filename', async ({ cli, server, mcpBrowser }) => { expect(attachments[0].data).toEqual(expect.any(Buffer)); }); +test('screenshot --filename infers webp from extension', async ({ cli, server, mcpBrowser }) => { + test.skip(mcpBrowser === 'webkit' && process.platform === 'darwin', 'CG on macOS does not include a webp encoder UTI'); + + await cli('open', server.HELLO_WORLD); + const { output, attachments } = await cli('screenshot', '--filename=screenshot.webp'); + expect(output).toContain('[Screenshot of viewport](./screenshot.webp)'); + const buffer = attachments[0].data as Buffer; + expect(buffer.subarray(0, 4).toString('latin1')).toBe('RIFF'); + expect(buffer.subarray(8, 12).toString('latin1')).toBe('WEBP'); +}); + test('pdf', async ({ cli, server, mcpBrowser }) => { test.skip(mcpBrowser !== 'chromium' && mcpBrowser !== 'chrome', 'PDF is only supported in Chromium and Chrome'); await cli('open', server.HELLO_WORLD); diff --git a/tests/mcp/screenshot.spec.ts b/tests/mcp/screenshot.spec.ts index faa0d7d5eec73..294a54b8e343f 100644 --- a/tests/mcp/screenshot.spec.ts +++ b/tests/mcp/screenshot.spec.ts @@ -96,8 +96,10 @@ test('--output-dir should work', async ({ startClient, server }, testInfo) => { expect(files[0]).toMatch(/^page-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z\.png$/); }); -for (const type of ['png', 'jpeg']) { - test(`browser_take_screenshot (type: ${type})`, async ({ startClient, server }, testInfo) => { +for (const type of ['png', 'jpeg', 'webp']) { + test(`browser_take_screenshot (type: ${type})`, async ({ startClient, server, mcpBrowser }, testInfo) => { + test.skip(type === 'webp' && mcpBrowser === 'webkit' && process.platform === 'darwin', 'CG on macOS does not include a webp encoder UTI'); + const outputDir = testInfo.outputPath('output'); const { client } = await startClient({ config: { outputDir }, diff --git a/tests/page/page-screenshot.spec.ts b/tests/page/page-screenshot.spec.ts index a2f7b77b3c8a5..aa647f66353d7 100644 --- a/tests/page/page-screenshot.spec.ts +++ b/tests/page/page-screenshot.spec.ts @@ -266,6 +266,36 @@ it.describe('page screenshot', () => { expect(screenshot).toMatchSnapshot('white.jpg'); }); + it('should produce a valid webp screenshot', async ({ page, server, isBidi, browserName, platform }) => { + it.skip(isBidi, 'webp screenshots are not supported via WebDriver BiDi'); + it.skip(browserName === 'webkit' && platform === 'darwin', 'CG on macOS does not include a webp encoder UTI'); + + await page.setViewportSize({ width: 300, height: 300 }); + await page.goto(server.EMPTY_PAGE); + await page.evaluate(() => (document.body.style.background = 'rgb(255, 0, 0)')); + const screenshot = await page.screenshot({ type: 'webp' }); + // Valid WebP container: "RIFF" "WEBP". + expect(screenshot.subarray(0, 4).toString('latin1')).toBe('RIFF'); + expect(screenshot.subarray(8, 12).toString('latin1')).toBe('WEBP'); + // Decode it back in the page to confirm it carries the rendered content. + const pixel = await page.evaluate(async base64 => { + const img = new Image(); + img.src = 'data:image/webp;base64,' + base64; + await img.decode(); + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const context = canvas.getContext('2d')!; + context.drawImage(img, 0, 0); + const data = context.getImageData(canvas.width >> 1, canvas.height >> 1, 1, 1).data; + return { width: img.naturalWidth, r: data[0], g: data[1], b: data[2] }; + }, screenshot.toString('base64')); + expect(pixel.width).toBeGreaterThan(0); + expect(pixel.r).toBeGreaterThan(200); + expect(pixel.g).toBeLessThan(60); + expect(pixel.b).toBeLessThan(60); + }); + it('should work with odd clip size on Retina displays', async ({ page, isElectron }) => { it.fixme(isElectron, 'Scale is wrong');