feat(screenshots): make them work everywhere (#164)

This commit is contained in:
Pavel Feldman 2019-12-06 11:33:24 -08:00 committed by Dmitry Gozman
parent 57313e3f73
commit bb1888c86e
55 changed files with 453 additions and 458 deletions

View File

@ -1506,8 +1506,7 @@ Page is guaranteed to have a main frame which persists during navigations.
- `width` <[number]> width of clipping area
- `height` <[number]> height of clipping area
- `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 `encoding`) with captured screenshot.
- returns: <[Promise]<[Buffer]>> Promise which resolves to buffer with the captured screenshot.
> **NOTE** Screenshots take at least 1/6 second on OS X. See https://crbug.com/741689 for discussion.
@ -3456,19 +3455,17 @@ If `key` is a single character and no modifier keys besides `Shift` are being he
> **NOTE** Modifier keys DO effect `elementHandle.press`. Holding down `Shift` will type the text in upper case.
#### elementHandle.screenshot([options])
- `options` <[Object]> Same options as in [page.screenshot](#pagescreenshotoptions).
- `options` <[Object]> Screenshot options.
- `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.
- returns: <[Promise]<|[Buffer]>> Promise which resolves to buffer with the captured screenshot.
This method scrolls element into view if needed, and then uses [page.screenshot](#pagescreenshotoptions) to take a screenshot of the element.
If the element is detached from DOM, the method throws an error.

View File

@ -10,7 +10,7 @@
"playwright": {
"chromium_revision": "719491",
"firefox_revision": "1004",
"webkit_revision": "1011"
"webkit_revision": "1015"
},
"scripts": {
"unit": "node test/test.js",

View File

@ -21,17 +21,16 @@ import { Events } from './events';
import { assert, helper } from '../helper';
import { BrowserContext } from './BrowserContext';
import { Connection, ConnectionEvents, CDPSession } from './Connection';
import { Page, Viewport } from './Page';
import { Page } from './Page';
import { Target } from './Target';
import { Protocol } from './protocol';
import { Chromium } from './features/chromium';
import { Screenshotter } from './Screenshotter';
import * as types from '../types';
export class Browser extends EventEmitter {
private _ignoreHTTPSErrors: boolean;
private _defaultViewport: Viewport;
private _defaultViewport: types.Viewport;
private _process: childProcess.ChildProcess;
private _screenshotter = new Screenshotter();
_connection: Connection;
_client: CDPSession;
private _closeCallback: () => Promise<void>;
@ -44,7 +43,7 @@ export class Browser extends EventEmitter {
connection: Connection,
contextIds: string[],
ignoreHTTPSErrors: boolean,
defaultViewport: Viewport | null,
defaultViewport: types.Viewport | null,
process: childProcess.ChildProcess | null,
closeCallback?: (() => Promise<void>)) {
const browser = new Browser(connection, contextIds, ignoreHTTPSErrors, defaultViewport, process, closeCallback);
@ -56,7 +55,7 @@ export class Browser extends EventEmitter {
connection: Connection,
contextIds: string[],
ignoreHTTPSErrors: boolean,
defaultViewport: Viewport | null,
defaultViewport: types.Viewport | null,
process: childProcess.ChildProcess | null,
closeCallback?: (() => Promise<void>)) {
super();
@ -107,7 +106,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._screenshotter);
const target = new Target(targetInfo, context, () => this._connection.createSession(targetInfo), this._ignoreHTTPSErrors, this._defaultViewport);
assert(!this._targets.has(event.targetInfo.targetId), 'Target should not exist before targetCreated');
this._targets.set(event.targetInfo.targetId, target);

View File

@ -16,8 +16,8 @@
*/
import { CDPSession } from './Connection';
import { Viewport } from './Page';
import { Protocol } from './protocol';
import * as types from '../types';
export class EmulationManager {
private _client: CDPSession;
@ -28,7 +28,7 @@ export class EmulationManager {
this._client = client;
}
async emulateViewport(viewport: Viewport): Promise<boolean> {
async emulateViewport(viewport: types.Viewport): Promise<boolean> {
const mobile = viewport.isMobile || false;
const width = viewport.width;
const height = viewport.height;

View File

@ -90,9 +90,9 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate {
return { width: layoutMetrics.layoutViewport.clientWidth, height: layoutMetrics.layoutViewport.clientHeight };
}
screenshot(handle: dom.ElementHandle, options?: types.ScreenshotOptions): Promise<string | Buffer> {
screenshot(handle: dom.ElementHandle, options?: types.ElementScreenshotOptions): Promise<Buffer> {
const page = this._frameManager.page();
return page._screenshotter.screenshotElement(page, handle, options);
return page._screenshotter.screenshotElement(handle, options);
}
async setInputFiles(handle: dom.ElementHandle, files: input.FilePayload[]): Promise<void> {

View File

@ -28,7 +28,7 @@ import { BrowserFetcher } from './BrowserFetcher';
import { Connection } from './Connection';
import { TimeoutError } from '../Errors';
import { assert, debugError, helper } from '../helper';
import { Viewport } from './Page';
import * as types from '../types';
import { PipeTransport } from './PipeTransport';
import { WebSocketTransport } from './WebSocketTransport';
import { ConnectionTransport } from '../ConnectionTransport';
@ -392,6 +392,6 @@ export type LauncherLaunchOptions = {
export type LauncherBrowserOptions = {
ignoreHTTPSErrors?: boolean,
defaultViewport?: Viewport | null,
defaultViewport?: types.Viewport | null,
slowMo?: number,
};

View File

@ -16,9 +16,18 @@
*/
import { EventEmitter } from 'events';
import * as console from '../console';
import * as dialog from '../dialog';
import * as dom from '../dom';
import * as frames from '../frames';
import { assert, debugError, helper } from '../helper';
import { ClickOptions, MultiClickOptions, PointerActionOptions, SelectOption, mediaTypes, mediaColorSchemes } from '../input';
import * as input from '../input';
import { ClickOptions, mediaColorSchemes, mediaTypes, MultiClickOptions, PointerActionOptions, SelectOption } from '../input';
import * as js from '../javascript';
import * as network from '../network';
import { Screenshotter } from '../screenshotter';
import { TimeoutSettings } from '../TimeoutSettings';
import * as types from '../types';
import { Browser } from './Browser';
import { BrowserContext } from './BrowserContext';
import { CDPSession, CDPSessionEvents } from './Connection';
@ -26,34 +35,17 @@ import { EmulationManager } from './EmulationManager';
import { Events } from './events';
import { Accessibility } from './features/accessibility';
import { Coverage } from './features/coverage';
import { Overrides } from './features/overrides';
import { Interception } from './features/interception';
import { Overrides } from './features/overrides';
import { PDF } from './features/pdf';
import { Workers } from './features/workers';
import { FrameManager, FrameManagerEvents } from './FrameManager';
import { RawMouseImpl, RawKeyboardImpl } from './Input';
import { RawKeyboardImpl, RawMouseImpl } from './Input';
import { DOMWorldDelegate } from './JSHandle';
import { NetworkManagerEvents } from './NetworkManager';
import { Protocol } from './protocol';
import { getExceptionMessage, releaseObject } from './protocolHelper';
import * as input from '../input';
import * as types from '../types';
import * as frames from '../frames';
import * as js from '../javascript';
import * as dom from '../dom';
import * as network from '../network';
import * as dialog from '../dialog';
import * as console from '../console';
import { DOMWorldDelegate } from './JSHandle';
import { Screenshotter } from './Screenshotter';
export type Viewport = {
width: number;
height: number;
deviceScaleFactor?: number;
isMobile?: boolean;
isLandscape?: boolean;
hasTouch?: boolean;
}
import { CRScreenshotDelegate } from './Screenshotter';
export class Page extends EventEmitter {
private _closed = false;
@ -74,21 +66,21 @@ export class Page extends EventEmitter {
readonly workers: Workers;
private _pageBindings = new Map<string, Function>();
_javascriptEnabled = true;
private _viewport: Viewport | null = null;
private _viewport: types.Viewport | null = null;
_screenshotter: Screenshotter;
private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>();
private _disconnectPromise: Promise<Error> | undefined;
private _emulatedMediaType: string | undefined;
static async create(client: CDPSession, browserContext: BrowserContext, ignoreHTTPSErrors: boolean, defaultViewport: Viewport | null, screenshotter: Screenshotter): Promise<Page> {
const page = new Page(client, browserContext, ignoreHTTPSErrors, screenshotter);
static async create(client: CDPSession, browserContext: BrowserContext, ignoreHTTPSErrors: boolean, defaultViewport: types.Viewport | null): Promise<Page> {
const page = new Page(client, browserContext, ignoreHTTPSErrors);
await page._initialize();
if (defaultViewport)
await page.setViewport(defaultViewport);
return page;
}
constructor(client: CDPSession, browserContext: BrowserContext, ignoreHTTPSErrors: boolean, screenshotter: Screenshotter) {
constructor(client: CDPSession, browserContext: BrowserContext, ignoreHTTPSErrors: boolean) {
super();
this._client = client;
this._closedPromise = new Promise(f => this._closedCallback = f);
@ -104,8 +96,7 @@ export class Page extends EventEmitter {
this.workers = new Workers(client, this._addConsoleMessage.bind(this), this._handleException.bind(this));
this.overrides = new Overrides(client);
this.interception = new Interception(this._frameManager.networkManager());
this._screenshotter = screenshotter;
this._screenshotter = new Screenshotter(this, new CRScreenshotDelegate(this._client), browserContext.browser());
client.on('Target.attachedToTarget', event => {
if (event.targetInfo.type !== 'worker') {
@ -456,7 +447,7 @@ export class Page extends EventEmitter {
return response;
}
async emulate(options: { viewport: Viewport; userAgent: string; }) {
async emulate(options: { viewport: types.Viewport; userAgent: string; }) {
await Promise.all([
this.setViewport(options.viewport),
this.setUserAgent(options.userAgent)
@ -485,14 +476,14 @@ export class Page extends EventEmitter {
this._emulatedMediaType = options.type;
}
async setViewport(viewport: Viewport) {
async setViewport(viewport: types.Viewport) {
const needsReload = await this._emulationManager.emulateViewport(viewport);
this._viewport = viewport;
if (needsReload)
await this.reload();
}
viewport(): Viewport | null {
viewport(): types.Viewport | null {
return this._viewport;
}
@ -509,8 +500,8 @@ export class Page extends EventEmitter {
await this._frameManager.networkManager().setCacheEnabled(enabled);
}
screenshot(options?: types.ScreenshotOptions): Promise<Buffer | string> {
return this._screenshotter.screenshotPage(this, options);
screenshot(options?: types.ScreenshotOptions): Promise<Buffer> {
return this._screenshotter.screenshotPage(options);
}
async title(): Promise<string> {
@ -585,11 +576,6 @@ export class Page extends EventEmitter {
}
}
type MediaFeature = {
name: string,
value: string
}
type FileChooser = {
element: dom.ElementHandle,
multiple: boolean

View File

@ -1,134 +1,40 @@
/**
* 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.
*/
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import * as fs from 'fs';
import { Page } from './Page';
import { assert, helper } from '../helper';
import { Protocol } from './protocol';
import * as dom from '../dom';
import { ScreenshotterDelegate } from '../screenshotter';
import * as types from '../types';
import { CDPSession } from './api';
const writeFileAsync = helper.promisify(fs.writeFile);
export class CRScreenshotDelegate implements ScreenshotterDelegate {
private _session: CDPSession;
export class Screenshotter {
private _queue = new TaskQueue();
async screenshotPage(page: Page, options: types.ScreenshotOptions = {}): Promise<Buffer | string> {
const format = helper.validateScreeshotOptions(options);
return this._queue.postTask(() => this._screenshot(page, format, options));
constructor(session: CDPSession) {
this._session = session;
}
async screenshotElement(page: Page, handle: dom.ElementHandle, options: types.ScreenshotOptions = {}): Promise<string | Buffer> {
const format = helper.validateScreeshotOptions(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;
});
async getBoundingBox(handle: dom.ElementHandle<Node>): Promise<types.Rect | undefined> {
const rect = await handle.boundingBox();
if (!rect)
return rect;
const { layoutViewport: { pageX, pageY } } = await this._session.send('Page.getLayoutMetrics');
rect.x += pageX;
rect.y += pageY;
return rect;
}
private async _screenshot(page: Page, format: 'png' | 'jpeg', options: types.ScreenshotOptions): Promise<Buffer | string> {
await page.browser()._activatePage(page);
let clip = options.clip ? processClip(options.clip) : undefined;
const viewport = page.viewport();
canCaptureOutsideViewport(): boolean {
return false;
}
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);
async setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise<void> {
await this._session.send('Emulation.setDefaultBackgroundColorOverride', { color });
}
// 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};
}
}
}
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;
async screenshot(format: 'png' | 'jpeg', options: types.ScreenshotOptions): Promise<Buffer> {
const clip = options.clip ? { ...options.clip, scale: 1 } : undefined;
const result = await this._session.send('Page.captureScreenshot', { format, quality: options.quality, clip });
return Buffer.from(result.data, 'base64');
}
}

View File

@ -15,14 +15,14 @@
* limitations under the License.
*/
import * as types from '../types';
import { Browser } from './Browser';
import { BrowserContext } from './BrowserContext';
import { CDPSession } from './Connection';
import { Events } from './events';
import { Worker } from './features/workers';
import { Page, Viewport } from './Page';
import { Page } from './Page';
import { Protocol } from './protocol';
import { Screenshotter } from './Screenshotter';
const targetSymbol = Symbol('target');
@ -32,8 +32,7 @@ export class Target {
_targetId: string;
private _sessionFactory: () => Promise<CDPSession>;
private _ignoreHTTPSErrors: boolean;
private _defaultViewport: Viewport;
private _screenshotter: Screenshotter;
private _defaultViewport: types.Viewport;
private _pagePromise: Promise<Page> | null = null;
private _workerPromise: Promise<Worker> | null = null;
_initializedPromise: Promise<boolean>;
@ -49,15 +48,13 @@ export class Target {
browserContext: BrowserContext,
sessionFactory: () => Promise<CDPSession>,
ignoreHTTPSErrors: boolean,
defaultViewport: Viewport | null,
screenshotter: Screenshotter) {
defaultViewport: types.Viewport | null) {
this._targetInfo = targetInfo;
this._browserContext = browserContext;
this._targetId = targetInfo.targetId;
this._sessionFactory = sessionFactory;
this._ignoreHTTPSErrors = ignoreHTTPSErrors;
this._defaultViewport = defaultViewport;
this._screenshotter = screenshotter;
this._initializedPromise = new Promise(fulfill => this._initializedCallback = fulfill).then(async success => {
if (!success)
return false;
@ -84,7 +81,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(async client => {
const page = await Page.create(client, this._browserContext, this._ignoreHTTPSErrors, this._defaultViewport, this._screenshotter);
const page = await Page.create(client, this._browserContext, this._ignoreHTTPSErrors, this._defaultViewport);
page[targetSymbol] = this;
return page;
});

View File

@ -10,7 +10,6 @@ import * as cssSelectorEngineSource from './generated/cssSelectorEngineSource';
import * as xpathSelectorEngineSource from './generated/xpathSelectorEngineSource';
import { assert, helper, debugError } from './helper';
import Injected from './injected/injected';
import { SelectorRoot } from './injected/selectorEngine';
export interface DOMWorldDelegate {
keyboard: input.Keyboard;
@ -146,7 +145,7 @@ export class DOMWorld {
}
export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
private readonly _world: DOMWorld;
readonly _world: DOMWorld;
constructor(context: js.ExecutionContext, remoteObject: any) {
super(context, remoteObject);
@ -258,8 +257,10 @@ export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
private async _viewportPointAndScroll(relativePoint: types.Point): Promise<{point: types.Point, scrollX: number, scrollY: number}> {
const [box, border] = await Promise.all([
this.boundingBox(),
this.evaluate((e: Element) => {
const style = e.ownerDocument.defaultView.getComputedStyle(e);
this.evaluate((node: Node) => {
if (node.nodeType !== Node.ELEMENT_NODE)
return { x: 0, y: 0 };
const style = node.ownerDocument.defaultView.getComputedStyle(node as Element);
return { x: parseInt(style.borderLeftWidth, 10), y: parseInt(style.borderTopWidth, 10) };
}).catch(debugError),
]);

View File

@ -21,11 +21,12 @@ import { filterCookies, NetworkCookie, SetNetworkCookieParam, rewriteCookies } f
import { Connection, ConnectionEvents } from './Connection';
import { Events } from './events';
import { Permissions } from './features/permissions';
import { Page, Viewport } from './Page';
import { Page } from './Page';
import * as types from '../types';
export class Browser extends EventEmitter {
private _connection: Connection;
_defaultViewport: Viewport;
_defaultViewport: types.Viewport;
private _process: import('child_process').ChildProcess;
private _closeCallback: () => void;
_targets: Map<string, Target>;
@ -33,14 +34,14 @@ export class Browser extends EventEmitter {
private _contexts: Map<string, BrowserContext>;
private _eventListeners: RegisteredListener[];
static async create(connection: Connection, defaultViewport: Viewport | null, process: import('child_process').ChildProcess | null, closeCallback: () => void) {
static async create(connection: Connection, defaultViewport: types.Viewport | null, process: import('child_process').ChildProcess | null, closeCallback: () => void) {
const {browserContextIds} = await connection.send('Target.getBrowserContexts');
const browser = new Browser(connection, browserContextIds, defaultViewport, process, closeCallback);
await connection.send('Target.enable');
return browser;
}
constructor(connection: Connection, browserContextIds: Array<string>, defaultViewport: Viewport | null, process: import('child_process').ChildProcess | null, closeCallback: () => void) {
constructor(connection: Connection, browserContextIds: Array<string>, defaultViewport: types.Viewport | null, process: import('child_process').ChildProcess | null, closeCallback: () => void) {
super();
this._connection = connection;
this._defaultViewport = defaultViewport;

View File

@ -93,9 +93,9 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate {
return this._frameManager._page.evaluate(() => ({ width: innerWidth, height: innerHeight }));
}
async screenshot(handle: dom.ElementHandle, options?: types.ScreenshotOptions): Promise<string | Buffer> {
async screenshot(handle: dom.ElementHandle, options?: types.ElementScreenshotOptions): Promise<Buffer> {
const page = this._frameManager._page;
return page._screenshotter.screenshotElement(page, handle, options);
return page._screenshotter.screenshotElement(handle, options);
}
async setInputFiles(handle: dom.ElementHandle, files: input.FilePayload[]): Promise<void> {

View File

@ -16,27 +16,28 @@
*/
import { EventEmitter } from 'events';
import * as console from '../console';
import * as dialog from '../dialog';
import * as dom from '../dom';
import { TimeoutError } from '../Errors';
import * as frames from '../frames';
import { assert, debugError, helper, RegisteredListener } from '../helper';
import * as input from '../input';
import * as js from '../javascript';
import * as network from '../network';
import { Screenshotter } from '../screenshotter';
import { TimeoutSettings } from '../TimeoutSettings';
import * as types from '../types';
import { BrowserContext } from './Browser';
import { JugglerSession, JugglerSessionEvents } from './Connection';
import { Events } from './events';
import { Accessibility } from './features/accessibility';
import { Interception } from './features/interception';
import { FrameManager, FrameManagerEvents, normalizeWaitUntil } from './FrameManager';
import { RawMouseImpl, RawKeyboardImpl } from './Input';
import { RawKeyboardImpl, RawMouseImpl } from './Input';
import { NavigationWatchdog } from './NavigationWatchdog';
import { NetworkManager, NetworkManagerEvents } from './NetworkManager';
import * as input from '../input';
import * as types from '../types';
import * as js from '../javascript';
import * as dom from '../dom';
import * as network from '../network';
import * as frames from '../frames';
import * as dialog from '../dialog';
import * as console from '../console';
import { Screenshotter } from './Screenshotter';
import { FFScreenshotDelegate } from './Screenshotter';
export class Page extends EventEmitter {
private _timeoutSettings: TimeoutSettings;
@ -54,12 +55,12 @@ export class Page extends EventEmitter {
_frameManager: FrameManager;
_javascriptEnabled = true;
private _eventListeners: RegisteredListener[];
private _viewport: Viewport;
private _viewport: types.Viewport;
private _disconnectPromise: Promise<Error>;
private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>();
_screenshotter: Screenshotter;
static async create(session: JugglerSession, browserContext: BrowserContext, defaultViewport: Viewport | null) {
static async create(session: JugglerSession, browserContext: BrowserContext, defaultViewport: types.Viewport | null) {
const page = new Page(session, browserContext);
await Promise.all([
session.send('Runtime.enable'),
@ -105,7 +106,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);
this._screenshotter = new Screenshotter(this, new FFScreenshotDelegate(session, this._frameManager), browserContext.browser());
}
_didClose() {
@ -247,7 +248,7 @@ export class Page extends EventEmitter {
await this._session.send('Page.setCacheDisabled', {cacheDisabled: !enabled});
}
async emulate(options: { viewport: Viewport; userAgent: string; }) {
async emulate(options: { viewport: types.Viewport; userAgent: string; }) {
await Promise.all([
this.setViewport(options.viewport),
this.setUserAgent(options.userAgent),
@ -268,7 +269,7 @@ export class Page extends EventEmitter {
return this._viewport;
}
async setViewport(viewport: Viewport) {
async setViewport(viewport: types.Viewport) {
const {
width,
height,
@ -280,8 +281,8 @@ export class Page extends EventEmitter {
await this._session.send('Page.setViewport', {
viewport: { width, height, isMobile, deviceScaleFactor, hasTouch, isLandscape },
});
const oldIsMobile = this._viewport ? this._viewport.isMobile : false;
const oldHasTouch = this._viewport ? this._viewport.hasTouch : false;
const oldIsMobile = this._viewport ? !!this._viewport.isMobile : false;
const oldHasTouch = this._viewport ? !!this._viewport.hasTouch : false;
this._viewport = viewport;
if (oldIsMobile !== isMobile || oldHasTouch !== hasTouch)
await this.reload();
@ -424,8 +425,8 @@ export class Page extends EventEmitter {
return watchDog.navigationResponse();
}
screenshot(options?: types.ScreenshotOptions): Promise<string | Buffer> {
return this._screenshotter.screenshotPage(this, options);
screenshot(options: types.ScreenshotOptions = {}): Promise<Buffer> {
return this._screenshotter.screenshotPage(options);
}
evaluate: types.Evaluate = (pageFunction, ...args) => {
@ -570,15 +571,6 @@ export class Page extends EventEmitter {
}
}
export type Viewport = {
width: number;
height: number;
deviceScaleFactor?: number;
isMobile?: boolean;
isLandscape?: boolean;
hasTouch?: boolean;
}
type FileChooser = {
element: dom.ElementHandle,
multiple: boolean

View File

@ -1,64 +1,42 @@
// 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 { ScreenshotterDelegate } from '../screenshotter';
import * as types from '../types';
import * as dom from '../dom';
import { JugglerSession } from './Connection';
import { FrameManager } from './FrameManager';
const writeFileAsync = helper.promisify(fs.writeFile);
export class Screenshotter {
export class FFScreenshotDelegate implements ScreenshotterDelegate {
private _session: JugglerSession;
private _frameManager: FrameManager;
constructor(session: JugglerSession) {
constructor(session: JugglerSession, frameManager: FrameManager) {
this._session = session;
this._frameManager = frameManager;
}
async screenshotPage(page: Page, options: types.ScreenshotOptions = {}): Promise<Buffer | string> {
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<string | Buffer> {
const frameId = page._frameManager._frameData(handle.executionContext().frame()).frameId;
const clip = await this._session.send('Page.getBoundingBox', {
getBoundingBox(handle: dom.ElementHandle<Node>): Promise<types.Rect | undefined> {
const frameId = this._frameManager._frameData(handle.executionContext().frame()).frameId;
return 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,
},
}
canCaptureOutsideViewport(): boolean {
return true;
}
async setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise<void> {
}
async screenshot(format: 'png' | 'jpeg', options: types.ScreenshotOptions): Promise<Buffer> {
const { data } = await this._session.send('Page.screenshot', {
mimeType: ('image/' + format) as ('image/png' | 'image/jpeg'),
fullPage: options.fullPage,
clip: options.clip,
});
return Buffer.from(data, 'base64');
}
}

View File

@ -16,9 +16,7 @@
*/
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`);
@ -155,43 +153,6 @@ 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) {

213
src/screenshotter.ts Normal file
View File

@ -0,0 +1,213 @@
/**
* 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 * as mime from 'mime';
import * as dom from './dom';
import { assert, helper } from './helper';
import * as types from './types';
const writeFileAsync = helper.promisify(fs.writeFile);
export interface Page {
viewport(): types.Viewport;
setViewport(v: types.Viewport): Promise<void>;
evaluate(f: () => any): Promise<types.Rect>;
}
export interface ScreenshotterDelegate {
getBoundingBox(handle: dom.ElementHandle<Node>): Promise<types.Rect | undefined>;
canCaptureOutsideViewport(): boolean;
setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise<void>;
screenshot(format: string, options: types.ScreenshotOptions, viewport: types.Viewport): Promise<Buffer>;
}
export class Screenshotter {
private _queue = new TaskQueue();
private _delegate: ScreenshotterDelegate;
private _page: Page;
constructor(page: Page, delegate: ScreenshotterDelegate, browserObject: any) {
this._delegate = delegate;
this._page = page;
this._queue = browserObject[taskQueueSymbol];
if (!this._queue) {
this._queue = new TaskQueue();
browserObject[taskQueueSymbol] = this._queue;
}
}
async screenshotPage(options: types.ScreenshotOptions = {}): Promise<Buffer> {
const format = validateScreeshotOptions(options);
return this._queue.postTask(async () => {
let overridenViewport: types.Viewport | undefined;
const viewport = this._page.viewport();
if (options.fullPage && !this._delegate.canCaptureOutsideViewport()) {
const fullPage = await this._page.evaluate(() => ({
width: Math.max(
document.body.scrollWidth, document.documentElement.scrollWidth,
document.body.offsetWidth, document.documentElement.offsetWidth,
document.body.clientWidth, document.documentElement.clientWidth
),
height: Math.max(
document.body.scrollHeight, document.documentElement.scrollHeight,
document.body.offsetHeight, document.documentElement.offsetHeight,
document.body.clientHeight, document.documentElement.clientHeight
)
}));
overridenViewport = { ...viewport, ...fullPage };
await this._page.setViewport(overridenViewport);
} else if (options.clip) {
options.clip = trimClipToViewport(viewport, options.clip);
}
const result = await this._screenshot(format, options, overridenViewport || viewport);
if (overridenViewport)
await this._page.setViewport(viewport);
return result;
});
}
async screenshotElement(handle: dom.ElementHandle, options: types.ElementScreenshotOptions = {}): Promise<Buffer> {
const format = validateScreeshotOptions(options);
const rewrittenOptions: types.ScreenshotOptions = { ...options };
return this._queue.postTask(async () => {
let overridenViewport: types.Viewport | undefined;
let boundingBox = await this._delegate.getBoundingBox(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.');
boundingBox = enclosingIntRect(boundingBox);
const viewport = this._page.viewport();
if (!this._delegate.canCaptureOutsideViewport()) {
if (boundingBox.width > viewport.width || boundingBox.height > viewport.height) {
overridenViewport = {
...viewport,
width: Math.max(viewport.width, Math.ceil(boundingBox.width)),
height: Math.max(viewport.height, Math.ceil(boundingBox.height)),
};
await this._page.setViewport(overridenViewport);
}
await handle._scrollIntoViewIfNeeded();
boundingBox = enclosingIntRect(await this._delegate.getBoundingBox(handle));
}
if (!overridenViewport)
rewrittenOptions.clip = boundingBox;
const result = await this._screenshot(format, rewrittenOptions, overridenViewport || viewport);
if (overridenViewport)
await this._page.setViewport(viewport);
return result;
});
}
private async _screenshot(format: 'png' | 'jpeg', options: types.ScreenshotOptions, viewport: types.Viewport): Promise<Buffer> {
const shouldSetDefaultBackground = options.omitBackground && format === 'png';
if (shouldSetDefaultBackground)
await this._delegate.setBackgroundColor({ r: 0, g: 0, b: 0, a: 0});
const buffer = await this._delegate.screenshot(format, options, viewport);
if (shouldSetDefaultBackground)
await this._delegate.setBackgroundColor();
if (options.path)
await writeFileAsync(options.path, buffer);
return buffer;
}
}
const taskQueueSymbol = Symbol('TaskQueue');
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;
}
}
function trimClipToViewport(viewport: types.Viewport, clip: types.Rect | undefined): types.Rect | undefined {
if (!clip)
return;
const p1 = { x: Math.min(clip.x, viewport.width), y: Math.min(clip.y, viewport.height) };
const p2 = { x: Math.min(clip.x + clip.width, viewport.width), y: Math.min(clip.y + clip.height, viewport.height) };
const result = { x: p1.x, y: p1.y, width: p2.x - p1.x, height: p2.y - p1.y };
assert(result.width && result.height, 'Clipped area is either empty or outside the viewport');
return result;
}
function 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;
}
function enclosingIntRect(rect: types.Rect): types.Rect {
const x = rect.x | 0;
const y = rect.y | 0;
const x2 = Math.ceil(((rect.x + rect.width) * 100 | 0) / 100);
const y2 = Math.ceil(((rect.y + rect.height) * 100 | 0) / 100);
return {
x,
y,
width: x2 - x,
height: y2 - y
};
}

View File

@ -43,12 +43,23 @@ export function clearSelector(selector: string | Selector): string | Selector {
return { selector: selector.selector, visible: selector.visible };
}
export type ScreenshotOptions = {
export type ElementScreenshotOptions = {
type?: 'png' | 'jpeg',
path?: string,
fullPage?: boolean,
clip?: Rect,
quality?: number,
omitBackground?: boolean,
encoding?: string,
};
export type ScreenshotOptions = ElementScreenshotOptions & {
fullPage?: boolean,
clip?: Rect,
};
export type Viewport = {
width: number;
height: number;
deviceScaleFactor?: number;
isMobile?: boolean;
isLandscape?: boolean;
hasTouch?: boolean;
}

View File

@ -20,15 +20,14 @@ import { EventEmitter } from 'events';
import { assert, helper, RegisteredListener, debugError } from '../helper';
import { filterCookies, NetworkCookie, rewriteCookies, SetNetworkCookieParam } from '../network';
import { Connection } from './Connection';
import { Page, Viewport } from './Page';
import { Page } from './Page';
import { Target } from './Target';
import { Screenshotter } from './Screenshotter';
import { Protocol } from './protocol';
import * as types from '../types';
export class Browser extends EventEmitter {
_defaultViewport: Viewport;
_defaultViewport: types.Viewport;
private _process: childProcess.ChildProcess;
_screenshotter = new Screenshotter();
_connection: Connection;
private _closeCallback: () => Promise<void>;
private _defaultContext: BrowserContext;
@ -39,7 +38,7 @@ export class Browser extends EventEmitter {
constructor(
connection: Connection,
defaultViewport: Viewport | null,
defaultViewport: types.Viewport | null,
process: childProcess.ChildProcess | null,
closeCallback?: (() => Promise<void>)) {
super();
@ -60,9 +59,6 @@ export class Browser extends EventEmitter {
helper.addEventListener(this._connection, 'Target.targetDestroyed', this._onTargetDestroyed.bind(this)),
helper.addEventListener(this._connection, 'Target.didCommitProvisionalTarget', this._onProvisionalTargetCommitted.bind(this)),
];
// Taking multiple screenshots in parallel doesn't work well, so we serialize them.
this._screenshotter = new Screenshotter();
}
async userAgent(): Promise<string> {

View File

@ -88,9 +88,9 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate {
return this._frameManager._page.evaluate(() => ({ width: innerWidth, height: innerHeight }));
}
screenshot(handle: dom.ElementHandle, options?: types.ScreenshotOptions): Promise<string | Buffer> {
screenshot(handle: dom.ElementHandle, options?: types.ElementScreenshotOptions): Promise<string | Buffer> {
const page = this._frameManager._page;
return page._screenshotter.screenshotElement(page, handle, options);
return page._screenshotter.screenshotElement(handle, options);
}
async setInputFiles(handle: dom.ElementHandle, files: input.FilePayload[]): Promise<void> {

View File

@ -19,7 +19,7 @@ import { debugError, helper } from '../helper';
import { Browser } from './Browser';
import { BrowserFetcher } from './BrowserFetcher';
import { Connection } from './Connection';
import { Viewport } from './Page';
import * as types from '../types';
import { PipeTransport } from './PipeTransport';
const DEFAULT_ARGS = [
@ -186,6 +186,6 @@ export type LauncherLaunchOptions = {
headless?: boolean,
dumpio?: boolean,
env?: {[key: string]: string} | undefined,
defaultViewport?: Viewport | null,
defaultViewport?: types.Viewport | null,
slowMo?: number,
};

View File

@ -16,9 +16,18 @@
*/
import { EventEmitter } from 'events';
import * as console from '../console';
import * as dialog from '../dialog';
import * as dom from '../dom';
import * as frames from '../frames';
import { assert, debugError, helper, RegisteredListener } from '../helper';
import * as input from '../input';
import { ClickOptions, mediaColorSchemes, mediaTypes, MultiClickOptions } from '../input';
import * as js from '../javascript';
import * as network from '../network';
import { Screenshotter } from '../screenshotter';
import { TimeoutSettings } from '../TimeoutSettings';
import * as types from '../types';
import { Browser, BrowserContext } from './Browser';
import { TargetSession, TargetSessionEvents } from './Connection';
import { Events } from './events';
@ -26,20 +35,7 @@ import { FrameManager, FrameManagerEvents } from './FrameManager';
import { RawKeyboardImpl, RawMouseImpl } from './Input';
import { NetworkManagerEvents } from './NetworkManager';
import { Protocol } from './protocol';
import { Screenshotter } from './Screenshotter';
import * as input from '../input';
import * as types from '../types';
import * as frames from '../frames';
import * as js from '../javascript';
import * as dom from '../dom';
import * as network from '../network';
import * as dialog from '../dialog';
import * as console from '../console';
export type Viewport = {
width: number;
height: number;
}
import { WKScreenshotDelegate } from './Screenshotter';
export class Page extends EventEmitter {
private _closed = false;
@ -53,7 +49,7 @@ export class Page extends EventEmitter {
private _frameManager: FrameManager;
private _bootstrapScripts: string[] = [];
_javascriptEnabled = true;
private _viewport: Viewport | null = null;
private _viewport: types.Viewport | null = null;
_screenshotter: Screenshotter;
private _workers = new Map<string, Worker>();
private _disconnectPromise: Promise<Error> | undefined;
@ -61,15 +57,15 @@ export class Page extends EventEmitter {
private _emulatedMediaType: string | undefined;
private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>();
static async create(session: TargetSession, browserContext: BrowserContext, defaultViewport: Viewport | null, screenshotter: Screenshotter): Promise<Page> {
const page = new Page(session, browserContext, screenshotter);
static async create(session: TargetSession, browserContext: BrowserContext, defaultViewport: types.Viewport | null): Promise<Page> {
const page = new Page(session, browserContext);
await page._initialize();
if (defaultViewport)
await page.setViewport(defaultViewport);
return page;
}
constructor(session: TargetSession, browserContext: BrowserContext, screenshotter: Screenshotter) {
constructor(session: TargetSession, browserContext: BrowserContext) {
super();
this._closedPromise = new Promise(f => this._closedCallback = f);
this._keyboard = new input.Keyboard(new RawKeyboardImpl(session));
@ -77,7 +73,7 @@ export class Page extends EventEmitter {
this._timeoutSettings = new TimeoutSettings();
this._frameManager = new FrameManager(session, this, this._timeoutSettings);
this._screenshotter = screenshotter;
this._screenshotter = new Screenshotter(this, new WKScreenshotDelegate(session), browserContext.browser());
this._setSession(session);
this._browserContext = browserContext;
@ -315,7 +311,7 @@ export class Page extends EventEmitter {
}, timeout, this._sessionClosePromise());
}
async emulate(options: { viewport: Viewport; userAgent: string; }) {
async emulate(options: { viewport: types.Viewport; userAgent: string; }) {
await Promise.all([
this.setViewport(options.viewport),
this.setUserAgent(options.userAgent)
@ -333,14 +329,14 @@ export class Page extends EventEmitter {
this._emulatedMediaType = options.type;
}
async setViewport(viewport: Viewport) {
async setViewport(viewport: types.Viewport) {
this._viewport = viewport;
const width = viewport.width;
const height = viewport.height;
await this._session.send('Emulation.setDeviceMetricsOverride', { width, height });
await this._session.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: viewport.deviceScaleFactor || 1 });
}
viewport(): Viewport | null {
viewport(): types.Viewport | null {
return this._viewport;
}
@ -367,8 +363,8 @@ export class Page extends EventEmitter {
await this._frameManager.networkManager().setCacheEnabled(enabled);
}
screenshot(options?: types.ScreenshotOptions): Promise<Buffer | string> {
return this._screenshotter.screenshotPage(this, options);
screenshot(options?: types.ScreenshotOptions): Promise<Buffer> {
return this._screenshotter.screenshotPage(options);
}
async title(): Promise<string> {
@ -464,23 +460,6 @@ export class Page extends EventEmitter {
}
}
type Metrics = {
Timestamp?: number,
Documents?: number,
Frames?: number,
JSEventListeners?: number,
Nodes?: number,
LayoutCount?: number,
RecalcStyleCount?: number,
LayoutDuration?: number,
RecalcStyleDuration?: number,
ScriptDuration?: number,
TaskDuration?: number,
JSHeapUsedSize?: number,
JSHeapTotalSize?: number,
}
type FileChooser = {
element: dom.ElementHandle,
multiple: boolean

View File

@ -1,83 +1,40 @@
// 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 jpeg from 'jpeg-js';
import { PNG } from 'pngjs';
import * as dom from '../dom';
import { ScreenshotterDelegate } from '../screenshotter';
import * as types from '../types';
import { TargetSession } from './Connection';
const writeFileAsync = helper.promisify(fs.writeFile);
export class WKScreenshotDelegate implements ScreenshotterDelegate {
private _session: TargetSession;
export class Screenshotter {
private _queue = new TaskQueue();
async screenshotPage(page: Page, options: types.ScreenshotOptions = {}): Promise<Buffer | string> {
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;
});
constructor(session: TargetSession) {
this._session = session;
}
async screenshotElement(page: Page, handle: dom.ElementHandle, options: types.ScreenshotOptions = {}): Promise<string | Buffer> {
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<any>;
constructor() {
this._chain = Promise.resolve();
}
postTask(task: () => any): Promise<any> {
const result = this._chain.then(task);
this._chain = result.catch(() => {});
return result;
getBoundingBox(handle: dom.ElementHandle<Node>): Promise<types.Rect | undefined> {
return handle.boundingBox();
}
canCaptureOutsideViewport(): boolean {
return false;
}
async setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise<void> {
// TODO: line below crashes, sort it out.
this._session.send('Page.setDefaultBackgroundColorOverride', { color });
}
async screenshot(format: string, options: types.ScreenshotOptions, viewport: types.Viewport ): Promise<Buffer> {
const rect = options.clip || { x: 0, y: 0, width: viewport.width, height: viewport.height };
const result = await this._session.send('Page.snapshotRect', { ...rect, coordinateSystem: options.fullPage ? 'Page' : 'Viewport' });
const prefix = 'data:image/png;base64,';
let buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64');
if (format === 'jpeg')
buffer = jpeg.encode(PNG.sync.read(buffer)).data;
return buffer;
}
}

View File

@ -59,7 +59,7 @@ export class Target {
async page(): Promise<Page | null> {
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()._screenshotter).then(page => {
this._pagePromise = Page.create(session, this._browserContext, this.browser()._defaultViewport).then(page => {
this._adoptPage(page);
return page;
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 B

After

Width:  |  Height:  |  Size: 130 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 B

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 B

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 B

After

Width:  |  Height:  |  Size: 81 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 B

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 B

After

Width:  |  Height:  |  Size: 138 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 B

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 B

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 B

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 B

After

Width:  |  Height:  |  Size: 81 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 B

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 B

After

Width:  |  Height:  |  Size: 130 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 B

After

Width:  |  Height:  |  Size: 138 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 B

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 B

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 433 B

View File

@ -39,20 +39,33 @@ module.exports.addTests = function({testRunner, expect, product, FFOX, CHROME, W
});
expect(screenshot).toBeGolden('screenshot-clip-rect.png');
});
it.skip(FFOX)('should clip elements to the viewport', async({page, server}) => {
it('should clip elements to the viewport', async({page, server}) => {
await page.setViewport({width: 500, height: 500});
await page.goto(server.PREFIX + '/grid.html');
const screenshot = await page.screenshot({
clip: {
x: 50,
y: 600,
width: 100,
y: 450,
width: 1000,
height: 100
}
});
expect(screenshot).toBeGolden('screenshot-offscreen-clip.png');
});
it.skip(WEBKIT)('should run in parallel', async({page, server}) => {
it('should throw on clip outside the viewport', async({page, server}) => {
await page.setViewport({width: 500, height: 500});
await page.goto(server.PREFIX + '/grid.html');
const screenshotError = await page.screenshot({
clip: {
x: 50,
y: 650,
width: 100,
height: 100
}
}).catch(error => error);
expect(screenshotError.message).toBe('Clipped area is either empty or outside the viewport');
});
it('should run in parallel', async({page, server}) => {
await page.setViewport({width: 500, height: 500});
await page.goto(server.PREFIX + '/grid.html');
const promises = [];
@ -92,13 +105,21 @@ module.exports.addTests = function({testRunner, expect, product, FFOX, CHROME, W
expect(screenshots[i]).toBeGolden(`grid-cell-${i}.png`);
await Promise.all(pages.map(page => page.close()));
});
it.skip(FFOX)('should allow transparency', async({page, server}) => {
await page.setViewport({ width: 100, height: 100 });
await page.goto(server.EMPTY_PAGE);
it.skip(FFOX || WEBKIT)('should allow transparency', async({page, server}) => {
await page.setViewport({ width: 50, height: 150 });
await page.setContent(`
<style>
body { margin: 0 }
div { width: 50px; height: 50px; }
</style>
<div style="background:black"></div>
<div style="background:white"></div>
<div style="background:transparent"></div>
`);
const screenshot = await page.screenshot({omitBackground: true});
expect(screenshot).toBeGolden('transparent.png');
});
it.skip(FFOX || WEBKIT)('should render white background on jpeg file', async({page, server}) => {
it('should render white background on jpeg file', async({page, server}) => {
await page.setViewport({ width: 100, height: 100 });
await page.goto(server.EMPTY_PAGE);
const screenshot = await page.screenshot({omitBackground: true, type: 'jpeg'});
@ -137,7 +158,7 @@ module.exports.addTests = function({testRunner, expect, product, FFOX, CHROME, W
it('should take into account padding and border', async({page, server}) => {
await page.setViewport({width: 500, height: 500});
await page.setContent(`
something above
<div style="height: 14px">oooo</div>
<style>div {
border: 2px solid blue;
background: green;
@ -145,9 +166,9 @@ module.exports.addTests = function({testRunner, expect, product, FFOX, CHROME, W
height: 50px;
}
</style>
<div></div>
<div id="d"></div>
`);
const elementHandle = await page.$('div');
const elementHandle = await page.$('div#d');
const screenshot = await elementHandle.screenshot();
expect(screenshot).toBeGolden('screenshot-element-padding-border.png');
});
@ -155,7 +176,7 @@ module.exports.addTests = function({testRunner, expect, product, FFOX, CHROME, W
await page.setViewport({width: 500, height: 500});
await page.setContent(`
something above
<div style="height: 14px">oooo</div>
<style>
div.to-screenshot {
border: 1px solid blue;
@ -182,7 +203,7 @@ module.exports.addTests = function({testRunner, expect, product, FFOX, CHROME, W
await page.setViewport({width: 500, height: 500});
await page.setContent(`
something above
<div style="height: 14px">oooo</div>
<style>
div.to-screenshot {
border: 1px solid blue;
@ -207,7 +228,7 @@ module.exports.addTests = function({testRunner, expect, product, FFOX, CHROME, W
it('should scroll element into view', async({page, server}) => {
await page.setViewport({width: 500, height: 500});
await page.setContent(`
something above
<div style="height: 14px">oooo</div>
<style>div.above {
border: 2px solid blue;
background: red;
@ -227,7 +248,7 @@ module.exports.addTests = function({testRunner, expect, product, FFOX, CHROME, W
const screenshot = await elementHandle.screenshot();
expect(screenshot).toBeGolden('screenshot-element-scrolled-into-view.png');
});
it.skip(WEBKIT)('should work with a rotated element', async({page, server}) => {
it('should work with a rotated element', async({page, server}) => {
await page.setViewport({width: 500, height: 500});
await page.setContent(`<div style="position:absolute;
top: 100px;
@ -240,14 +261,14 @@ module.exports.addTests = function({testRunner, expect, product, FFOX, CHROME, W
const screenshot = await elementHandle.screenshot();
expect(screenshot).toBeGolden('screenshot-element-rotate.png');
});
it.skip(WEBKIT)('should fail to screenshot a detached element', async({page, server}) => {
it('should fail to screenshot a detached element', async({page, server}) => {
await page.setContent('<h1>remove this</h1>');
const elementHandle = await page.$('h1');
await page.evaluate(element => element.remove(), elementHandle);
const screenshotError = await elementHandle.screenshot().catch(error => error);
expect(screenshotError.message).toBe('Node is either not visible or not an HTMLElement');
});
it.skip(WEBKIT)('should not hang with zero width/height element', async({page, server}) => {
it('should not hang with zero width/height element', async({page, server}) => {
await page.setContent('<div style="width: 50px; height: 0"></div>');
const div = await page.$('div');
const error = await div.screenshot().catch(e => e);