From 42747121313bdf7fd9ea17e9838eee82471fbb0c Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 5 Dec 2019 13:58:08 -0800 Subject: [PATCH] chore: unify screenshot handling between browsers, introduce Screenshotter everywhere --- docs/api.md | 11 +++++ src/chromium/JSHandle.ts | 3 +- src/chromium/Page.ts | 4 +- src/chromium/Screenshotter.ts | 59 +++--------------------- src/dom.ts | 4 +- src/firefox/JSHandle.ts | 22 ++------- src/firefox/Page.ts | 48 +++----------------- src/firefox/Screenshotter.ts | 64 ++++++++++++++++++++++++++ src/helper.ts | 40 +++++++++++++++++ src/types.ts | 10 +++++ src/webkit/Browser.ts | 6 +-- src/webkit/JSHandle.ts | 18 ++------ src/webkit/Page.ts | 85 ++++------------------------------- src/webkit/Screenshotter.ts | 83 ++++++++++++++++++++++++++++++++++ src/webkit/Target.ts | 2 +- src/webkit/TaskQueue.ts | 30 ------------- 16 files changed, 243 insertions(+), 246 deletions(-) create mode 100644 src/firefox/Screenshotter.ts create mode 100644 src/webkit/Screenshotter.ts delete mode 100644 src/webkit/TaskQueue.ts diff --git a/docs/api.md b/docs/api.md index afe0318acd..985568c8c8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -3457,6 +3457,17 @@ If `key` is a single character and no modifier keys besides `Shift` are being he #### elementHandle.screenshot([options]) - `options` <[Object]> Same options as in [page.screenshot](#pagescreenshotoptions). + - `path` <[string]> The file path to save the image to. The screenshot type will be inferred from file extension. If `path` is a relative path, then it is resolved relative to [current working directory](https://nodejs.org/api/process.html#process_process_cwd). If no path is provided, the image won't be saved to the disk. + - `type` <"png"|"jpeg"> Specify screenshot type, defaults to 'png'. + - `quality` <[number]> The quality of the image, between 0-100. Not applicable to `png` images. + - `fullPage` <[boolean]> When true, takes a screenshot of the full scrollable page. Defaults to `false`. + - `clip` <[Object]> Passed clip value is ignored and instead set to the element's bounding box. + - `x` <[number]> + - `y` <[number]> + - `width` <[number]> + - `height` <[number]> + - `omitBackground` <[boolean]> Hides default white background and allows capturing screenshots with transparency. Defaults to `false`. + - `encoding` <[string]> The encoding of the image, can be either `base64` or `binary`. Defaults to `binary`. - returns: <[Promise]<[string]|[Buffer]>> Promise which resolves to buffer or a base64 string (depending on the value of `options.encoding`) with captured screenshot. This method scrolls element into view if needed, and then uses [page.screenshot](#pagescreenshotoptions) to take a screenshot of the element. diff --git a/src/chromium/JSHandle.ts b/src/chromium/JSHandle.ts index b6434f8f50..8163f5c3f4 100644 --- a/src/chromium/JSHandle.ts +++ b/src/chromium/JSHandle.ts @@ -23,7 +23,6 @@ import * as frames from '../frames'; import { CDPSession } from './Connection'; import { FrameManager } from './FrameManager'; import { Protocol } from './protocol'; -import { ScreenshotOptions } from './Screenshotter'; import { ExecutionContextDelegate } from './ExecutionContext'; export class DOMWorldDelegate implements dom.DOMWorldDelegate { @@ -91,7 +90,7 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate { return { width: layoutMetrics.layoutViewport.clientWidth, height: layoutMetrics.layoutViewport.clientHeight }; } - screenshot(handle: dom.ElementHandle, options: ScreenshotOptions = {}): Promise { + screenshot(handle: dom.ElementHandle, options?: types.ScreenshotOptions): Promise { const page = this._frameManager.page(); return page._screenshotter.screenshotElement(page, handle, options); } diff --git a/src/chromium/Page.ts b/src/chromium/Page.ts index ae93268ab5..a9a68ad8d6 100644 --- a/src/chromium/Page.ts +++ b/src/chromium/Page.ts @@ -44,7 +44,7 @@ import * as network from '../network'; import * as dialog from '../dialog'; import * as console from '../console'; import { DOMWorldDelegate } from './JSHandle'; -import { Screenshotter, ScreenshotOptions } from './Screenshotter'; +import { Screenshotter } from './Screenshotter'; export type Viewport = { width: number; @@ -509,7 +509,7 @@ export class Page extends EventEmitter { await this._frameManager.networkManager().setCacheEnabled(enabled); } - screenshot(options: ScreenshotOptions = {}): Promise { + screenshot(options?: types.ScreenshotOptions): Promise { return this._screenshotter.screenshotPage(this, options); } diff --git a/src/chromium/Screenshotter.ts b/src/chromium/Screenshotter.ts index 2740233480..154fbd1605 100644 --- a/src/chromium/Screenshotter.ts +++ b/src/chromium/Screenshotter.ts @@ -18,32 +18,22 @@ import * as fs from 'fs'; import { Page } from './Page'; import { assert, helper } from '../helper'; -import * as mime from 'mime'; import { Protocol } from './protocol'; import * as dom from '../dom'; +import * as types from '../types'; const writeFileAsync = helper.promisify(fs.writeFile); -export type ScreenshotOptions = { - type?: 'png' | 'jpeg', - path?: string, - fullPage?: boolean, - clip?: {x: number, y: number, width: number, height: number}, - quality?: number, - omitBackground?: boolean, - encoding?: string, -} - export class Screenshotter { private _queue = new TaskQueue(); - async screenshotPage(page: Page, options: ScreenshotOptions = {}): Promise { - const format = this._format(options); + async screenshotPage(page: Page, options: types.ScreenshotOptions = {}): Promise { + const format = helper.validateScreeshotOptions(options); return this._queue.postTask(() => this._screenshot(page, format, options)); } - async screenshotElement(page: Page, handle: dom.ElementHandle, options: ScreenshotOptions = {}): Promise { - const format = this._format(options); + async screenshotElement(page: Page, handle: dom.ElementHandle, options: types.ScreenshotOptions = {}): Promise { + const format = helper.validateScreeshotOptions(options); return this._queue.postTask(async () => { let needsViewportReset = false; @@ -84,7 +74,7 @@ export class Screenshotter { }); } - private async _screenshot(page: Page, format: 'png' | 'jpeg', options: ScreenshotOptions): Promise { + private async _screenshot(page: Page, format: 'png' | 'jpeg', options: types.ScreenshotOptions): Promise { await page.browser()._activatePage(page); let clip = options.clip ? processClip(options.clip) : undefined; const viewport = page.viewport(); @@ -127,43 +117,6 @@ export class Screenshotter { return {x, y, width, height, scale: 1}; } } - - private _format(options: ScreenshotOptions): 'png' | 'jpeg' { - let format: 'png' | 'jpeg' | 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); - format = options.type; - } else if (options.path) { - const mimeType = mime.getType(options.path); - if (mimeType === 'image/png') - format = 'png'; - else if (mimeType === 'image/jpeg') - format = 'jpeg'; - assert(format, 'Unsupported screenshot mime type: ' + mimeType); - } - - if (!format) - format = 'png'; - - if (options.quality) { - assert(format === 'jpeg', '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); - } - assert(!options.clip || !options.fullPage, 'options.clip and options.fullPage are exclusive'); - if (options.clip) { - assert(typeof options.clip.x === 'number', 'Expected options.clip.x to be a number but found ' + (typeof options.clip.x)); - assert(typeof options.clip.y === 'number', 'Expected options.clip.y to be a number but found ' + (typeof options.clip.y)); - assert(typeof options.clip.width === 'number', 'Expected options.clip.width to be a number but found ' + (typeof options.clip.width)); - assert(typeof options.clip.height === 'number', 'Expected options.clip.height to be a number but found ' + (typeof options.clip.height)); - assert(options.clip.width !== 0, 'Expected options.clip.width not to be 0.'); - assert(options.clip.height !== 0, 'Expected options.clip.height not to be 0.'); - } - return format; - } } class TaskQueue { diff --git a/src/dom.ts b/src/dom.ts index d26248e649..458ecb53c0 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -22,7 +22,7 @@ export interface DOMWorldDelegate { contentQuads(handle: ElementHandle): Promise; layoutViewport(): Promise<{ width: number, height: number }>; boundingBox(handle: ElementHandle): Promise; - screenshot(handle: ElementHandle, options?: any): Promise; + screenshot(handle: ElementHandle, options?: types.ScreenshotOptions): Promise; setInputFiles(handle: ElementHandle, files: input.FilePayload[]): Promise; adoptElementHandle(handle: ElementHandle, to: DOMWorld): Promise; } @@ -359,7 +359,7 @@ export class ElementHandle extends js.JSHandle { return this._world.delegate.boundingBox(this); } - async screenshot(options: any = {}): Promise { + async screenshot(options?: types.ScreenshotOptions): Promise { return this._world.delegate.screenshot(this, options); } diff --git a/src/firefox/JSHandle.ts b/src/firefox/JSHandle.ts index 3a6843a101..c3e6f10fcc 100644 --- a/src/firefox/JSHandle.ts +++ b/src/firefox/JSHandle.ts @@ -92,25 +92,9 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate { return this._frameManager._page.evaluate(() => ({ width: innerWidth, height: innerHeight })); } - async screenshot(handle: dom.ElementHandle, options: any = {}): Promise { - const clip = await this._session.send('Page.getBoundingBox', { - frameId: this._frameId, - objectId: toRemoteObject(handle).objectId, - }); - if (!clip) - throw new Error('Node is either not visible or not an HTMLElement'); - assert(clip.width, 'Node has 0 width.'); - assert(clip.height, 'Node has 0 height.'); - await handle._scrollIntoViewIfNeeded(); - - return await this._frameManager._page.screenshot(Object.assign({}, options, { - clip: { - x: clip.x, - y: clip.y, - width: clip.width, - height: clip.height, - }, - })); + async screenshot(handle: dom.ElementHandle, options?: types.ScreenshotOptions): Promise { + const page = this._frameManager._page; + return page._screenshotter.screenshotElement(page, handle, options); } async setInputFiles(handle: dom.ElementHandle, files: input.FilePayload[]): Promise { diff --git a/src/firefox/Page.ts b/src/firefox/Page.ts index 7ffd457c38..44b439f42e 100644 --- a/src/firefox/Page.ts +++ b/src/firefox/Page.ts @@ -16,8 +16,6 @@ */ import { EventEmitter } from 'events'; -import * as fs from 'fs'; -import * as mime from 'mime'; import { TimeoutError } from '../Errors'; import { assert, debugError, helper, RegisteredListener } from '../helper'; import { TimeoutSettings } from '../TimeoutSettings'; @@ -38,8 +36,7 @@ import * as network from '../network'; import * as frames from '../frames'; import * as dialog from '../dialog'; import * as console from '../console'; - -const writeFileAsync = helper.promisify(fs.writeFile); +import { Screenshotter } from './Screenshotter'; export class Page extends EventEmitter { private _timeoutSettings: TimeoutSettings; @@ -60,6 +57,7 @@ export class Page extends EventEmitter { private _viewport: Viewport; private _disconnectPromise: Promise; private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>(); + _screenshotter: Screenshotter; static async create(session: JugglerSession, browserContext: BrowserContext, defaultViewport: Viewport | null) { const page = new Page(session, browserContext); @@ -107,6 +105,7 @@ export class Page extends EventEmitter { helper.addEventListener(this._networkManager, NetworkManagerEvents.RequestFailed, request => this.emit(Events.Page.RequestFailed, request)), ]; this._viewport = null; + this._screenshotter = new Screenshotter(session); } _didClose() { @@ -425,26 +424,8 @@ export class Page extends EventEmitter { return watchDog.navigationResponse(); } - async screenshot(options: { fullPage?: boolean; clip?: { width: number; height: number; x: number; y: number; }; encoding?: string; path?: string; } = {}): Promise { - const {data} = await this._session.send('Page.screenshot', { - mimeType: getScreenshotMimeType(options), - fullPage: options.fullPage, - clip: processClip(options.clip), - }); - const buffer = options.encoding === 'base64' ? data : Buffer.from(data, 'base64'); - if (options.path) - await writeFileAsync(options.path, buffer); - return buffer; - - function processClip(clip) { - if (!clip) - return undefined; - const x = Math.round(clip.x); - const y = Math.round(clip.y); - const width = Math.round(clip.width + clip.x - x); - const height = Math.round(clip.height + clip.y - y); - return {x, y, width, height}; - } + screenshot(options?: types.ScreenshotOptions): Promise { + return this._screenshotter.screenshotPage(this, options); } evaluate: types.Evaluate = (pageFunction, ...args) => { @@ -589,25 +570,6 @@ export class Page extends EventEmitter { } } -function getScreenshotMimeType(options) { - // 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) { - if (options.type === 'png') - return 'image/png'; - if (options.type === 'jpeg') - return 'image/jpeg'; - throw new Error('Unknown options.type value: ' + options.type); - } - if (options.path) { - const fileType = mime.getType(options.path); - if (fileType === 'image/png' || fileType === 'image/jpeg') - return fileType; - throw new Error('Unsupported screenshot mime type: ' + fileType); - } - return 'image/png'; -} - export type Viewport = { width: number; height: number; diff --git a/src/firefox/Screenshotter.ts b/src/firefox/Screenshotter.ts new file mode 100644 index 0000000000..3768c3972f --- /dev/null +++ b/src/firefox/Screenshotter.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as fs from 'fs'; +import { Page } from './Page'; +import { assert, helper } from '../helper'; +import * as dom from '../dom'; +import * as types from '../types'; +import { JugglerSession } from './Connection'; + +const writeFileAsync = helper.promisify(fs.writeFile); + +export class Screenshotter { + private _session: JugglerSession; + + constructor(session: JugglerSession) { + this._session = session; + } + + async screenshotPage(page: Page, options: types.ScreenshotOptions = {}): Promise { + const format = helper.validateScreeshotOptions(options); + const {data} = await this._session.send('Page.screenshot', { + mimeType: ('image/' + format) as ('image/png' | 'image/jpeg'), + fullPage: options.fullPage, + clip: processClip(options.clip), + }); + const buffer = options.encoding === 'base64' ? data : Buffer.from(data, 'base64'); + if (options.path) + await writeFileAsync(options.path, buffer); + return buffer; + + function processClip(clip) { + if (!clip) + return undefined; + const x = Math.round(clip.x); + const y = Math.round(clip.y); + const width = Math.round(clip.width + clip.x - x); + const height = Math.round(clip.height + clip.y - y); + return {x, y, width, height}; + } + } + + async screenshotElement(page: Page, handle: dom.ElementHandle, options: types.ScreenshotOptions = {}): Promise { + const frameId = page._frameManager._frameData(handle.executionContext().frame()).frameId; + const clip = await this._session.send('Page.getBoundingBox', { + frameId, + objectId: handle._remoteObject.objectId, + }); + if (!clip) + throw new Error('Node is either not visible or not an HTMLElement'); + assert(clip.width, 'Node has 0 width.'); + assert(clip.height, 'Node has 0 height.'); + await handle._scrollIntoViewIfNeeded(); + return this.screenshotPage(page, { + ...options, + clip: { + x: clip.x, + y: clip.y, + width: clip.width, + height: clip.height, + }, + }); + } +} diff --git a/src/helper.ts b/src/helper.ts index ef56e325da..dc560c1318 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -14,8 +14,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import * as debug from 'debug'; +import * as mime from 'mime'; import { TimeoutError } from './Errors'; +import * as types from './types'; export const debugError = debug(`playwright:error`); @@ -152,6 +155,43 @@ class Helper { clearTimeout(timeoutTimer); } } + + static validateScreeshotOptions(options: types.ScreenshotOptions): 'png' | 'jpeg' { + let format: 'png' | 'jpeg' | 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); + format = options.type; + } else if (options.path) { + const mimeType = mime.getType(options.path); + if (mimeType === 'image/png') + format = 'png'; + else if (mimeType === 'image/jpeg') + format = 'jpeg'; + assert(format, 'Unsupported screenshot mime type: ' + mimeType); + } + + if (!format) + format = 'png'; + + if (options.quality) { + assert(format === 'jpeg', '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); + } + assert(!options.clip || !options.fullPage, 'options.clip and options.fullPage are exclusive'); + if (options.clip) { + assert(typeof options.clip.x === 'number', 'Expected options.clip.x to be a number but found ' + (typeof options.clip.x)); + assert(typeof options.clip.y === 'number', 'Expected options.clip.y to be a number but found ' + (typeof options.clip.y)); + assert(typeof options.clip.width === 'number', 'Expected options.clip.width to be a number but found ' + (typeof options.clip.width)); + assert(typeof options.clip.height === 'number', 'Expected options.clip.height to be a number but found ' + (typeof options.clip.height)); + assert(options.clip.width !== 0, 'Expected options.clip.width not to be 0.'); + assert(options.clip.height !== 0, 'Expected options.clip.height not to be 0.'); + } + return format; + } } export function assert(value: any, message?: string) { diff --git a/src/types.ts b/src/types.ts index 96e31ada28..c6a968c46a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,3 +38,13 @@ export function clearSelector(selector: string | Selector): string | Selector { return selector; return { selector: selector.selector, visible: selector.visible }; } + +export type ScreenshotOptions = { + type?: 'png' | 'jpeg', + path?: string, + fullPage?: boolean, + clip?: Rect, + quality?: number, + omitBackground?: boolean, + encoding?: string, +}; diff --git a/src/webkit/Browser.ts b/src/webkit/Browser.ts index e54010b9aa..3fc05b20d6 100644 --- a/src/webkit/Browser.ts +++ b/src/webkit/Browser.ts @@ -22,13 +22,13 @@ import { filterCookies, NetworkCookie, rewriteCookies, SetNetworkCookieParam } f import { Connection } from './Connection'; import { Page, Viewport } from './Page'; import { Target } from './Target'; -import { TaskQueue } from './TaskQueue'; +import { Screenshotter } from './Screenshotter'; import { Protocol } from './protocol'; export class Browser extends EventEmitter { _defaultViewport: Viewport; private _process: childProcess.ChildProcess; - _screenshotTaskQueue = new TaskQueue(); + _screenshotter = new Screenshotter(); _connection: Connection; private _closeCallback: () => Promise; private _defaultContext: BrowserContext; @@ -62,7 +62,7 @@ export class Browser extends EventEmitter { ]; // Taking multiple screenshots in parallel doesn't work well, so we serialize them. - this._screenshotTaskQueue = new TaskQueue(); + this._screenshotter = new Screenshotter(); } async userAgent(): Promise { diff --git a/src/webkit/JSHandle.ts b/src/webkit/JSHandle.ts index b3a363a0d0..a4e26cd086 100644 --- a/src/webkit/JSHandle.ts +++ b/src/webkit/JSHandle.ts @@ -15,8 +15,7 @@ * limitations under the License. */ -import * as fs from 'fs'; -import { debugError, helper, assert } from '../helper'; +import { debugError, assert } from '../helper'; import * as input from '../input'; import * as dom from '../dom'; import * as frames from '../frames'; @@ -25,8 +24,6 @@ import { TargetSession } from './Connection'; import { FrameManager } from './FrameManager'; import { Protocol } from './protocol'; -const writeFileAsync = helper.promisify(fs.writeFile); - export class DOMWorldDelegate implements dom.DOMWorldDelegate { readonly keyboard: input.Keyboard; readonly mouse: input.Mouse; @@ -91,16 +88,9 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate { return this._frameManager._page.evaluate(() => ({ width: innerWidth, height: innerHeight })); } - async screenshot(handle: dom.ElementHandle, options: any = {}): Promise { - const objectId = toRemoteObject(handle).objectId; - this._client.send('DOM.getDocument'); - const {nodeId} = await this._client.send('DOM.requestNode', {objectId}); - const result = await this._client.send('Page.snapshotNode', {nodeId}); - const prefix = 'data:image/png;base64,'; - const buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64'); - if (options.path) - await writeFileAsync(options.path, buffer); - return buffer; + screenshot(handle: dom.ElementHandle, options?: types.ScreenshotOptions): Promise { + const page = this._frameManager._page; + return page._screenshotter.screenshotElement(page, handle, options); } async setInputFiles(handle: dom.ElementHandle, files: input.FilePayload[]): Promise { diff --git a/src/webkit/Page.ts b/src/webkit/Page.ts index 266baaebf5..0f391f5571 100644 --- a/src/webkit/Page.ts +++ b/src/webkit/Page.ts @@ -16,8 +16,6 @@ */ import { EventEmitter } from 'events'; -import * as fs from 'fs'; -import * as mime from 'mime'; import { assert, debugError, helper, RegisteredListener } from '../helper'; import { ClickOptions, mediaColorSchemes, mediaTypes, MultiClickOptions } from '../input'; import { TimeoutSettings } from '../TimeoutSettings'; @@ -28,7 +26,7 @@ import { FrameManager, FrameManagerEvents } from './FrameManager'; import { RawKeyboardImpl, RawMouseImpl } from './Input'; import { NetworkManagerEvents } from './NetworkManager'; import { Protocol } from './protocol'; -import { TaskQueue } from './TaskQueue'; +import { Screenshotter } from './Screenshotter'; import * as input from '../input'; import * as types from '../types'; import * as frames from '../frames'; @@ -38,8 +36,6 @@ import * as network from '../network'; import * as dialog from '../dialog'; import * as console from '../console'; -const writeFileAsync = helper.promisify(fs.writeFile); - export type Viewport = { width: number; height: number; @@ -58,22 +54,22 @@ export class Page extends EventEmitter { private _bootstrapScripts: string[] = []; _javascriptEnabled = true; private _viewport: Viewport | null = null; - private _screenshotTaskQueue: TaskQueue; + _screenshotter: Screenshotter; private _workers = new Map(); private _disconnectPromise: Promise | undefined; private _sessionListeners: RegisteredListener[] = []; private _emulatedMediaType: string | undefined; private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>(); - static async create(session: TargetSession, browserContext: BrowserContext, defaultViewport: Viewport | null, screenshotTaskQueue: TaskQueue): Promise { - const page = new Page(session, browserContext, screenshotTaskQueue); + static async create(session: TargetSession, browserContext: BrowserContext, defaultViewport: Viewport | null, screenshotter: Screenshotter): Promise { + const page = new Page(session, browserContext, screenshotter); await page._initialize(); if (defaultViewport) await page.setViewport(defaultViewport); return page; } - constructor(session: TargetSession, browserContext: BrowserContext, screenshotTaskQueue: TaskQueue) { + constructor(session: TargetSession, browserContext: BrowserContext, screenshotter: Screenshotter) { super(); this._closedPromise = new Promise(f => this._closedCallback = f); this._keyboard = new input.Keyboard(new RawKeyboardImpl(session)); @@ -81,7 +77,7 @@ export class Page extends EventEmitter { this._timeoutSettings = new TimeoutSettings(); this._frameManager = new FrameManager(session, this, this._timeoutSettings); - this._screenshotTaskQueue = screenshotTaskQueue; + this._screenshotter = screenshotter; this._setSession(session); this._browserContext = browserContext; @@ -371,63 +367,8 @@ export class Page extends EventEmitter { await this._frameManager.networkManager().setCacheEnabled(enabled); } - async screenshot(options: ScreenshotOptions = {}): Promise { - let screenshotType = 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', 'Unknown options.type value: ' + options.type); - screenshotType = options.type; - } else if (options.path) { - const mimeType = mime.getType(options.path); - if (mimeType === 'image/png') - screenshotType = 'png'; - assert(screenshotType, 'Unsupported screenshot mime type: ' + mimeType); - } - - if (!screenshotType) - screenshotType = 'png'; - - if (options.quality) - assert(screenshotType === 'jpeg', 'options.quality is unsupported for the ' + screenshotType + ' screenshots'); - assert(!options.clip || !options.fullPage, 'options.clip and options.fullPage are exclusive'); - if (options.clip) { - assert(typeof options.clip.x === 'number', 'Expected options.clip.x to be a number but found ' + (typeof options.clip.x)); - assert(typeof options.clip.y === 'number', 'Expected options.clip.y to be a number but found ' + (typeof options.clip.y)); - assert(typeof options.clip.width === 'number', 'Expected options.clip.width to be a number but found ' + (typeof options.clip.width)); - assert(typeof options.clip.height === 'number', 'Expected options.clip.height to be a number but found ' + (typeof options.clip.height)); - assert(options.clip.width !== 0, 'Expected options.clip.width not to be 0.'); - assert(options.clip.height !== 0, 'Expected options.clip.height not to be 0.'); - } - return this._screenshotTaskQueue.postTask(this._screenshotTask.bind(this, options)); - } - - async _screenshotTask(options?: ScreenshotOptions): Promise { - const params: Protocol.Page.snapshotRectParameters = { x: 0, y: 0, width: 800, height: 600, coordinateSystem: 'Page' }; - if (options.fullPage) { - const pageSize = await this.evaluate(() => - ({ - width: document.body.scrollWidth, - height: document.body.scrollHeight - })); - Object.assign(params, pageSize); - } else if (options.clip) { - Object.assign(params, options.clip); - } else if (this._viewport) { - Object.assign(params, this._viewport); - } - const [, result] = await Promise.all([ - this.browser()._activatePage(this), - this._session.send('Page.snapshotRect', params), - ]).catch(e => { - debugError('Failed to take screenshot: ' + e); - throw e; - }); - const prefix = 'data:image/png;base64,'; - const buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64'); - if (options.path) - await writeFileAsync(options.path, buffer); - return buffer; + screenshot(options?: types.ScreenshotOptions): Promise { + return this._screenshotter.screenshotPage(this, options); } async title(): Promise { @@ -540,16 +481,6 @@ type Metrics = { JSHeapTotalSize?: number, } -type ScreenshotOptions = { - type?: string, - path?: string, - fullPage?: boolean, - clip?: {x: number, y: number, width: number, height: number}, - quality?: number, - omitBackground?: boolean, - encoding?: string, -} - type FileChooser = { element: dom.ElementHandle, multiple: boolean diff --git a/src/webkit/Screenshotter.ts b/src/webkit/Screenshotter.ts new file mode 100644 index 0000000000..737e9f5113 --- /dev/null +++ b/src/webkit/Screenshotter.ts @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +import * as fs from 'fs'; +import { Page } from './Page'; +import { assert, helper, debugError } from '../helper'; +import { Protocol } from './protocol'; +import * as dom from '../dom'; +import * as types from '../types'; + +const writeFileAsync = helper.promisify(fs.writeFile); + +export class Screenshotter { + private _queue = new TaskQueue(); + + async screenshotPage(page: Page, options: types.ScreenshotOptions = {}): Promise { + const format = helper.validateScreeshotOptions(options); + assert(format === 'png', 'Only png format is supported'); + return this._queue.postTask(async () => { + const params: Protocol.Page.snapshotRectParameters = { x: 0, y: 0, width: 800, height: 600, coordinateSystem: 'Page' }; + if (options.fullPage) { + const pageSize = await page.evaluate(() => + ({ + width: document.body.scrollWidth, + height: document.body.scrollHeight + })); + Object.assign(params, pageSize); + } else if (options.clip) { + Object.assign(params, options.clip); + } else if (page.viewport()) { + Object.assign(params, page.viewport()); + } + const [, result] = await Promise.all([ + page.browser()._activatePage(page), + page._session.send('Page.snapshotRect', params), + ]).catch(e => { + debugError('Failed to take screenshot: ' + e); + throw e; + }); + const prefix = 'data:image/png;base64,'; + const buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64'); + if (options.path) + await writeFileAsync(options.path, buffer); + return buffer; + }); + } + + async screenshotElement(page: Page, handle: dom.ElementHandle, options: types.ScreenshotOptions = {}): Promise { + const format = helper.validateScreeshotOptions(options); + assert(format === 'png', 'Only png format is supported'); + return this._queue.postTask(async () => { + const objectId = (handle._remoteObject as Protocol.Runtime.RemoteObject).objectId; + page._session.send('DOM.getDocument'); + const {nodeId} = await page._session.send('DOM.requestNode', {objectId}); + const [, result] = await Promise.all([ + page.browser()._activatePage(page), + page._session.send('Page.snapshotNode', {nodeId}) + ]).catch(e => { + debugError('Failed to take screenshot: ' + e); + throw e; + }); + const prefix = 'data:image/png;base64,'; + const buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64'); + if (options.path) + await writeFileAsync(options.path, buffer); + return buffer; + }); + } +} + +class TaskQueue { + private _chain: Promise; + + constructor() { + this._chain = Promise.resolve(); + } + + postTask(task: () => any): Promise { + const result = this._chain.then(task); + this._chain = result.catch(() => {}); + return result; + } +} diff --git a/src/webkit/Target.ts b/src/webkit/Target.ts index 785fe6119c..481f3d8284 100644 --- a/src/webkit/Target.ts +++ b/src/webkit/Target.ts @@ -59,7 +59,7 @@ export class Target { async page(): Promise { if (this._type === 'page' && !this._pagePromise) { const session = this.browser()._connection.session(this._targetId); - this._pagePromise = Page.create(session, this._browserContext, this.browser()._defaultViewport, this.browser()._screenshotTaskQueue).then(page => { + this._pagePromise = Page.create(session, this._browserContext, this.browser()._defaultViewport, this.browser()._screenshotter).then(page => { this._adoptPage(page); return page; }); diff --git a/src/webkit/TaskQueue.ts b/src/webkit/TaskQueue.ts deleted file mode 100644 index 99b2de4469..0000000000 --- a/src/webkit/TaskQueue.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright 2019 Google Inc. All rights reserved. - * Modifications 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. - */ - -export class TaskQueue { - private _chain: Promise; - - constructor() { - this._chain = Promise.resolve(); - } - - postTask(task: () => any): Promise { - const result = this._chain.then(task); - this._chain = result.catch(() => {}); - return result; - } -}