diff --git a/docs/api.md b/docs/api.md index 39c3a2e702..57445e37bc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1560,7 +1560,7 @@ Page is guaranteed to have a main frame which persists during navigations. #### page.screenshot([options]) - `options` <[Object]> Options object which might have the following properties: - `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` <[string]> Specify screenshot type, can be either `jpeg` or `png`. Defaults to 'png'. + - `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]> An object which specifies clipping region of the page. Should have the following fields: diff --git a/src/chromium/Browser.ts b/src/chromium/Browser.ts index 3a07f7be5d..87b0c029a7 100644 --- a/src/chromium/Browser.ts +++ b/src/chromium/Browser.ts @@ -23,15 +23,15 @@ import { BrowserContext } from './BrowserContext'; import { Connection, ConnectionEvents, CDPSession } from './Connection'; import { Page, Viewport } from './Page'; import { Target } from './Target'; -import { TaskQueue } from './TaskQueue'; import { Protocol } from './protocol'; import { Chromium } from './features/chromium'; +import { Screenshotter } from './Screenshotter'; export class Browser extends EventEmitter { private _ignoreHTTPSErrors: boolean; private _defaultViewport: Viewport; private _process: childProcess.ChildProcess; - private _screenshotTaskQueue = new TaskQueue(); + private _screenshotter = new Screenshotter(); private _connection: Connection; _client: CDPSession; private _closeCallback: () => Promise; @@ -107,7 +107,7 @@ export class Browser extends EventEmitter { const {browserContextId} = targetInfo; const context = (browserContextId && this._contexts.has(browserContextId)) ? this._contexts.get(browserContextId) : this._defaultContext; - const target = new Target(targetInfo, context, () => this._connection.createSession(targetInfo), this._ignoreHTTPSErrors, this._defaultViewport, this._screenshotTaskQueue); + const target = new Target(targetInfo, context, () => this._connection.createSession(targetInfo), this._ignoreHTTPSErrors, this._defaultViewport, this._screenshotter); assert(!this._targets.has(event.targetInfo.targetId), 'Target should not exist before targetCreated'); this._targets.set(event.targetInfo.targetId, target); diff --git a/src/chromium/JSHandle.ts b/src/chromium/JSHandle.ts index 18212c70ef..722e250945 100644 --- a/src/chromium/JSHandle.ts +++ b/src/chromium/JSHandle.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { assert, debugError } from '../helper'; +import { debugError } from '../helper'; import * as dom from '../dom'; import * as input from '../input'; import * as types from '../types'; @@ -24,6 +24,7 @@ import { CDPSession } from './Connection'; import { FrameManager } from './FrameManager'; import { Protocol } from './protocol'; import { toRemoteObject, toHandle, ExecutionContextDelegate } from './ExecutionContext'; +import { ScreenshotOptions } from './Screenshotter'; export class DOMWorldDelegate implements dom.DOMWorldDelegate { readonly keyboard: input.Keyboard; @@ -71,45 +72,9 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate { return {x, y, width, height}; } - async screenshot(handle: dom.ElementHandle, options: any = {}): Promise { - let needsViewportReset = false; - - let boundingBox = await this.boundingBox(handle); - assert(boundingBox, 'Node is either not visible or not an HTMLElement'); - - const viewport = this._frameManager.page().viewport(); - - if (viewport && (boundingBox.width > viewport.width || boundingBox.height > viewport.height)) { - const newViewport = { - width: Math.max(viewport.width, Math.ceil(boundingBox.width)), - height: Math.max(viewport.height, Math.ceil(boundingBox.height)), - }; - await this._frameManager.page().setViewport(Object.assign({}, viewport, newViewport)); - - needsViewportReset = true; - } - - await handle._scrollIntoViewIfNeeded(); - - boundingBox = await this.boundingBox(handle); - assert(boundingBox, 'Node is either not visible or not an HTMLElement'); - assert(boundingBox.width !== 0, 'Node has 0 width.'); - assert(boundingBox.height !== 0, 'Node has 0 height.'); - - const { layoutViewport: { pageX, pageY } } = await this._client.send('Page.getLayoutMetrics'); - - const clip = Object.assign({}, boundingBox); - clip.x += pageX; - clip.y += pageY; - - const imageData = await this._frameManager.page().screenshot(Object.assign({}, { - clip - }, options)); - - if (needsViewportReset) - await this._frameManager.page().setViewport(viewport); - - return imageData; + screenshot(handle: dom.ElementHandle, options: ScreenshotOptions = {}): Promise { + const page = this._frameManager.page(); + return page._screenshotter.screenshotElement(page, handle, options); } async ensurePointerActionPoint(handle: dom.ElementHandle, relativePoint?: types.Point): Promise { diff --git a/src/chromium/Page.ts b/src/chromium/Page.ts index addc749843..dbbbd71177 100644 --- a/src/chromium/Page.ts +++ b/src/chromium/Page.ts @@ -16,8 +16,6 @@ */ import { EventEmitter } from 'events'; -import * as fs from 'fs'; -import * as mime from 'mime'; import { assert, debugError, helper } from '../helper'; import { ClickOptions, MultiClickOptions, PointerActionOptions, SelectOption, mediaTypes, mediaColorSchemes } from '../input'; import { TimeoutSettings } from '../TimeoutSettings'; @@ -39,7 +37,6 @@ import { NetworkManagerEvents } from './NetworkManager'; import { Protocol } from './protocol'; import { getExceptionMessage, releaseObject, valueFromRemoteObject } from './protocolHelper'; import { Target } from './Target'; -import { TaskQueue } from './TaskQueue'; import * as input from '../input'; import * as types from '../types'; import * as dom from '../dom'; @@ -48,8 +45,7 @@ import * as js from '../javascript'; import * as network from '../network'; import * as dialog from '../dialog'; import { DOMWorldDelegate } from './JSHandle'; - -const writeFileAsync = helper.promisify(fs.writeFile); +import { Screenshotter, ScreenshotOptions } from './Screenshotter'; export type Viewport = { width: number; @@ -78,20 +74,20 @@ export class Page extends EventEmitter { private _pageBindings = new Map(); _javascriptEnabled = true; private _viewport: Viewport | null = null; - private _screenshotTaskQueue: TaskQueue; + _screenshotter: Screenshotter; private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>(); private _disconnectPromise: Promise | undefined; private _emulatedMediaType: string | undefined; - static async create(client: CDPSession, target: Target, ignoreHTTPSErrors: boolean, defaultViewport: Viewport | null, screenshotTaskQueue: TaskQueue): Promise { - const page = new Page(client, target, ignoreHTTPSErrors, screenshotTaskQueue); + static async create(client: CDPSession, target: Target, ignoreHTTPSErrors: boolean, defaultViewport: Viewport | null, screenshotter: Screenshotter): Promise { + const page = new Page(client, target, ignoreHTTPSErrors, screenshotter); await page._initialize(); if (defaultViewport) await page.setViewport(defaultViewport); return page; } - constructor(client: CDPSession, target: Target, ignoreHTTPSErrors: boolean, screenshotTaskQueue: TaskQueue) { + constructor(client: CDPSession, target: Target, ignoreHTTPSErrors: boolean, screenshotter: Screenshotter) { super(); this._client = client; this._target = target; @@ -107,7 +103,7 @@ export class Page extends EventEmitter { this.overrides = new Overrides(client); this.interception = new Interception(this._frameManager.networkManager()); - this._screenshotTaskQueue = screenshotTaskQueue; + this._screenshotter = screenshotter; client.on('Target.attachedToTarget', event => { if (event.targetInfo.type !== 'worker') { @@ -521,84 +517,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' || options.type === 'jpeg', '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'; - else if (mimeType === 'image/jpeg') - screenshotType = 'jpeg'; - 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(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 this._screenshotTaskQueue.postTask(this._screenshotTask.bind(this, screenshotType, options)); - } - - async _screenshotTask(format: 'png' | 'jpeg', options?: ScreenshotOptions): Promise { - await this._client.send('Target.activateTarget', {targetId: this._target._targetId}); - let clip = options.clip ? processClip(options.clip) : undefined; - - if (options.fullPage) { - const metrics = await this._client.send('Page.getLayoutMetrics'); - const width = Math.ceil(metrics.contentSize.width); - const height = Math.ceil(metrics.contentSize.height); - - // Overwrite clip for full page at all times. - clip = { x: 0, y: 0, width, height, scale: 1 }; - const { - isMobile = false, - deviceScaleFactor = 1, - isLandscape = false - } = this._viewport || {}; - const screenOrientation: Protocol.Emulation.ScreenOrientation = isLandscape ? { angle: 90, type: 'landscapePrimary' } : { angle: 0, type: 'portraitPrimary' }; - await this._client.send('Emulation.setDeviceMetricsOverride', { mobile: isMobile, width, height, deviceScaleFactor, screenOrientation }); - } - const shouldSetDefaultBackground = options.omitBackground && format === 'png'; - if (shouldSetDefaultBackground) - await this._client.send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } }); - const result = await this._client.send('Page.captureScreenshot', { format, quality: options.quality, clip }); - if (shouldSetDefaultBackground) - await this._client.send('Emulation.setDefaultBackgroundColorOverride'); - - if (options.fullPage && this._viewport) - await this.setViewport(this._viewport); - - const buffer = options.encoding === 'base64' ? result.data : Buffer.from(result.data, 'base64'); - if (options.path) - await writeFileAsync(options.path, buffer); - return buffer; - - function processClip(clip) { - 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, scale: 1}; - } + screenshot(options: ScreenshotOptions = {}): Promise { + return this._screenshotter.screenshotPage(this, options); } async title(): Promise { @@ -676,16 +596,6 @@ export class Page extends EventEmitter { } } -type ScreenshotOptions = { - type?: string, - path?: string, - fullPage?: boolean, - clip?: {x: number, y: number, width: number, height: number}, - quality?: number, - omitBackground?: boolean, - encoding?: string, -} - type MediaFeature = { name: string, value: string diff --git a/src/chromium/Screenshotter.ts b/src/chromium/Screenshotter.ts new file mode 100644 index 0000000000..1bc6d01d52 --- /dev/null +++ b/src/chromium/Screenshotter.ts @@ -0,0 +1,181 @@ +/** + * 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. + */ + +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'; + +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); + return this._queue.postTask(() => this._screenshot(page, format, options)); + } + + async screenshotElement(page: Page, handle: dom.ElementHandle, options: ScreenshotOptions = {}): Promise { + const format = this._format(options); + return this._queue.postTask(async () => { + let needsViewportReset = false; + + let boundingBox = await handle.boundingBox(); + assert(boundingBox, 'Node is either not visible or not an HTMLElement'); + + const viewport = page.viewport(); + + if (viewport && (boundingBox.width > viewport.width || boundingBox.height > viewport.height)) { + const newViewport = { + width: Math.max(viewport.width, Math.ceil(boundingBox.width)), + height: Math.max(viewport.height, Math.ceil(boundingBox.height)), + }; + await page.setViewport(Object.assign({}, viewport, newViewport)); + + needsViewportReset = true; + } + + await handle._scrollIntoViewIfNeeded(); + + boundingBox = await handle.boundingBox(); + assert(boundingBox, 'Node is either not visible or not an HTMLElement'); + assert(boundingBox.width !== 0, 'Node has 0 width.'); + assert(boundingBox.height !== 0, 'Node has 0 height.'); + + const { layoutViewport: { pageX, pageY } } = await page._client.send('Page.getLayoutMetrics'); + + const clip = Object.assign({}, boundingBox); + clip.x += pageX; + clip.y += pageY; + + const imageData = await this._screenshot(page, format, {...options, clip}); + + if (needsViewportReset) + await page.setViewport(viewport); + + return imageData; + }); + } + + private async _screenshot(page: Page, format: 'png' | 'jpeg', options: ScreenshotOptions): Promise { + await page._client.send('Target.activateTarget', {targetId: page.target()._targetId}); + let clip = options.clip ? processClip(options.clip) : undefined; + const viewport = page.viewport(); + + if (options.fullPage) { + const metrics = await page._client.send('Page.getLayoutMetrics'); + const width = Math.ceil(metrics.contentSize.width); + const height = Math.ceil(metrics.contentSize.height); + + // Overwrite clip for full page at all times. + clip = { x: 0, y: 0, width, height, scale: 1 }; + const { + isMobile = false, + deviceScaleFactor = 1, + isLandscape = false + } = viewport || {}; + const screenOrientation: Protocol.Emulation.ScreenOrientation = isLandscape ? { angle: 90, type: 'landscapePrimary' } : { angle: 0, type: 'portraitPrimary' }; + await page._client.send('Emulation.setDeviceMetricsOverride', { mobile: isMobile, width, height, deviceScaleFactor, screenOrientation }); + } + const shouldSetDefaultBackground = options.omitBackground && format === 'png'; + if (shouldSetDefaultBackground) + await page._client.send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } }); + const result = await page._client.send('Page.captureScreenshot', { format, quality: options.quality, clip }); + if (shouldSetDefaultBackground) + await page._client.send('Emulation.setDefaultBackgroundColorOverride'); + + if (options.fullPage && viewport) + await page.setViewport(viewport); + + const buffer = options.encoding === 'base64' ? result.data : Buffer.from(result.data, 'base64'); + if (options.path) + await writeFileAsync(options.path, buffer); + return buffer; + + function processClip(clip) { + 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, 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 { + 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/chromium/Target.ts b/src/chromium/Target.ts index 3c39b35b55..6fba007ea1 100644 --- a/src/chromium/Target.ts +++ b/src/chromium/Target.ts @@ -22,7 +22,7 @@ import { Events } from './events'; import { Worker } from './features/workers'; import { Page, Viewport } from './Page'; import { Protocol } from './protocol'; -import { TaskQueue } from './TaskQueue'; +import { Screenshotter } from './Screenshotter'; export class Target { private _targetInfo: Protocol.Target.TargetInfo; @@ -31,7 +31,7 @@ export class Target { _sessionFactory: () => Promise; private _ignoreHTTPSErrors: boolean; private _defaultViewport: Viewport; - private _screenshotTaskQueue: TaskQueue; + private _screenshotter: Screenshotter; private _pagePromise: Promise | null = null; private _workerPromise: Promise | null = null; _initializedPromise: Promise; @@ -46,14 +46,14 @@ export class Target { sessionFactory: () => Promise, ignoreHTTPSErrors: boolean, defaultViewport: Viewport | null, - screenshotTaskQueue: TaskQueue) { + screenshotter: Screenshotter) { this._targetInfo = targetInfo; this._browserContext = browserContext; this._targetId = targetInfo.targetId; this._sessionFactory = sessionFactory; this._ignoreHTTPSErrors = ignoreHTTPSErrors; this._defaultViewport = defaultViewport; - this._screenshotTaskQueue = screenshotTaskQueue; + this._screenshotter = screenshotter; this._initializedPromise = new Promise(fulfill => this._initializedCallback = fulfill).then(async success => { if (!success) return false; @@ -76,7 +76,7 @@ export class Target { async page(): Promise { if ((this._targetInfo.type === 'page' || this._targetInfo.type === 'background_page') && !this._pagePromise) { this._pagePromise = this._sessionFactory() - .then(client => Page.create(client, this, this._ignoreHTTPSErrors, this._defaultViewport, this._screenshotTaskQueue)); + .then(client => Page.create(client, this, this._ignoreHTTPSErrors, this._defaultViewport, this._screenshotter)); } return this._pagePromise; } diff --git a/src/chromium/TaskQueue.ts b/src/chromium/TaskQueue.ts deleted file mode 100644 index 99b2de4469..0000000000 --- a/src/chromium/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; - } -} diff --git a/test/screenshot.spec.js b/test/screenshot.spec.js index f50aae32ab..47b4acabe9 100644 --- a/test/screenshot.spec.js +++ b/test/screenshot.spec.js @@ -151,6 +151,33 @@ module.exports.addTests = function({testRunner, expect, product, FFOX, CHROME, W const screenshot = await elementHandle.screenshot(); expect(screenshot).toBeGolden('screenshot-element-padding-border.png'); }); + it('should capture full element when larger than viewport in parallel', async({page, server}) => { + await page.setViewport({width: 500, height: 500}); + + await page.setContent(` + something above + +
+
+
+ `); + const elementHandles = await page.$$('div.to-screenshot'); + const promises = elementHandles.map(handle => handle.screenshot()); + const screenshots = await Promise.all(promises); + expect(screenshots[2]).toBeGolden('screenshot-element-larger-than-viewport.png'); + + expect(await page.evaluate(() => ({ w: window.innerWidth, h: window.innerHeight }))).toEqual({ w: 500, h: 500 }); + }); it('should capture full element when larger than viewport', async({page, server}) => { await page.setViewport({width: 500, height: 500}); @@ -168,6 +195,8 @@ module.exports.addTests = function({testRunner, expect, product, FFOX, CHROME, W }
+
+
`); const elementHandle = await page.$('div.to-screenshot'); const screenshot = await elementHandle.screenshot();