diff --git a/docs/api.md b/docs/api.md index 985568c8c8..267a307f42 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. diff --git a/package.json b/package.json index 4828c8d159..0ce96d8603 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "playwright": { "chromium_revision": "719491", "firefox_revision": "1004", - "webkit_revision": "1011" + "webkit_revision": "1015" }, "scripts": { "unit": "node test/test.js", diff --git a/src/chromium/Browser.ts b/src/chromium/Browser.ts index dd7585b0a0..95f52f4bc9 100644 --- a/src/chromium/Browser.ts +++ b/src/chromium/Browser.ts @@ -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; @@ -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)) { 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)) { 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); diff --git a/src/chromium/EmulationManager.ts b/src/chromium/EmulationManager.ts index c6eeab9875..e4c9f51016 100644 --- a/src/chromium/EmulationManager.ts +++ b/src/chromium/EmulationManager.ts @@ -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 { + async emulateViewport(viewport: types.Viewport): Promise { const mobile = viewport.isMobile || false; const width = viewport.width; const height = viewport.height; diff --git a/src/chromium/JSHandle.ts b/src/chromium/JSHandle.ts index 8551557d97..ca965a6c8c 100644 --- a/src/chromium/JSHandle.ts +++ b/src/chromium/JSHandle.ts @@ -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 { + screenshot(handle: dom.ElementHandle, options?: types.ElementScreenshotOptions): Promise { 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 { diff --git a/src/chromium/Launcher.ts b/src/chromium/Launcher.ts index faff28bc3b..66453557de 100644 --- a/src/chromium/Launcher.ts +++ b/src/chromium/Launcher.ts @@ -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, }; diff --git a/src/chromium/Page.ts b/src/chromium/Page.ts index c6ab4d6a6b..696c1e8348 100644 --- a/src/chromium/Page.ts +++ b/src/chromium/Page.ts @@ -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(); _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 | undefined; private _emulatedMediaType: string | undefined; - static async create(client: CDPSession, browserContext: BrowserContext, ignoreHTTPSErrors: boolean, defaultViewport: Viewport | null, screenshotter: Screenshotter): Promise { - const page = new Page(client, browserContext, ignoreHTTPSErrors, screenshotter); + static async create(client: CDPSession, browserContext: BrowserContext, ignoreHTTPSErrors: boolean, defaultViewport: types.Viewport | null): Promise { + 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 { - return this._screenshotter.screenshotPage(this, options); + screenshot(options?: types.ScreenshotOptions): Promise { + return this._screenshotter.screenshotPage(options); } async title(): Promise { @@ -585,11 +576,6 @@ export class Page extends EventEmitter { } } -type MediaFeature = { - name: string, - value: string -} - type FileChooser = { element: dom.ElementHandle, multiple: boolean diff --git a/src/chromium/Screenshotter.ts b/src/chromium/Screenshotter.ts index 154fbd1605..b61f3e3ca7 100644 --- a/src/chromium/Screenshotter.ts +++ b/src/chromium/Screenshotter.ts @@ -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 { - 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 { - 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): Promise { + 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 { - 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 { + 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; - - constructor() { - this._chain = Promise.resolve(); - } - - postTask(task: () => any): Promise { - const result = this._chain.then(task); - this._chain = result.catch(() => {}); - return result; + async screenshot(format: 'png' | 'jpeg', options: types.ScreenshotOptions): Promise { + 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'); } } diff --git a/src/chromium/Target.ts b/src/chromium/Target.ts index 4e33301837..c2724376da 100644 --- a/src/chromium/Target.ts +++ b/src/chromium/Target.ts @@ -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; private _ignoreHTTPSErrors: boolean; - private _defaultViewport: Viewport; - private _screenshotter: Screenshotter; + private _defaultViewport: types.Viewport; private _pagePromise: Promise | null = null; private _workerPromise: Promise | null = null; _initializedPromise: Promise; @@ -49,15 +48,13 @@ export class Target { browserContext: BrowserContext, sessionFactory: () => Promise, 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 { 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; }); diff --git a/src/dom.ts b/src/dom.ts index 17bb39404c..52e24895a0 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -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 extends js.JSHandle { - private readonly _world: DOMWorld; + readonly _world: DOMWorld; constructor(context: js.ExecutionContext, remoteObject: any) { super(context, remoteObject); @@ -258,8 +257,10 @@ export class ElementHandle extends js.JSHandle { 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), ]); diff --git a/src/firefox/Browser.ts b/src/firefox/Browser.ts index 7318474fd3..e067a14fc8 100644 --- a/src/firefox/Browser.ts +++ b/src/firefox/Browser.ts @@ -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; @@ -33,14 +34,14 @@ export class Browser extends EventEmitter { private _contexts: Map; 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, defaultViewport: Viewport | null, process: import('child_process').ChildProcess | null, closeCallback: () => void) { + constructor(connection: Connection, browserContextIds: Array, defaultViewport: types.Viewport | null, process: import('child_process').ChildProcess | null, closeCallback: () => void) { super(); this._connection = connection; this._defaultViewport = defaultViewport; diff --git a/src/firefox/JSHandle.ts b/src/firefox/JSHandle.ts index 139667a9c8..51f300e29b 100644 --- a/src/firefox/JSHandle.ts +++ b/src/firefox/JSHandle.ts @@ -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 { + async screenshot(handle: dom.ElementHandle, options?: types.ElementScreenshotOptions): Promise { 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 { diff --git a/src/firefox/Page.ts b/src/firefox/Page.ts index 44b439f42e..d1deb575b8 100644 --- a/src/firefox/Page.ts +++ b/src/firefox/Page.ts @@ -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; 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 { - return this._screenshotter.screenshotPage(this, options); + screenshot(options: types.ScreenshotOptions = {}): Promise { + 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 diff --git a/src/firefox/Screenshotter.ts b/src/firefox/Screenshotter.ts index 3768c3972f..d1f67deb71 100644 --- a/src/firefox/Screenshotter.ts +++ b/src/firefox/Screenshotter.ts @@ -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 { - const format = helper.validateScreeshotOptions(options); - const {data} = await this._session.send('Page.screenshot', { - mimeType: ('image/' + format) as ('image/png' | 'image/jpeg'), - fullPage: options.fullPage, - clip: processClip(options.clip), - }); - const buffer = options.encoding === 'base64' ? data : Buffer.from(data, 'base64'); - if (options.path) - await writeFileAsync(options.path, buffer); - return buffer; - - function processClip(clip) { - if (!clip) - return undefined; - const x = Math.round(clip.x); - const y = Math.round(clip.y); - const width = Math.round(clip.width + clip.x - x); - const height = Math.round(clip.height + clip.y - y); - return {x, y, width, height}; - } - } - - async screenshotElement(page: Page, handle: dom.ElementHandle, options: types.ScreenshotOptions = {}): Promise { - const frameId = page._frameManager._frameData(handle.executionContext().frame()).frameId; - const clip = await this._session.send('Page.getBoundingBox', { + getBoundingBox(handle: dom.ElementHandle): Promise { + 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 { + } + + async screenshot(format: 'png' | 'jpeg', options: types.ScreenshotOptions): Promise { + 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'); } } diff --git a/src/helper.ts b/src/helper.ts index dc560c1318..180f505ec4 100644 --- a/src/helper.ts +++ b/src/helper.ts @@ -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) { diff --git a/src/screenshotter.ts b/src/screenshotter.ts new file mode 100644 index 0000000000..04c083589f --- /dev/null +++ b/src/screenshotter.ts @@ -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; + evaluate(f: () => any): Promise; +} + +export interface ScreenshotterDelegate { + getBoundingBox(handle: dom.ElementHandle): Promise; + canCaptureOutsideViewport(): boolean; + setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise; + screenshot(format: string, options: types.ScreenshotOptions, viewport: types.Viewport): Promise; +} + +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 { + 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 { + 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 { + 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; + + constructor() { + this._chain = Promise.resolve(); + } + + postTask(task: () => any): Promise { + 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 + }; +} diff --git a/src/types.ts b/src/types.ts index 94231cc87d..e4266a978c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; +} diff --git a/src/webkit/Browser.ts b/src/webkit/Browser.ts index 3fc05b20d6..bc0d91fbb7 100644 --- a/src/webkit/Browser.ts +++ b/src/webkit/Browser.ts @@ -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; 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)) { 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 { diff --git a/src/webkit/JSHandle.ts b/src/webkit/JSHandle.ts index c26f38f93e..8e4e643d99 100644 --- a/src/webkit/JSHandle.ts +++ b/src/webkit/JSHandle.ts @@ -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 { + screenshot(handle: dom.ElementHandle, options?: types.ElementScreenshotOptions): Promise { 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 { diff --git a/src/webkit/Launcher.ts b/src/webkit/Launcher.ts index 265b637181..d33160aa31 100644 --- a/src/webkit/Launcher.ts +++ b/src/webkit/Launcher.ts @@ -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, }; diff --git a/src/webkit/Page.ts b/src/webkit/Page.ts index 0f391f5571..3766eb6bac 100644 --- a/src/webkit/Page.ts +++ b/src/webkit/Page.ts @@ -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(); private _disconnectPromise: Promise | 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 { - const page = new Page(session, browserContext, screenshotter); + static async create(session: TargetSession, browserContext: BrowserContext, defaultViewport: types.Viewport | null): Promise { + 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 { - return this._screenshotter.screenshotPage(this, options); + screenshot(options?: types.ScreenshotOptions): Promise { + return this._screenshotter.screenshotPage(options); } async title(): Promise { @@ -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 diff --git a/src/webkit/Screenshotter.ts b/src/webkit/Screenshotter.ts index 737e9f5113..54a02fcb7a 100644 --- a/src/webkit/Screenshotter.ts +++ b/src/webkit/Screenshotter.ts @@ -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 { - 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 { - const format = helper.validateScreeshotOptions(options); - assert(format === 'png', 'Only png format is supported'); - return this._queue.postTask(async () => { - const objectId = (handle._remoteObject as Protocol.Runtime.RemoteObject).objectId; - page._session.send('DOM.getDocument'); - const {nodeId} = await page._session.send('DOM.requestNode', {objectId}); - const [, result] = await Promise.all([ - page.browser()._activatePage(page), - page._session.send('Page.snapshotNode', {nodeId}) - ]).catch(e => { - debugError('Failed to take screenshot: ' + e); - throw e; - }); - const prefix = 'data:image/png;base64,'; - const buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64'); - if (options.path) - await writeFileAsync(options.path, buffer); - return buffer; - }); - } -} - -class TaskQueue { - private _chain: Promise; - - constructor() { - this._chain = Promise.resolve(); - } - - postTask(task: () => any): Promise { - const result = this._chain.then(task); - this._chain = result.catch(() => {}); - return result; + getBoundingBox(handle: dom.ElementHandle): Promise { + return handle.boundingBox(); + } + + canCaptureOutsideViewport(): boolean { + return false; + } + + async setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise { + // TODO: line below crashes, sort it out. + this._session.send('Page.setDefaultBackgroundColorOverride', { color }); + } + + async screenshot(format: string, options: types.ScreenshotOptions, viewport: types.Viewport ): Promise { + 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; } } diff --git a/src/webkit/Target.ts b/src/webkit/Target.ts index 481f3d8284..72a40d3379 100644 --- a/src/webkit/Target.ts +++ b/src/webkit/Target.ts @@ -59,7 +59,7 @@ export class Target { async page(): Promise { if (this._type === 'page' && !this._pagePromise) { const session = this.browser()._connection.session(this._targetId); - this._pagePromise = Page.create(session, this._browserContext, this.browser()._defaultViewport, this.browser()._screenshotter).then(page => { + this._pagePromise = Page.create(session, this._browserContext, this.browser()._defaultViewport).then(page => { this._adoptPage(page); return page; }); diff --git a/test/golden-chromium/screenshot-element-fractional-offset.png b/test/golden-chromium/screenshot-element-fractional-offset.png index cc8669d598..6b65c6fd6b 100644 Binary files a/test/golden-chromium/screenshot-element-fractional-offset.png and b/test/golden-chromium/screenshot-element-fractional-offset.png differ diff --git a/test/golden-chromium/screenshot-element-rotate.png b/test/golden-chromium/screenshot-element-rotate.png index 52e2a0f6d3..b6eadf8907 100644 Binary files a/test/golden-chromium/screenshot-element-rotate.png and b/test/golden-chromium/screenshot-element-rotate.png differ diff --git a/test/golden-chromium/screenshot-offscreen-clip.png b/test/golden-chromium/screenshot-offscreen-clip.png index 31a0935cda..21f7b0f461 100644 Binary files a/test/golden-chromium/screenshot-offscreen-clip.png and b/test/golden-chromium/screenshot-offscreen-clip.png differ diff --git a/test/golden-chromium/transparent.png b/test/golden-chromium/transparent.png index 1cf45d8688..6c1fe85b52 100644 Binary files a/test/golden-chromium/transparent.png and b/test/golden-chromium/transparent.png differ diff --git a/test/golden-firefox/screenshot-clip-odd-size.png b/test/golden-firefox/screenshot-clip-odd-size.png index 8e86dc9017..b010d1f87f 100644 Binary files a/test/golden-firefox/screenshot-clip-odd-size.png and b/test/golden-firefox/screenshot-clip-odd-size.png differ diff --git a/test/golden-firefox/screenshot-clip-rect.png b/test/golden-firefox/screenshot-clip-rect.png index 7a74457869..ac23b7de50 100644 Binary files a/test/golden-firefox/screenshot-clip-rect.png and b/test/golden-firefox/screenshot-clip-rect.png differ diff --git a/test/golden-firefox/screenshot-element-bounding-box.png b/test/golden-firefox/screenshot-element-bounding-box.png index f4e059c300..32e05bf05b 100644 Binary files a/test/golden-firefox/screenshot-element-bounding-box.png and b/test/golden-firefox/screenshot-element-bounding-box.png differ diff --git a/test/golden-firefox/screenshot-element-fractional.png b/test/golden-firefox/screenshot-element-fractional.png index d1431bd91d..35c53377f9 100644 Binary files a/test/golden-firefox/screenshot-element-fractional.png and b/test/golden-firefox/screenshot-element-fractional.png differ diff --git a/test/golden-firefox/screenshot-element-larger-than-viewport.png b/test/golden-firefox/screenshot-element-larger-than-viewport.png index 6d28cddcea..5fcdb92355 100644 Binary files a/test/golden-firefox/screenshot-element-larger-than-viewport.png and b/test/golden-firefox/screenshot-element-larger-than-viewport.png differ diff --git a/test/golden-firefox/screenshot-element-padding-border.png b/test/golden-firefox/screenshot-element-padding-border.png index 2b72c7528b..917dd48188 100644 Binary files a/test/golden-firefox/screenshot-element-padding-border.png and b/test/golden-firefox/screenshot-element-padding-border.png differ diff --git a/test/golden-firefox/screenshot-element-rotate.png b/test/golden-firefox/screenshot-element-rotate.png index 0a78fb1ae7..b6eadf8907 100644 Binary files a/test/golden-firefox/screenshot-element-rotate.png and b/test/golden-firefox/screenshot-element-rotate.png differ diff --git a/test/golden-firefox/screenshot-element-scrolled-into-view.png b/test/golden-firefox/screenshot-element-scrolled-into-view.png index 2b72c7528b..917dd48188 100644 Binary files a/test/golden-firefox/screenshot-element-scrolled-into-view.png and b/test/golden-firefox/screenshot-element-scrolled-into-view.png differ diff --git a/test/golden-firefox/screenshot-grid-fullpage.png b/test/golden-firefox/screenshot-grid-fullpage.png index ac47ec83b1..d6d38217f7 100644 Binary files a/test/golden-firefox/screenshot-grid-fullpage.png and b/test/golden-firefox/screenshot-grid-fullpage.png differ diff --git a/test/golden-firefox/screenshot-offscreen-clip.png b/test/golden-firefox/screenshot-offscreen-clip.png index 31a0935cda..21f7b0f461 100644 Binary files a/test/golden-firefox/screenshot-offscreen-clip.png and b/test/golden-firefox/screenshot-offscreen-clip.png differ diff --git a/test/golden-firefox/screenshot-sanity.png b/test/golden-firefox/screenshot-sanity.png index 07890a04b3..ecab61fe17 100644 Binary files a/test/golden-firefox/screenshot-sanity.png and b/test/golden-firefox/screenshot-sanity.png differ diff --git a/test/golden-firefox/white.jpg b/test/golden-firefox/white.jpg new file mode 100644 index 0000000000..fb9070def3 Binary files /dev/null and b/test/golden-firefox/white.jpg differ diff --git a/test/golden-webkit/screenshot-clip-odd-size.png b/test/golden-webkit/screenshot-clip-odd-size.png index 27a2b2807f..b010d1f87f 100644 Binary files a/test/golden-webkit/screenshot-clip-odd-size.png and b/test/golden-webkit/screenshot-clip-odd-size.png differ diff --git a/test/golden-webkit/screenshot-clip-rect.png b/test/golden-webkit/screenshot-clip-rect.png index 3217645a36..ac23b7de50 100644 Binary files a/test/golden-webkit/screenshot-clip-rect.png and b/test/golden-webkit/screenshot-clip-rect.png differ diff --git a/test/golden-webkit/screenshot-element-bounding-box.png b/test/golden-webkit/screenshot-element-bounding-box.png index 5b07911c66..32e05bf05b 100644 Binary files a/test/golden-webkit/screenshot-element-bounding-box.png and b/test/golden-webkit/screenshot-element-bounding-box.png differ diff --git a/test/golden-webkit/screenshot-element-fractional-offset.png b/test/golden-webkit/screenshot-element-fractional-offset.png index c9e0eb15e9..6b65c6fd6b 100644 Binary files a/test/golden-webkit/screenshot-element-fractional-offset.png and b/test/golden-webkit/screenshot-element-fractional-offset.png differ diff --git a/test/golden-webkit/screenshot-element-fractional.png b/test/golden-webkit/screenshot-element-fractional.png index 390ef19d2e..35c53377f9 100644 Binary files a/test/golden-webkit/screenshot-element-fractional.png and b/test/golden-webkit/screenshot-element-fractional.png differ diff --git a/test/golden-webkit/screenshot-element-larger-than-viewport.png b/test/golden-webkit/screenshot-element-larger-than-viewport.png index bea66e69e8..5fcdb92355 100644 Binary files a/test/golden-webkit/screenshot-element-larger-than-viewport.png and b/test/golden-webkit/screenshot-element-larger-than-viewport.png differ diff --git a/test/golden-webkit/screenshot-element-padding-border.png b/test/golden-webkit/screenshot-element-padding-border.png index 7260617ec2..917dd48188 100644 Binary files a/test/golden-webkit/screenshot-element-padding-border.png and b/test/golden-webkit/screenshot-element-padding-border.png differ diff --git a/test/golden-webkit/screenshot-element-rotate.png b/test/golden-webkit/screenshot-element-rotate.png new file mode 100644 index 0000000000..b6eadf8907 Binary files /dev/null and b/test/golden-webkit/screenshot-element-rotate.png differ diff --git a/test/golden-webkit/screenshot-element-scrolled-into-view.png b/test/golden-webkit/screenshot-element-scrolled-into-view.png index 7260617ec2..917dd48188 100644 Binary files a/test/golden-webkit/screenshot-element-scrolled-into-view.png and b/test/golden-webkit/screenshot-element-scrolled-into-view.png differ diff --git a/test/golden-webkit/screenshot-grid-fullpage.png b/test/golden-webkit/screenshot-grid-fullpage.png index 142de08133..d6d38217f7 100644 Binary files a/test/golden-webkit/screenshot-grid-fullpage.png and b/test/golden-webkit/screenshot-grid-fullpage.png differ diff --git a/test/golden-webkit/screenshot-offscreen-clip.png b/test/golden-webkit/screenshot-offscreen-clip.png index fa7bfdc3e3..21f7b0f461 100644 Binary files a/test/golden-webkit/screenshot-offscreen-clip.png and b/test/golden-webkit/screenshot-offscreen-clip.png differ diff --git a/test/golden-webkit/screenshot-sanity.png b/test/golden-webkit/screenshot-sanity.png index fc41e344f9..ecab61fe17 100644 Binary files a/test/golden-webkit/screenshot-sanity.png and b/test/golden-webkit/screenshot-sanity.png differ diff --git a/test/golden-webkit/transparent.png b/test/golden-webkit/transparent.png deleted file mode 100644 index 47adcb4de0..0000000000 Binary files a/test/golden-webkit/transparent.png and /dev/null differ diff --git a/test/golden-webkit/white.jpg b/test/golden-webkit/white.jpg new file mode 100644 index 0000000000..fb9070def3 Binary files /dev/null and b/test/golden-webkit/white.jpg differ diff --git a/test/golden-webkit/white.png b/test/golden-webkit/white.png new file mode 100644 index 0000000000..e2c01a35a7 Binary files /dev/null and b/test/golden-webkit/white.png differ diff --git a/test/screenshot.spec.js b/test/screenshot.spec.js index 47b4acabe9..b17e3ff8f7 100644 --- a/test/screenshot.spec.js +++ b/test/screenshot.spec.js @@ -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(` + +
+
+
+ `); 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 +
oooo
-
+
`); - 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 +
oooo