/** * Copyright 2017 Google Inc. All rights reserved. * * 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. */ var fs = require('fs'); var EventEmitter = require('events'); var helpers = require('./helpers'); var mime = require('mime'); class Page extends EventEmitter { /** * @param {!Browser} browser * @param {!CDP} client * @return {!Promise} */ static async create(browser, client) { await Promise.all([ client.send('Network.enable', {}), client.send('Page.enable', {}), client.send('Runtime.enable', {}), client.send('Security.enable', {}), ]); var screenDPI = await helpers.evaluate(client, () => window.devicePixelRatio, []); var page = new Page(browser, client, screenDPI.result.value); // Initialize default page size. await page.setSize({width: 400, height: 300}); return page; } /** * @param {!Browser} browser * @param {!CDP} client * @param {number} screenDPI */ constructor(browser, client, screenDPI) { super(); this._browser = browser; this._client = client; this._screenDPI = screenDPI; this._extraHeaders = {}; /** @type {!Map} */ this._sourceURLToPageCallback = new Map(); /** @type {!Map} */ this._scriptIdToPageCallback = new Map(); client.on('Debugger.paused', event => this._onDebuggerPaused(event)); client.on('Debugger.scriptParsed', event => this._onScriptParsed(event)); client.on('Network.responseReceived', event => this.emit(Page.Events.ResponseReceived, event.response)); client.on('Network.loadingFailed', event => this.emit(Page.Events.ResourceLoadingFailed, event)); client.on('Runtime.consoleAPICalled', event => this._onConsoleAPI(event)); client.on('Page.javascriptDialogOpening', dialog => this.emit(Page.Events.DialogOpened, dialog)); client.on('Runtime.exceptionThrown', exception => this._handleException(exception.exceptionDetails)); } /** * @param {string} url * @return {!Promise} */ async addScriptTag(url) { return this.evaluateAsync(addScriptTag, url); /** * @param {string} url */ function addScriptTag(url) { var script = document.createElement('script'); script.src = url; var promise = new Promise(x => script.onload = x); document.head.appendChild(script); return promise; } } /** * @param {string} filePath * @return {!Promise} */ async injectFile(filePath) { var code = fs.readFileSync(filePath, 'utf8'); await helpers.evaluateText(this._client, code, false /* awaitPromise */); } /** * @param {string} name * @param {function(?)} callback */ async setInPageCallback(name, callback) { var hasCallback = await this.evaluate(function(name) { return !!window[name]; }, name); if (hasCallback) throw new Error(`Failed to set in-page callback with name ${name}: window['${name}'] already exists!`); var sourceURL = '__in_page_callback__' + name; this._sourceURLToPageCallback.set(sourceURL, new InPageCallback(name, callback)); var text = helpers.evaluationString(inPageCallback, [name], false /* awaitPromise */, sourceURL); await Promise.all([ this._client.send('Debugger.enable', {}), this._client.send('Page.addScriptToEvaluateOnLoad', { scriptSource: text }), helpers.evaluateText(this._client, text, false /* awaitPromise */) ]); function inPageCallback(callbackName) { window[callbackName] = (...args) => { window[callbackName].__args = args; window[callbackName].__result = undefined; debugger; return window[callbackName].__result; }; } } /** * @param {!InPageCallback} inPageCallback */ async _handleInPageCallback(inPageCallback) { var name = inPageCallback.name; var callback = inPageCallback.callback; var args = await this.evaluate(callbackName => window[callbackName].__args, name); var result = callback.apply(null, args); await this.evaluate(assignResult, name, result); this._client.send('Debugger.resume'); /** * @param {string} callbackName * @param {string} callbackResult */ function assignResult(callbackName, callbackResult) { window[callbackName].__result = callbackResult; } } _onDebuggerPaused(event) { var location = event.callFrames[0] ? event.callFrames[0].location : null; var inPageCallback = location ? this._scriptIdToPageCallback.get(location.scriptId) : null; if (inPageCallback) { this._handleInPageCallback(inPageCallback); return; } this._client.send('Debugger.resume'); } _onScriptParsed(event) { var inPageCallback = this._sourceURLToPageCallback.get(event.url); if (inPageCallback) this._scriptIdToPageCallback.set(event.scriptId, inPageCallback); } /** * @param {!Object} headers * @return {!Promise} */ async setExtraHTTPHeaders(headers) { this._extraHeaders = {}; // Note: header names are case-insensitive. for (var key of Object.keys(headers)) this._extraHeaders[key.toLowerCase()] = headers[key]; return this._client.send('Network.setExtraHTTPHeaders', { headers }); } /** * @return {!Object} */ extraHTTPHeaders() { return Object.assign({}, this._extraHeaders); } /** * @param {string} userAgent * @return {!Promise} */ async setUserAgentOverride(userAgent) { this._userAgent = userAgent; return this._client.send('Network.setUserAgentOverride', { userAgent }); } /** * @return {string} */ userAgentOverride() { return this._userAgent; } _handleException(exceptionDetails) { var stack = []; if (exceptionDetails.stackTrace) { stack = exceptionDetails.stackTrace.callFrames.map(cf => cf.url); } var stackTrace = exceptionDetails.stackTrace; this.emit(Page.Events.ExceptionThrown, exceptionDetails.exception.description, stack); } _onConsoleAPI(event) { var values = event.args.map(arg => arg.value || arg.description || ''); this.emit(Page.Events.ConsoleMessage, values.join(' ')); } /** * @param {boolean} accept * @param {string} promptText * @return {!Promise} */ async handleDialog(accept, promptText) { return this._client.send('Page.handleJavaScriptDialog', {accept, promptText}); } /** * @return {!Promise} */ async url() { return this.evaluate(function() { return window.location.href; }); } /** * @param {string} html * @return {!Promise} */ async setContent(html) { var resourceTree = await this._client.send('Page.getResourceTree', {}); await this._client.send('Page.setDocumentContent', { frameId: resourceTree.frameTree.frame.id, html: html }); } /** * @param {string} html * @return {!Promise} */ async navigate(url) { var loadPromise = new Promise(fulfill => this._client.once('Page.loadEventFired', fulfill)).then(() => true); var interstitialPromise = new Promise(fulfill => this._client.once('Security.certificateError', fulfill)).then(() => false); var referrer = this._extraHeaders.referer; // Await for the command to throw exception in case of illegal arguments. await this._client.send('Page.navigate', {url, referrer}); return await Promise.race([loadPromise, interstitialPromise]); } /** * @param {!{width: number, height: number}} size * @return {!Promise} */ async setSize(size) { this._size = size; var width = size.width; var height = size.height; var zoom = this._screenDPI; return Promise.all([ this._client.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, scale: 1 / zoom, mobile: false, fitWindow: false }), this._client.send('Emulation.setVisibleSize', { width: width / zoom, height: height / zoom, }) ]); } /** * @return {!{width: number, height: number}} */ size() { return this._size; } /** * @param {function()} fun * @param {!Array<*>} args * @return {!Promise<(!Object|udndefined)>} */ async evaluate(fun, ...args) { var response = await helpers.evaluate(this._client, fun, args, false /* awaitPromise */); if (response.exceptionDetails) { this._handleException(response.exceptionDetails); return; } return response.result.value; } /** * @param {function()} fun * @param {!Array<*>} args * @return {!Promise<(!Object|udndefined)>} */ async evaluateAsync(fun, ...args) { var response = await helpers.evaluate(this._client, fun, args, true /* awaitPromise */); if (response.exceptionDetails) { this._handleException(response.exceptionDetails); return; } return response.result.value; } /** * @param {function()} fun * @param {!Array<*>} args * @return {!Promise} */ async evaluateOnInitialized(fun, ...args) { var code = helpers.evaluationString(fun, args, false); await this._client.send('Page.addScriptToEvaluateOnLoad', { scriptSource: code }); } /** * @param {!Page.ScreenshotType} screenshotType * @param {?{x: number, y: number, width: number, height: number}} clipRect * @return {!Promise} */ async screenshot(screenshotType, clipRect) { if (clipRect) { await Promise.all([ this._client.send('Emulation.setVisibleSize', { width: clipRect.width / this._screenDPI, height: clipRect.height / this._screenDPI, }), this._client.send('Emulation.forceViewport', { x: clipRect.x / this._screenDPI, y: clipRect.y / this._screenDPI, scale: 1, }) ]); } var result = await this._client.send('Page.captureScreenshot', { fromSurface: true, format: screenshotType, }); if (clipRect) { await Promise.all([ this.setSize(this.size()), this._client.send('Emulation.resetViewport') ]); } return new Buffer(result.data, 'base64'); } /** * @param {string} filePath * @param {?{x: number, y: number, width: number, height: number}} clipRect * @return {!Promise} */ async saveScreenshot(filePath, clipRect) { var mimeType = mime.lookup(filePath); var screenshotType = null; if (mimeType === 'image/png') screenshotType = Page.ScreenshotTypes.PNG; else if (mimeType === 'image/jpeg') screenshotType = Page.ScreenshotTypes.JPG; if (!screenshotType) throw new Error(`Cannot render to file ${fileName} - unsupported mimeType ${mimeType}`); var buffer = await this.screenshot(screenshotType, clipRect); fs.writeFileSync(filePath, buffer); } /** * @return {!Promise} */ async plainText() { return this.evaluate(function() { return document.body.innerText; }); } /** * @return {!Promise} */ async title() { return this.evaluate(function() { return document.title; }); } /** * @return {!Promise} */ async close() { return this._browser.closePage(this); } } class InPageCallback { /** * @param {string} name * @param {function(?):?} callback */ constructor(name, callback) { this.name = name; this.callback = callback; } } /** @enum {string} */ Page.ScreenshotTypes = { PNG: "png", JPG: "jpeg", }; Page.Events = { ConsoleMessage: 'Page.Events.ConsoleMessage', DialogOpened: 'Page.Events.DialogOpened', ExceptionThrown: 'Page.Events.ExceptionThrown', ResourceLoadingFailed: 'Page.Events.ResourceLoadingFailed', ResponseReceived: 'Page.Events.ResponseReceived', }; module.exports = Page;