feat(screenshot): multiple element screenshots are now taken sequentially (#114)

This makes multiple element screenshots to not fight for the page viewport
by putting viewport manipulation under screenshot task queue.

Drive-by: encapsulated all screenshot logic in Screenshotter.
This commit is contained in:
Dmitry Gozman 2019-12-02 10:53:58 -08:00 committed by GitHub
parent 76ab83f581
commit b6c892842b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 232 additions and 177 deletions

View file

@ -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:

View file

@ -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<void>;
@ -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);

View file

@ -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<string | Buffer> {
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<string | Buffer> {
const page = this._frameManager.page();
return page._screenshotter.screenshotElement(page, handle, options);
}
async ensurePointerActionPoint(handle: dom.ElementHandle, relativePoint?: types.Point): Promise<types.Point> {

View file

@ -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<string, Function>();
_javascriptEnabled = true;
private _viewport: Viewport | null = null;
private _screenshotTaskQueue: TaskQueue;
_screenshotter: Screenshotter;
private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>();
private _disconnectPromise: Promise<Error> | undefined;
private _emulatedMediaType: string | undefined;
static async create(client: CDPSession, target: Target, ignoreHTTPSErrors: boolean, defaultViewport: Viewport | null, screenshotTaskQueue: TaskQueue): Promise<Page> {
const page = new Page(client, target, ignoreHTTPSErrors, screenshotTaskQueue);
static async create(client: CDPSession, target: Target, ignoreHTTPSErrors: boolean, defaultViewport: Viewport | null, screenshotter: Screenshotter): Promise<Page> {
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<Buffer | string> {
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<Buffer | string> {
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<Buffer | string> {
return this._screenshotter.screenshotPage(this, options);
}
async title(): Promise<string> {
@ -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

View file

@ -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<Buffer | string> {
const format = this._format(options);
return this._queue.postTask(() => this._screenshot(page, format, options));
}
async screenshotElement(page: Page, handle: dom.ElementHandle, options: ScreenshotOptions = {}): Promise<string | Buffer> {
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<Buffer | string> {
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<any>;
constructor() {
this._chain = Promise.resolve();
}
postTask(task: () => any): Promise<any> {
const result = this._chain.then(task);
this._chain = result.catch(() => {});
return result;
}
}

View file

@ -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<CDPSession>;
private _ignoreHTTPSErrors: boolean;
private _defaultViewport: Viewport;
private _screenshotTaskQueue: TaskQueue;
private _screenshotter: Screenshotter;
private _pagePromise: Promise<Page> | null = null;
private _workerPromise: Promise<Worker> | null = null;
_initializedPromise: Promise<boolean>;
@ -46,14 +46,14 @@ export class Target {
sessionFactory: () => Promise<CDPSession>,
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<Page | null> {
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;
}

View file

@ -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<any>;
constructor() {
this._chain = Promise.resolve();
}
postTask(task: () => any): Promise<any> {
const result = this._chain.then(task);
this._chain = result.catch(() => {});
return result;
}
}

View file

@ -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
<style>
div.to-screenshot {
border: 1px solid blue;
width: 600px;
height: 600px;
margin-left: 50px;
}
::-webkit-scrollbar{
display: none;
}
</style>
<div class="to-screenshot"></div>
<div class="to-screenshot"></div>
<div class="to-screenshot"></div>
`);
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
}
</style>
<div class="to-screenshot"></div>
<div class="to-screenshot"></div>
<div class="to-screenshot"></div>
`);
const elementHandle = await page.$('div.to-screenshot');
const screenshot = await elementHandle.screenshot();