feat(screenshot): add webp output format (#41152)

This commit is contained in:
Yury Semikhatsky
2026-07-09 18:02:10 -07:00
committed by GitHub
parent 32e8fd9846
commit f3114b3e9f
43 changed files with 824 additions and 68 deletions
+3 -2
View File
@@ -1303,7 +1303,8 @@ Defaults to `false`.
## screenshot-option-quality
- `quality` <[int]>
The quality of the image, between 0-100. Not applicable to `png` images.
The quality of the image, between 0-100. Not applicable to `png` images. For `jpeg` the default is `80`.
For `webp`, a quality of `100` (the default) produces a lossless image, while lower values use lossy compression.
## screenshot-option-path
- `path` <[path]>
@@ -1313,7 +1314,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`.
+1
View File
@@ -48,6 +48,7 @@ const ignores = [
"examples",
"packages/*/lib/",
"packages/playwright-core/bundles/utils/src/third_party/",
"packages/utils/webp/webp_codec.js",
"packages/playwright-core/src/generated/*",
"packages/playwright-core/src/third_party/",
"packages/playwright-core/types/*",
+9 -6
View File
@@ -13176,7 +13176,8 @@ export interface ElementHandle<T=Node> extends JSHandle<T> {
path?: string;
/**
* The quality of the image, between 0-100. Not applicable to `png` images.
* The quality of the image, between 0-100. Not applicable to `png` images. For `jpeg` the default is `80`. For
* `webp`, a quality of `100` (the default) produces a lossless image, while lower values use lossy compression.
*/
quality?: number;
@@ -13218,7 +13219,7 @@ export interface ElementHandle<T=Node> extends JSHandle<T> {
/**
* Specify screenshot type, defaults to `png`.
*/
type?: "png"|"jpeg";
type?: "png"|"jpeg"|"webp";
}): Promise<Buffer>;
/**
@@ -25108,7 +25109,8 @@ export interface LocatorScreenshotOptions {
path?: string;
/**
* The quality of the image, between 0-100. Not applicable to `png` images.
* The quality of the image, between 0-100. Not applicable to `png` images. For `jpeg` the default is `80`. For
* `webp`, a quality of `100` (the default) produces a lossless image, while lower values use lossy compression.
*/
quality?: number;
@@ -25150,7 +25152,7 @@ export interface LocatorScreenshotOptions {
/**
* Specify screenshot type, defaults to `png`.
*/
type?: "png"|"jpeg";
type?: "png"|"jpeg"|"webp";
}
interface ElementHandleWaitForSelectorOptions {
@@ -25825,7 +25827,8 @@ export interface PageScreenshotOptions {
path?: string;
/**
* The quality of the image, between 0-100. Not applicable to `png` images.
* The quality of the image, between 0-100. Not applicable to `png` images. For `jpeg` the default is `80`. For
* `webp`, a quality of `100` (the default) produces a lossless image, while lower values use lossy compression.
*/
quality?: number;
@@ -25867,7 +25870,7 @@ export interface PageScreenshotOptions {
/**
* Specify screenshot type, defaults to `png`.
*/
type?: "png"|"jpeg";
type?: "png"|"jpeg"|"webp";
}
type Devices = {
+2
View File
@@ -14,6 +14,8 @@
!lib/**/*.ttf
!lib/**/*.json
!lib/**/*.md
# Include the WebP codec WASM binary (see @utils/webp/webp).
!lib/**/*.wasm
!lib/xdg-open
!lib/**/manifest.webmanifest
# Exclude injected files. A preprocessed version of these is included via lib/generated.
+6 -4
View File
@@ -3332,7 +3332,7 @@ export type ElementHandleQuerySelectorAllResult = {
};
export type ElementHandleScreenshotParams = {
timeout: number,
type?: 'png' | 'jpeg',
type?: 'png' | 'jpeg' | 'webp',
quality?: number,
omitBackground?: boolean,
caret?: 'hide' | 'initial',
@@ -3346,7 +3346,7 @@ export type ElementHandleScreenshotParams = {
style?: string,
};
export type ElementHandleScreenshotOptions = {
type?: 'png' | 'jpeg',
type?: 'png' | 'jpeg' | 'webp',
quality?: number,
omitBackground?: boolean,
caret?: 'hide' | 'initial',
@@ -4192,6 +4192,7 @@ export type PageExpectScreenshotParams = {
threshold?: number,
fullPage?: boolean,
clip?: Rect,
type?: 'png' | 'webp',
omitBackground?: boolean,
caret?: 'hide' | 'initial',
animations?: 'disabled' | 'allow',
@@ -4215,6 +4216,7 @@ export type PageExpectScreenshotOptions = {
threshold?: number,
fullPage?: boolean,
clip?: Rect,
type?: 'png' | 'webp',
omitBackground?: boolean,
caret?: 'hide' | 'initial',
animations?: 'disabled' | 'allow',
@@ -4239,7 +4241,7 @@ export type PageExpectScreenshotErrorDetails = {
};
export type PageScreenshotParams = {
timeout: number,
type?: 'png' | 'jpeg',
type?: 'png' | 'jpeg' | 'webp',
quality?: number,
fullPage?: boolean,
clip?: Rect,
@@ -4255,7 +4257,7 @@ export type PageScreenshotParams = {
style?: string,
};
export type PageScreenshotOptions = {
type?: 'png' | 'jpeg',
type?: 'png' | 'jpeg' | 'webp',
quality?: number,
fullPage?: boolean,
clip?: Rect,
@@ -320,13 +320,15 @@ export async function convertInputFiles(files: string | FilePayload | string[] |
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;
@@ -493,6 +493,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<Buffer> {
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,
+6 -4
View File
@@ -3335,7 +3335,7 @@ export type ElementHandleQuerySelectorAllResult = {
};
export type ElementHandleScreenshotParams = {
timeout: number,
type?: 'png' | 'jpeg',
type?: 'png' | 'jpeg' | 'webp',
quality?: number,
omitBackground?: boolean,
caret?: 'hide' | 'initial',
@@ -3349,7 +3349,7 @@ export type ElementHandleScreenshotParams = {
style?: string,
};
export type ElementHandleScreenshotOptions = {
type?: 'png' | 'jpeg',
type?: 'png' | 'jpeg' | 'webp',
quality?: number,
omitBackground?: boolean,
caret?: 'hide' | 'initial',
@@ -4195,6 +4195,7 @@ export type PageExpectScreenshotParams = {
threshold?: number,
fullPage?: boolean,
clip?: Rect,
type?: 'png' | 'webp',
omitBackground?: boolean,
caret?: 'hide' | 'initial',
animations?: 'disabled' | 'allow',
@@ -4218,6 +4219,7 @@ export type PageExpectScreenshotOptions = {
threshold?: number,
fullPage?: boolean,
clip?: Rect,
type?: 'png' | 'webp',
omitBackground?: boolean,
caret?: 'hide' | 'initial',
animations?: 'disabled' | 'allow',
@@ -4242,7 +4244,7 @@ export type PageExpectScreenshotErrorDetails = {
};
export type PageScreenshotParams = {
timeout: number,
type?: 'png' | 'jpeg',
type?: 'png' | 'jpeg' | 'webp',
quality?: number,
fullPage?: boolean,
clip?: Rect,
@@ -4258,7 +4260,7 @@ export type PageScreenshotParams = {
style?: string,
};
export type PageScreenshotOptions = {
type?: 'png' | 'jpeg',
type?: 'png' | 'jpeg' | 'webp',
quality?: number,
fullPage?: boolean,
clip?: Rect,
@@ -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<Buffer> {
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<Buffer> {
const { visualViewport, contentSize, cssContentSize } = await progress.race(this._mainFrameSession._client.send('Page.getLayoutMetrics'));
if (!documentRect) {
documentRect = {
@@ -459,7 +459,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<Buffer> {
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<Buffer> {
if (!documentRect) {
const scrollOffset = await this._page.mainFrame().waitForFunctionValueInUtility(progress, () => ({ x: window.scrollX, y: window.scrollY }));
documentRect = {
@@ -470,7 +470,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',
+9 -11
View File
@@ -720,28 +720,26 @@ export class Page extends SdkObject<PageEventMap> {
return await this.screenshotter.screenshotPage(progress, options || {});
};
const comparator = getComparator('image/png');
let intermediateResult: {
actual?: Buffer,
previous?: Buffer,
errorMessage: string,
diff?: Buffer,
} | undefined;
const areEqualScreenshots = (actual: Buffer | undefined, expected: Buffer | undefined, previous: Buffer | undefined) => {
const comparatorResult = actual && expected ? comparator(actual, expected, options) : undefined;
if (comparatorResult !== undefined && !!comparatorResult === !!options.isNot)
return true;
if (comparatorResult)
intermediateResult = { errorMessage: comparatorResult.errorMessage, diff: comparatorResult.diff, actual, previous };
return false;
};
try {
if (!options.expected && options.isNot)
throw new Error('"not" matcher requires expected result');
const format = validateScreenshotOptions(options || {});
if (format !== 'png')
throw new Error('Only PNG screenshots are supported');
const comparator = getComparator(`image/${format}`);
const areEqualScreenshots = (actual: Buffer | undefined, expected: Buffer | undefined, previous: Buffer | undefined) => {
const comparatorResult = actual && expected ? comparator(actual, expected, options) : undefined;
if (comparatorResult !== undefined && !!comparatorResult === !!options.isNot)
return true;
if (comparatorResult)
intermediateResult = { errorMessage: comparatorResult.errorMessage, diff: comparatorResult.diff, actual, previous };
return false;
};
let actual: Buffer | undefined;
let previous: Buffer | undefined;
const pollIntervals = [0, 100, 250, 500];
@@ -33,7 +33,7 @@ declare global {
}
export type ScreenshotOptions = {
type?: 'png' | 'jpeg';
type?: 'png' | 'jpeg' | 'webp';
quality?: number;
omitBackground?: boolean;
animations?: 'disabled' | 'allow';
@@ -288,7 +288,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<Buffer> {
private async _screenshot(progress: Progress, format: 'png' | 'jpeg' | 'webp', documentRect: types.Rect | undefined, viewportRect: types.Rect | undefined, fitsViewport: boolean, options: ScreenshotOptions): Promise<Buffer> {
if ((options as any).__testHookBeforeScreenshot)
await progress.race((options as any).__testHookBeforeScreenshot());
@@ -298,7 +298,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 ?? 100 : undefined;
const buffer = await this._page.delegate.takeScreenshot(progress, format, documentRect, viewportRect, quality, fitsViewport, options.scale || 'device');
await progress.race(cleanupHighlight());
if (shouldSetDefaultBackground)
@@ -344,12 +344,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;
}
@@ -357,7 +357,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);
@@ -23,6 +23,7 @@ import { ManualPromise } from '@isomorphic/manualPromise';
import { splitErrorMessage } from '@utils/stackTrace';
import { debugLogger } from '@utils/debugLogger';
import { eventsHelper } from '@utils/eventsHelper';
import { encodeWebp } from '@utils/webp/webp';
import * as dialog from '../../dialog';
import * as dom from '../../dom';
import { TargetClosedError } from '../../errors';
@@ -767,8 +768,13 @@ export class WVPage implements PageDelegate {
const result = await progress.race(this._session.send('Page.snapshotRect', { ...rect, coordinateSystem: documentRect ? 'Page' : 'Viewport' }));
const prefix = 'data:image/png;base64,';
let buffer: Buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64');
if (format === 'jpeg')
if (format === 'jpeg') {
buffer = jpegjs.encode(PNG.sync.read(buffer), quality).data;
} else if (format === 'webp') {
const png = PNG.sync.read(buffer);
// Match the native WebKit encoder: webp quality 100 (or omitted) is lossless.
buffer = (quality === undefined || quality >= 100) ? encodeWebp(png, { lossless: true }) : encodeWebp(png, { quality });
}
return buffer;
}
@@ -15,10 +15,13 @@
* limitations under the License.
*/
import { PNG } from 'pngjs';
import { headersArrayToObject, headersObjectToArray } from '@isomorphic/headers';
import { splitErrorMessage } from '@utils/stackTrace';
import { eventsHelper } from '@utils/eventsHelper';
import { hostPlatform } from '@utils/hostPlatform';
import { encodeWebp } from '@utils/webp/webp';
import { assert } from '@isomorphic/assert';
import * as dialog from '../dialog';
import * as dom from '../dom';
@@ -868,9 +871,18 @@ export class WKPage implements PageDelegate {
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, format: format as 'png' | 'jpeg', quality }));
// WebKit on macOS has no built-in WebP encoder, so capture a PNG and re-encode it.
const recodePngToWebp = format === 'webp' && process.platform === 'darwin';
const result = await progress.race(this._session.send('Page.snapshotRect', { ...rect, coordinateSystem: documentRect ? 'Page' : 'Viewport', omitDeviceScaleFactor, format: (recodePngToWebp ? 'png' : format) as 'png' | 'jpeg' | 'webp', quality: recodePngToWebp ? undefined : quality }));
// Strip the 'data:image/<format>;base64,' prefix.
return Buffer.from(result.dataURL.substring(result.dataURL.indexOf(',') + 1), 'base64');
const buffer = Buffer.from(result.dataURL.substring(result.dataURL.indexOf(',') + 1), 'base64');
if (recodePngToWebp) {
const png = PNG.sync.read(buffer);
const image = { width: png.width, height: png.height, data: png.data };
// Match the native WebKit encoder: webp quality 100 (or omitted) is lossless.
return (quality === undefined || quality >= 100) ? encodeWebp(image, { lossless: true }) : encodeWebp(image, { quality });
}
return buffer;
}
async getContentFrame(handle: dom.ElementHandle): Promise<frames.Frame | null> {
@@ -58,7 +58,7 @@ export class Response {
readonly toolName: string;
readonly toolArgs: Record<string, any>;
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<string>();
@@ -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 });
}
@@ -209,7 +209,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}` });
}
}
@@ -14,24 +14,41 @@
* limitations under the License.
*/
import path from 'path';
import jpegjs from 'jpeg-js';
import { PNG } from 'pngjs';
import * as z from 'zod';
import { formatObject } from '@isomorphic/stringUtils';
import { scaleImageToSize } from '@isomorphic/imageUtils';
import { decodeWebp, encodeWebp, isLosslessWebp } from '@utils/webp/webp';
import { defineTabTool } from './tool';
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.'),
scale: z.enum(['css', 'device']).default('css').describe('Image resolution scale. "css" produces a screenshot sized in CSS pixels (smaller, consistent across devices). "device" produces a high-resolution screenshot using device pixels (larger, accounts for the device pixel ratio). Default is css.'),
});
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: {
@@ -46,10 +63,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: params.scale,
...tab.actionTimeoutOptions,
...(params.fullPage !== undefined && { fullPage: params.fullPage })
@@ -73,11 +90,18 @@ 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.
const image = imageType === 'png' ? PNG.sync.read(buffer) : jpegjs.decode(buffer, { maxMemoryUsageInMB: 512 });
const decode = () => {
if (imageType === 'png')
return PNG.sync.read(buffer);
if (imageType === 'webp')
return decodeWebp(buffer);
return jpegjs.decode(buffer, { maxMemoryUsageInMB: 512 });
};
const image = decode();
const pixels = image.width * image.height;
const shrink = Math.min(1568 / image.width, 1568 / image.height, Math.sqrt(1.15 * 1024 * 1024 / pixels));
@@ -87,6 +111,8 @@ export function scaleImageToFitMessage(buffer: Buffer, imageType: 'png' | 'jpeg'
const width = image.width * shrink | 0;
const height = image.height * shrink | 0;
const scaledImage = scaleImageToSize(image, { width, height });
if (imageType === 'webp')
return encodeWebp(scaledImage, isLosslessWebp(buffer) ? { lossless: true } : { quality: 80 });
// eslint-disable-next-line no-restricted-syntax
return imageType === 'png' ? PNG.sync.write(scaledImage as any) : jpegjs.encode(scaledImage, 80).data;
}
@@ -805,12 +805,13 @@ 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.'),
hires: z.boolean().optional().describe('When true, captures a high-resolution screenshot using device pixels (accounts for the device pixel ratio), instead of CSS pixels.'),
}),
toolName: 'browser_take_screenshot',
toolParams: ({ target, filename, ['full-page']: fullPage, hires }) => ({ filename, target, fullPage, scale: hires ? 'device' : undefined }),
toolParams: ({ target, filename, type, ['full-page']: fullPage, hires }) => ({ filename, target, type, fullPage, scale: hires ? 'device' : undefined }),
});
const pdfSave = declareCommand({
@@ -56,7 +56,10 @@ function generateCommandHelp(command: AnyCommandSchema) {
const optionsShape = (command.options as zodType.ZodObject<any>).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));
}
}
+9 -6
View File
@@ -13176,7 +13176,8 @@ export interface ElementHandle<T=Node> extends JSHandle<T> {
path?: string;
/**
* The quality of the image, between 0-100. Not applicable to `png` images.
* The quality of the image, between 0-100. Not applicable to `png` images. For `jpeg` the default is `80`. For
* `webp`, a quality of `100` (the default) produces a lossless image, while lower values use lossy compression.
*/
quality?: number;
@@ -13218,7 +13219,7 @@ export interface ElementHandle<T=Node> extends JSHandle<T> {
/**
* Specify screenshot type, defaults to `png`.
*/
type?: "png"|"jpeg";
type?: "png"|"jpeg"|"webp";
}): Promise<Buffer>;
/**
@@ -25108,7 +25109,8 @@ export interface LocatorScreenshotOptions {
path?: string;
/**
* The quality of the image, between 0-100. Not applicable to `png` images.
* The quality of the image, between 0-100. Not applicable to `png` images. For `jpeg` the default is `80`. For
* `webp`, a quality of `100` (the default) produces a lossless image, while lower values use lossy compression.
*/
quality?: number;
@@ -25150,7 +25152,7 @@ export interface LocatorScreenshotOptions {
/**
* Specify screenshot type, defaults to `png`.
*/
type?: "png"|"jpeg";
type?: "png"|"jpeg"|"webp";
}
interface ElementHandleWaitForSelectorOptions {
@@ -25825,7 +25827,8 @@ export interface PageScreenshotOptions {
path?: string;
/**
* The quality of the image, between 0-100. Not applicable to `png` images.
* The quality of the image, between 0-100. Not applicable to `png` images. For `jpeg` the default is `80`. For
* `webp`, a quality of `100` (the default) produces a lossless image, while lower values use lossy compression.
*/
quality?: number;
@@ -25867,7 +25870,7 @@ export interface PageScreenshotOptions {
/**
* Specify screenshot type, defaults to `png`.
*/
type?: "png"|"jpeg";
type?: "png"|"jpeg"|"webp";
}
type Devices = {
@@ -343,8 +343,9 @@ export async function toHaveScreenshot(
const [page, locator] = (pageOrLocator as any)._apiName === 'Page' ? [(pageOrLocator as PageEx), undefined] : [(pageOrLocator as Locator).page() as PageEx, pageOrLocator as Locator];
const configOptions = expectConfig().toHaveScreenshot || {};
const helper = new SnapshotHelper(this, testInfo, 'toHaveScreenshot', locator, undefined, configOptions, nameOrOptions, optOptions);
if (!helper.expectedPath.toLowerCase().endsWith('.png'))
throw new Error(`Screenshot name "${path.basename(helper.expectedPath)}" must have '.png' extension`);
const screenshotType = ({ 'image/png': 'png', 'image/webp': 'webp' } as const)[helper.mimeType];
if (!screenshotType)
throw new Error(`Screenshot name "${path.basename(helper.expectedPath)}" must have a '.png' or '.webp' extension`);
expectTypes(pageOrLocator, ['Page', 'Locator'], 'toHaveScreenshot');
const style = await loadScreenshotStyles(helper.options.stylePath);
const timeout = helper.options.timeout ?? this.timeout;
@@ -361,6 +362,7 @@ export async function toHaveScreenshot(
style,
isNot: !!this.isNot,
timeout,
type: screenshotType,
comparator: helper.options.comparator,
maxDiffPixels: helper.options.maxDiffPixels,
maxDiffPixelRatio: helper.options.maxDiffPixelRatio,
+1
View File
@@ -423,6 +423,7 @@ ElementHandle:
literals:
- png
- jpeg
- webp
quality: int?
$mixin: CommonScreenshotOptions
returns:
+6
View File
@@ -186,6 +186,11 @@ Page:
threshold: float?
fullPage: boolean?
clip: Rect?
type:
type: enum?
literals:
- png
- webp
$mixin: CommonScreenshotOptions
returns:
actual: binary?
@@ -211,6 +216,7 @@ Page:
literals:
- png
- jpeg
- webp
quality: int?
fullPage: boolean?
clip: Rect?
+3 -2
View File
@@ -1897,7 +1897,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'])),
@@ -2437,6 +2437,7 @@ scheme.PageExpectScreenshotParams = tObject({
threshold: tOptional(tFloat),
fullPage: tOptional(tBoolean),
clip: tOptional(tType('Rect')),
type: tOptional(tEnum(['png', 'webp'])),
omitBackground: tOptional(tBoolean),
caret: tOptional(tEnum(['hide', 'initial'])),
animations: tOptional(tEnum(['disabled', 'allow'])),
@@ -2461,7 +2462,7 @@ scheme.PageExpectScreenshotErrorDetails = 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')),
+1
View File
@@ -16,6 +16,7 @@ node_modules/yazl
[comparators.ts]
./image_tools
./third_party/pixelmatch
./webp/webp
[env.ts]
"strict"
+16 -2
View File
@@ -23,6 +23,7 @@ import { padImageToSize } from '@isomorphic/imageUtils';
// @ts-ignore
import pixelmatch from './third_party/pixelmatch';
import { compare } from './image_tools/compare';
import { decodeWebp } from './webp/webp';
import type { ImageData } from '@isomorphic/imageUtils';
@@ -35,6 +36,8 @@ export function getComparator(mimeType: string): Comparator {
return compareImages.bind(null, 'image/png');
if (mimeType === 'image/jpeg')
return compareImages.bind(null, 'image/jpeg');
if (mimeType === 'image/webp')
return compareImages.bind(null, 'image/webp');
if (mimeType === 'text/plain')
return compareText;
return compareBuffersOrStrings;
@@ -57,8 +60,15 @@ function compareImages(mimeType: string, actualBuffer: Buffer | string, expected
return { errorMessage: 'Actual result should be a Buffer.' };
validateBuffer(expectedBuffer, mimeType);
let actual: ImageData = mimeType === 'image/png' ? PNG.sync.read(actualBuffer) : jpegjs.decode(actualBuffer, { maxMemoryUsageInMB: JPEG_JS_MAX_BUFFER_SIZE_IN_MB });
let expected: ImageData = mimeType === 'image/png' ? PNG.sync.read(expectedBuffer) : jpegjs.decode(expectedBuffer, { maxMemoryUsageInMB: JPEG_JS_MAX_BUFFER_SIZE_IN_MB });
const decode = (buffer: Buffer): ImageData => {
if (mimeType === 'image/png')
return PNG.sync.read(buffer);
if (mimeType === 'image/webp')
return decodeWebp(buffer);
return jpegjs.decode(buffer, { maxMemoryUsageInMB: JPEG_JS_MAX_BUFFER_SIZE_IN_MB });
};
let actual: ImageData = decode(actualBuffer);
let expected: ImageData = decode(expectedBuffer);
const size = { width: Math.max(expected.width, actual.width), height: Math.max(expected.height, actual.height) };
let sizesMismatchError = '';
if (expected.width !== actual.width || expected.height !== actual.height) {
@@ -105,6 +115,10 @@ function validateBuffer(buffer: Buffer, mimeType: string): void {
const jpegMagicNumber = [255, 216];
if (buffer.length < jpegMagicNumber.length || !jpegMagicNumber.every((byte, index) => buffer[index] === byte))
throw new Error('Could not decode expected image as JPEG.');
} else if (mimeType === 'image/webp') {
// A WebP bitstream is a RIFF container tagged 'WEBP': "RIFF????WEBP".
if (buffer.length < 12 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WEBP')
throw new Error('Could not decode expected image as WEBP.');
}
}
+1
View File
@@ -19,6 +19,7 @@ export * from './chromiumChannels';
export * from './comparators';
export * from './crypto';
export * from './debug';
export * from './webp/webp';
export * from './debugLogger';
export * from './env';
export * from './eventsHelper';
+122
View File
@@ -0,0 +1,122 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Synchronous WebP encode/decode backed by our own libwebp WASM build. The
// codec module (webp_codec.js + webp_codec.wasm) is produced by
// utils/libwebp-wasm/build.sh; see that folder for the build and its license.
import fs from 'fs';
import path from 'path';
// @ts-ignore - generated Emscripten glue, no type declarations.
import initWebpModule from './webp_codec';
export type WebpImage = { width: number, height: number, data: Buffer };
type WebpModule = {
_malloc(size: number): number;
_free(ptr: number): void;
_webp_free(ptr: number): void;
_webp_encode_rgba(rgba: number, width: number, height: number, quality: number, lossless: number, outSize: number): number;
_webp_decode_rgba(data: number, size: number, widthPtr: number, heightPtr: number): number;
HEAPU8: Uint8Array;
HEAPU32: Uint32Array;
};
let module: WebpModule | undefined;
function webpModule(): WebpModule {
if (!module) {
// The module is built with WASM_ASYNC_COMPILATION=0, so the factory
// instantiates synchronously and attaches the exports to `instance` before
// returning; the returned promise is ignored. We pass the .wasm bytes
// explicitly (it sits next to this file / the bundle) rather than relying on
// Emscripten's own file lookup.
const instance: any = { wasmBinary: fs.readFileSync(path.join(__dirname, 'webp_codec.wasm')) };
initWebpModule(instance);
module = instance as WebpModule;
}
return module;
}
export function encodeWebp(image: WebpImage, options: { quality?: number, lossless?: boolean } = {}): Buffer {
// For lossy, `quality` is the 0..100 quality factor; for lossless it is the
// 0..100 compression effort (higher = smaller/slower).
const { quality = 75, lossless = false } = options;
const m = webpModule();
const inPtr = m._malloc(image.data.length);
m.HEAPU8.set(image.data, inPtr);
const sizePtr = m._malloc(4);
const outPtr = m._webp_encode_rgba(inPtr, image.width, image.height, quality, lossless ? 1 : 0, sizePtr);
const size = m.HEAPU32[sizePtr >> 2];
let out: Buffer | undefined;
if (outPtr && size) {
out = Buffer.from(m.HEAPU8.subarray(outPtr, outPtr + size));
m._webp_free(outPtr);
}
m._free(inPtr);
m._free(sizePtr);
if (!out)
throw new Error('WebP encode failed');
return out;
}
export function decodeWebp(buffer: Buffer): WebpImage {
const m = webpModule();
const inPtr = m._malloc(buffer.length);
m.HEAPU8.set(buffer, inPtr);
const widthPtr = m._malloc(4);
const heightPtr = m._malloc(4);
const outPtr = m._webp_decode_rgba(inPtr, buffer.length, widthPtr, heightPtr);
const width = m.HEAPU32[widthPtr >> 2];
const height = m.HEAPU32[heightPtr >> 2];
let result: WebpImage | undefined;
if (outPtr) {
const data = Buffer.from(m.HEAPU8.subarray(outPtr, outPtr + width * height * 4));
result = { data, width, height };
m._webp_free(outPtr);
}
m._free(inPtr);
m._free(widthPtr);
m._free(heightPtr);
if (!result)
throw new Error('WebP decode failed');
return result;
}
// Whether a WebP bitstream is lossless, determined by parsing the RIFF header.
// The frame is 'VP8L' (lossless) or 'VP8 ' (lossy); the 'VP8X' extended format
// wraps one of those, so scan its chunks. (The lossy quality factor is an
// encoder input and is not stored in the bitstream, so it cannot be recovered.)
export function isLosslessWebp(buffer: Buffer): boolean {
if (buffer.length < 16 || buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WEBP')
return false;
let offset = 12;
while (offset + 8 <= buffer.length) {
const fourcc = buffer.toString('ascii', offset, offset + 4);
if (fourcc === 'VP8L')
return true;
if (fourcc === 'VP8 ')
return false;
// Skip this chunk (8-byte header + payload, padded to an even size) and
// keep scanning — only 'VP8X' is expected to lead here.
if (fourcc !== 'VP8X')
return false;
const size = buffer.readUInt32LE(offset + 4);
offset += 8 + size + (size & 1);
}
return false;
}
+173
View File
@@ -0,0 +1,173 @@
Third-party license notices for the bundled WebP codec (webp_codec.wasm and
its Emscripten glue webp_codec.js).
webp_codec.wasm is compiled from libwebp (https://github.com/webmproject/libwebp)
and webp_codec.js is generated by Emscripten (https://emscripten.org). Their
licenses are reproduced below.
================================================================================
libwebp — BSD 3-Clause License
================================================================================
Copyright (c) 2010, Google Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of Google nor the names of its contributors may
be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Additional IP Rights Grant (Patents)
------------------------------------
"These implementations" means the copyrightable works that implement the WebM
codecs distributed by Google as part of the WebM Project.
Google hereby grants to you a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable (except as stated in this section) patent license to
make, have made, use, offer to sell, sell, import, transfer, and otherwise
run, modify and propagate the contents of these implementations of WebM, where
such license applies only to those patent claims, both currently owned by
Google and acquired in the future, licensable by Google that are necessarily
infringed by these implementations of WebM. This grant does not include claims
that would be infringed only as a consequence of further modification of these
implementations. If you or your agent or exclusive licensee institute or order
or agree to the institution of patent litigation or any other patent
enforcement activity against any entity (including a cross-claim or
counterclaim in a lawsuit) alleging that any of these implementations of WebM
or any code incorporated within any of these implementations of WebM
constitute direct or contributory patent infringement, or inducement of
patent infringement, then any patent rights granted to you under this License
for these implementations of WebM shall terminate as of the date such
litigation is filed.
================================================================================
Emscripten (webp_codec.js glue + runtime linked into webp_codec.wasm)
================================================================================
Emscripten is available under 2 licenses, the MIT license and the
University of Illinois/NCSA Open Source License.
Both are permissive open source licenses, with little if any
practical difference between them.
The reason for offering both is that (1) the MIT license is
well-known, while (2) the University of Illinois/NCSA Open Source
License allows Emscripten's code to be integrated upstream into
LLVM, which uses that license, should the opportunity arise.
The full text of both licenses follows.
==============================================================================
Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
==============================================================================
Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file.
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal with the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimers.
Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimers
in the documentation and/or other materials provided with the
distribution.
Neither the names of Mozilla,
nor the names of its contributors may be used to endorse
or promote products derived from this Software without specific prior
written permission.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR
ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE SOFTWARE.
==============================================================================
This program uses portions of Node.js source code located in src/library_path.js,
in accordance with the terms of the MIT license. Node's license follows:
"""
Copyright Joyent, Inc. and other Node contributors. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
"""
The musl libc project is bundled in this repo, and it has the MIT license, see
system/lib/libc/musl/COPYRIGHT
The third_party/ subdirectory contains code with other licenses. None of it is
used by default, but certain options use it (e.g., the optional closure compiler
flag will run closure compiler from third_party/).
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -19,10 +19,15 @@ import { test, expect } from './npmTest';
// Lower bounds on the number of inlined npm packages per LICENSE. If a bundle drops below
// these, something is broken with bundle generation or dependencies were silently removed.
// Package count per bundle .LICENSE sidecar; `0` marks a non-bundle license
// (e.g. a vendored binary's third-party license) that has no "Total Packages"
// summary and is only checked for presence.
const EXPECTED: Record<string, Record<string, number>> = {
'playwright-core': {
'lib/serverRegistry.js.LICENSE': 10,
'lib/utilsBundle.js.LICENSE': 80,
// Vendored WebP codec (libwebp compiled to WASM), not a bundle sidecar.
'lib/webp_codec.LICENSE': 0,
},
'playwright': {
'lib/matchers/expect.js.LICENSE': 20,
@@ -65,6 +70,11 @@ for (const [pkg, licenses] of Object.entries(EXPECTED)) {
for (const [relPath, minPackages] of Object.entries(licenses)) {
const absPath = path.join(extractDir, 'package', relPath);
const contents = await fs.promises.readFile(absPath, 'utf8');
if (!minPackages) {
// Non-bundle license (no "Total Packages" summary) — just verify it ships.
expect(contents.trim().length, `${pkg}/${relPath} is empty`).toBeGreaterThan(0);
continue;
}
const match = contents.match(/^Total Packages: (\d+)$/m);
expect(match, `${pkg}/${relPath} is missing the "Total Packages" summary line`).toBeTruthy();
const count = Number(match![1]);
+11
View File
@@ -53,6 +53,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);
+1 -1
View File
@@ -96,7 +96,7 @@ 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']) {
for (const type of ['png', 'jpeg', 'webp']) {
test(`browser_take_screenshot (type: ${type})`, async ({ startClient, server }, testInfo) => {
const outputDir = testInfo.outputPath('output');
const { client } = await startClient({
+10
View File
@@ -278,6 +278,16 @@ it.describe('page screenshot', () => {
expect(screenshot).toMatchSnapshot('white.jpg');
});
it('should produce a valid webp screenshot', async ({ page, server, isBidi }) => {
it.skip(isBidi, 'webp screenshots are not supported via WebDriver BiDi');
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' });
expect(screenshot).toMatchSnapshot('red.webp');
});
it('should work with odd clip size on Retina displays', async ({ page, isElectron }) => {
it.skip(isElectron, 'electron does not set device scale factor to 1');
Binary file not shown.

After

Width:  |  Height:  |  Size: 518 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 B

@@ -554,7 +554,7 @@ test('should fail when screenshot is different size', async ({ runInlineTest })
expect(result.output).toContain('Expected an image 22px by 33px, received 1280px by 720px.');
});
test('should fail when given non-png snapshot name', async ({ runInlineTest }) => {
test('should fail when given unsupported snapshot name', async ({ runInlineTest }) => {
const result = await runInlineTest({
...playwrightConfig({
snapshotPathTemplate: '__screenshots__/{testFilePath}/{arg}{ext}',
@@ -567,7 +567,7 @@ test('should fail when given non-png snapshot name', async ({ runInlineTest }) =
`
});
expect(result.exitCode).toBe(1);
expect(result.output).toContain(`Screenshot name "snapshot.jpeg" must have '.png' extension`);
expect(result.output).toContain(`Screenshot name "snapshot.jpeg" must have a '.png' or '.webp' extension`);
});
test('should fail when given buffer', async ({ runInlineTest }) => {
+15
View File
@@ -1027,6 +1027,21 @@ copyFiles.push({
to: 'packages/playwright-core/lib',
});
// WebP codec: ship the WASM binary and its third-party license into lib/ next
// to coreBundle.js, where @utils/webp/webp reads them at runtime. The .js glue
// is inlined into coreBundle; the .wasm and .LICENSE ship as assets. The
// license is generated from the pinned libwebp source by utils/libwebp-wasm/build.sh.
copyFiles.push({
files: 'packages/utils/webp/webp_codec.wasm',
from: 'packages/utils/webp',
to: 'packages/playwright-core/lib',
});
copyFiles.push({
files: 'packages/utils/webp/webp_codec.LICENSE',
from: 'packages/utils/webp',
to: 'packages/playwright-core/lib',
});
copyFiles.push({
files: 'packages/playwright/src/agents/*.md',
+1
View File
@@ -0,0 +1 @@
.build/
+117
View File
@@ -0,0 +1,117 @@
# libwebp → WASM (self-built)
The **build tooling** for a self-contained WebP codec compiled from
[libwebp](https://github.com/webmproject/libwebp) to WebAssembly, built entirely
from source with no third-party npm codec dependency. Running `build.sh`
produces the codec artifacts and emits them into the shipped location,
`packages/utils/webp/`.
**This folder (`utils/libwebp-wasm/`) — build tooling only:**
| File | What |
|------|------|
| `build.sh` | Reproducible build: fetches emsdk + libwebp, compiles, links, regenerates the license. |
| `webp_wasm.c` | Tiny C wrapper over libwebp's config-based encode + `WebPDecodeRGBA` + `WebPFree`. |
**Shipped codec — `packages/utils/webp/` (importable as `@utils/webp/webp`):**
| File | What |
|------|------|
| `webp.ts` | Typed **synchronous** API: `encodeWebp(image, {quality\|lossless})` / `decodeWebp(buffer)`. |
| `webp_codec.js` / `webp_codec.wasm` | Built artifacts (Emscripten CommonJS glue + binary). **SIMD, synchronous.** |
| `webp_codec.LICENSE` | Third-party license, regenerated by the build: libwebp BSD from the pinned source + Emscripten's license copied from the compiler. |
Pinned: **libwebp `3757b8a`** (a post-1.6.0 `main` commit — see below), built
with Emscripten (tested emcc 6.0.2).
## API
Exported from the `@utils` barrel; **synchronous** — no `await` (see
"Synchronous" below):
```ts
import { encodeWebp, decodeWebp } from '@utils/webp/webp';
// Lossy (quality is the 0..100 quality factor):
const webp = encodeWebp({ data: rgba, width, height }, { quality: 80 }); // -> Buffer
// Lossless (pixel-exact; quality is the 0..100 compression effort):
const exact = encodeWebp({ data: rgba, width, height }, { lossless: true });
const { data, width, height } = decodeWebp(webp); // RGBA Buffer
```
Lossless output is verified pixel-exact (decode MAE 0.0000). `webp.ts` reads
`webp_codec.wasm` next to itself (shipped into `lib/` by the build),
instantiating lazily on first call.
## Build
```bash
./build.sh # SIMD build (default) — ~552 KB wasm
SIMD=0 ./build.sh # scalar build — ~333 KB wasm, ~2x slower encode
```
The first run downloads and activates emsdk under `.build/` (set `EMSDK_DIR` to
reuse an existing one). libwebp is built with all CLI tools, threads, mux and
demux disabled — just the encoder + decoder + dsp needed for RGBA <-> WebP.
## Performance (photo 1920×1080, quality 80)
| Build | encode | decode |
|-------|-------:|-------:|
| SIMD (default) | **~370 ms** | ~75 ms |
| scalar (`SIMD=0`) | ~720 ms | ~75 ms |
The SIMD build (SSE2 → wasm SIMD) is ~2× faster on encode. Output is standard
WebP; lossless round-trips pixel-exact.
## Why a pinned post-1.6.0 commit (and not v1.6.0)
Building the **v1.6.0 release** with a modern Emscripten needed two workarounds;
both were fixed upstream *after* 1.6.0 (released 2025-07-08) but are not in any
tagged release yet, so we pin a `main` commit that includes them:
1. **Runtime SIMD dispatch.** libwebp's wasm SIMD selector (`src/dsp/cpu.c`) was
guarded by the *legacy* `defined(EMSCRIPTEN)` macro, which modern emcc no
longer defines (only `__EMSCRIPTEN__`) — so the compiled SSE2 code was never
selected at runtime and you silently got scalar speed. Fixed by
[`5339483`](https://github.com/webmproject/libwebp/commit/5339483509d9)
*"dsp/cpu.c: avoid use of legacy EMSCRIPTEN definition"* (2026-04-12).
2. **SIMD compile on Emscripten.** libwebp's cmake probed `-mavx2`/`-msse4.1`
(which emcc accepts) and those codepaths failed to compile. Fixed by
[`453a18c`](https://github.com/webmproject/libwebp/commit/453a18c42f39)
*"cmake/cpu.cmake: fix SSE2 check on Emscripten"* and
[`b51704f`](https://github.com/webmproject/libwebp/commit/b51704f9b9fe)
*"dsp/cpu.c: allow use of AVX2 on Emscripten"* (2026-04-24).
(Older toolchains sidestep both: Emscripten ≤ 2.0.34 still defined the legacy
`EMSCRIPTEN` macro, and older libwebp lacked the AVX2/SSE41 probes.) **Switch
`LIBWEBP_COMMIT` in `build.sh` to a release tag once one ships with these
fixes** (expected 1.6.1 / 1.7.0).
## Synchronous
The module is linked with `-sWASM_ASYNC_COMPILATION=0` and CommonJS output (no
`EXPORT_ES6`), so it instantiates with `new WebAssembly.Instance` and the exports
attach to the module object during the (synchronous) factory call — `encodeWebp`
/ `decodeWebp` are then plain synchronous calls. This matches playwright-core's
synchronous image `Comparator`, and the CommonJS glue inlines cleanly into
`coreBundle` (via `@utils`).
Synchronous instantiation is **Node-only in practice**: browsers cap synchronous
WASM compilation on the main thread (a large module must be compiled async or in
a worker). Playwright uses it server-side in Node, so this is fine.
## Packaging
The codec ships with playwright-core:
- `webp_codec.js` (glue) is inlined into `coreBundle.js` via the `@utils` barrel.
- `webp_codec.wasm` and `webp_codec.LICENSE` are copied into `lib/` by
`utils/build/build.js` (`copyFiles`) and included by
`packages/playwright-core/.npmignore` (`!lib/**/*.wasm`, `!lib/**/*.LICENSE`).
`ThirdPartyNotices.txt` already covers per-`lib/` license sidecars generically.
`webp.ts` reads `webp_codec.wasm` via `path.join(__dirname, 'webp_codec.wasm')`,
which resolves to `lib/` at runtime (where `coreBundle.js` lives).
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
#
# Build libwebp to a self-contained WASM module exposing a minimal RGBA
# encode/decode API (see webp_wasm.c). Emits everything the shipped codec needs
# into packages/utils/webp/ (where the typed wrapper webp.ts imports them,
# @utils/webp/webp): the synchronous Emscripten CommonJS glue (webp_codec.js),
# the binary (webp_codec.wasm) and the regenerated third-party license
# (webp_codec.LICENSE).
#
# Usage:
# ./build.sh # SIMD build (default; SSE2 -> wasm SIMD)
# SIMD=0 ./build.sh # scalar build (smaller, ~2x slower encode)
#
# libwebp is pinned to a post-1.6.0 `main` commit that contains the Emscripten
# SIMD fixes (5339483 "avoid legacy EMSCRIPTEN definition" and 453a18c "fix SSE2
# check on Emscripten", both 2026-04, unreleased as of v1.6.0 / 2025-07). With
# those, no cmake patching or -DEMSCRIPTEN is needed. Switch to a release tag
# once one ships with the fixes.
#
# Reproducibility: uses whatever emsdk "latest" resolves to (tested emcc 6.0.2).
# Set EMSDK_DIR to reuse an existing emsdk.
#
set -euo pipefail
# Pinned to a commit rather than a release tag on purpose: the latest release
# (v1.6.0, 2025-07-08) predates the upstream Emscripten SIMD fixes (5339483 and
# 453a18c, both 2026-04) and building it needs local patches + -DEMSCRIPTEN.
# This `main` commit already has them, so the build stays patch-free. Move to a
# release tag once one ships with these fixes (expected 1.6.1 / 1.7.0).
LIBWEBP_COMMIT="${LIBWEBP_COMMIT:-3757b8afeb54e305eaef18502812a9a88b7ed662}"
SIMD="${SIMD:-1}"
HERE="$(cd "$(dirname "$0")" && pwd)"
OUT="$(cd "$HERE/../../packages/utils/webp" && pwd)" # shipped codec (imported via @utils/webp/webp)
WORK="${WORK:-$HERE/.build}"
mkdir -p "$WORK"
# --- 1. Emscripten toolchain -------------------------------------------------
if ! command -v emcc >/dev/null 2>&1; then
EMSDK_DIR="${EMSDK_DIR:-$WORK/emsdk}"
if [ ! -d "$EMSDK_DIR" ]; then
git clone --depth 1 https://github.com/emscripten-core/emsdk.git "$EMSDK_DIR"
fi
"$EMSDK_DIR/emsdk" install latest
"$EMSDK_DIR/emsdk" activate latest
# shellcheck disable=SC1091
source "$EMSDK_DIR/emsdk_env.sh"
fi
echo "emcc: $(emcc --version | head -1)"
# --- 2. libwebp source (pinned commit) ---------------------------------------
SRC="$WORK/libwebp-$LIBWEBP_COMMIT"
if [ ! -d "$SRC" ]; then
mkdir -p "$SRC"
curl -sSL "https://github.com/webmproject/libwebp/archive/${LIBWEBP_COMMIT}.tar.gz" \
| tar xz --strip 1 -C "$SRC"
fi
# --- 3. Configure + build libwebp static libs --------------------------------
# On Emscripten, cmake auto-adds -msimd128 to the SIMD sources and libwebp's
# runtime dispatch (dsp/cpu.c) keys off __EMSCRIPTEN__, so only SSE2 (the SIMD
# level that lowers to wasm) is used at runtime. SIMD is opt-in on Emscripten.
BUILD="$WORK/build-$([ "$SIMD" = 1 ] && echo simd || echo scalar)"
emcmake cmake -S "$SRC" -B "$BUILD" \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=OFF \
-DWEBP_USE_THREAD=OFF \
-DWEBP_ENABLE_SIMD="$SIMD" \
-DWEBP_BUILD_CWEBP=OFF -DWEBP_BUILD_DWEBP=OFF -DWEBP_BUILD_GIF2WEBP=OFF \
-DWEBP_BUILD_IMG2WEBP=OFF -DWEBP_BUILD_VWEBP=OFF -DWEBP_BUILD_WEBPINFO=OFF \
-DWEBP_BUILD_WEBPMUX=OFF -DWEBP_BUILD_EXTRAS=OFF -DWEBP_BUILD_ANIM_UTILS=OFF
emmake make -C "$BUILD" webp sharpyuv -j"$(nproc)"
# --- 4. Link the wrapper into a synchronous CommonJS module ------------------
# WASM_ASYNC_COMPILATION=0 instantiates the module with `new WebAssembly.Instance`
# (synchronous), so the exports attach to the module object during the factory
# call and encode/decode are plain synchronous calls (needed for Playwright's
# synchronous image Comparator). CommonJS output (no EXPORT_ES6) inlines cleanly
# into playwright-core's coreBundle via @utils. Sync compilation is Node-only in
# practice (browsers cap synchronous WASM compilation on the main thread).
emcc "$HERE/webp_wasm.c" "$BUILD/libwebp.a" "$BUILD/libsharpyuv.a" \
-I "$SRC/src" -I "$SRC" \
-O3 -flto \
-sMODULARIZE=1 -sENVIRONMENT=node,web \
-sWASM_ASYNC_COMPILATION=0 \
-sEXPORTED_FUNCTIONS=_webp_encode_rgba,_webp_decode_rgba,_webp_free,_malloc,_free \
-sEXPORTED_RUNTIME_METHODS=HEAPU8,HEAPU32 \
-sALLOW_MEMORY_GROWTH=1 -sFILESYSTEM=0 -sMALLOC=emmalloc \
--no-entry \
-o "$OUT/webp_codec.js"
# --- 5. Regenerate the third-party license from the pinned libwebp source ----
# Ships with playwright-core (copied into lib/ by the build; see build.js).
{
cat <<'EOF'
Third-party license notices for the bundled WebP codec (webp_codec.wasm and
its Emscripten glue webp_codec.js).
webp_codec.wasm is compiled from libwebp (https://github.com/webmproject/libwebp)
and webp_codec.js is generated by Emscripten (https://emscripten.org). Their
licenses are reproduced below.
================================================================================
libwebp — BSD 3-Clause License
================================================================================
EOF
cat "$SRC/COPYING"
echo
echo "--------------------------------------------------------------------------------"
cat "$SRC/PATENTS"
cat <<'EOF'
================================================================================
Emscripten (webp_codec.js glue + runtime linked into webp_codec.wasm)
================================================================================
EOF
# Copy the license verbatim from the Emscripten compiler rather than hardcoding.
cat "$(dirname "$(command -v emcc)")/LICENSE"
} > "$OUT/webp_codec.LICENSE"
echo "Built into $OUT: webp_codec.wasm ($(stat -c%s "$OUT/webp_codec.wasm") bytes), webp_codec.js, webp_codec.LICENSE"
+82
View File
@@ -0,0 +1,82 @@
/*
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Minimal WASM wrapper around libwebp's encode/decode RGBA API. Exposes exactly
// what the codec benchmark needs: RGBA -> WebP (lossy or lossless) and
// WebP -> RGBA. Memory is owned by libwebp and released via webp_free().
#include <stddef.h>
#include <stdint.h>
#include <emscripten.h>
#include "webp/encode.h"
#include "webp/decode.h"
// Encode RGBA (width*height*4, row stride = width*4) to a WebP bitstream.
// lossless == 0: lossy, `quality` is the 0..100 quality factor.
// lossless != 0: lossless (pixels preserved exactly); `quality` is the 0..100
// compression effort (higher = smaller/slower).
// Returns a pointer to the libwebp-allocated output and writes its byte length
// to *out_size. Returns NULL (and *out_size == 0) on failure. Free the returned
// pointer with webp_free().
EMSCRIPTEN_KEEPALIVE
uint8_t* webp_encode_rgba(const uint8_t* rgba, int width, int height,
float quality, int lossless, size_t* out_size) {
*out_size = 0;
WebPConfig config;
if (!WebPConfigInit(&config))
return NULL;
config.lossless = lossless ? 1 : 0;
config.quality = quality;
if (!WebPValidateConfig(&config))
return NULL;
WebPPicture pic;
if (!WebPPictureInit(&pic))
return NULL;
pic.use_argb = config.lossless; // lossless encodes from the ARGB buffer
pic.width = width;
pic.height = height;
WebPMemoryWriter writer;
WebPMemoryWriterInit(&writer);
pic.writer = WebPMemoryWrite;
pic.custom_ptr = &writer;
const int ok = WebPPictureImportRGBA(&pic, rgba, width * 4) &&
WebPEncode(&config, &pic);
WebPPictureFree(&pic);
if (!ok) {
WebPMemoryWriterClear(&writer);
return NULL;
}
*out_size = writer.size;
return writer.mem; // WebPMalloc'd; release with webp_free()
}
// Decode a WebP bitstream to RGBA. Returns a pointer to the libwebp-allocated
// RGBA buffer (width*height*4) and writes the dimensions to *width/*height.
// Returns NULL on failure. Free the returned pointer with webp_free().
EMSCRIPTEN_KEEPALIVE
uint8_t* webp_decode_rgba(const uint8_t* data, size_t size,
int* width, int* height) {
return WebPDecodeRGBA(data, size, width, height);
}
// Release a buffer returned by webp_encode_rgba/webp_decode_rgba.
EMSCRIPTEN_KEEPALIVE
void webp_free(void* ptr) {
WebPFree(ptr);
}