/** * 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. */ let await = require('./utilities').await; let EventEmitter = require('events'); let fs = require('fs'); let path = require('path'); let PageEvents = require('../lib/Page').Events; let noop = function() { }; class WebPage { /** * @param {!Browser} browser * @param {string} scriptPath * @param {!Object=} options */ constructor(browser, scriptPath, options) { this._page = await(browser.newPage()); this.settings = new WebPageSettings(this._page); options = options || {}; options.settings = options.settings || {}; if (options.settings.userAgent) this.settings.userAgent = options.settings.userAgent; if (options.viewportSize) await(this._page.setViewport(options.viewportSize)); this.loading = false; this.loadingProgress = 0; this.clipRect = options.clipRect || {left: 0, top: 0, width: 0, height: 0}; this.onConsoleMessage = null; this.onLoadFinished = null; this.onResourceError = null; this.onResourceReceived = null; this._onInitialized = undefined; this._deferEvaluate = false; this._customHeaders = {}; this._currentFrame = this._page.mainFrame(); this.libraryPath = path.dirname(scriptPath); this._onResourceRequestedCallback = undefined; this._onConfirmCallback = undefined; this._onAlertCallback = undefined; this._onError = noop; this._pageEvents = new AsyncEmitter(this._page); this._pageEvents.on(PageEvents.Response, response => this._onResponseReceived(response)); this._pageEvents.on(PageEvents.RequestFinished, request => this._onRequestFinished(request)); this._pageEvents.on(PageEvents.RequestFailed, event => (this.onResourceError || noop).call(null, event)); this._pageEvents.on(PageEvents.Console, (...args) => this._onConsole(...args)); this._pageEvents.on(PageEvents.Confirm, message => this._onConfirm(message)); this._pageEvents.on(PageEvents.Alert, message => this._onAlert(message)); this._pageEvents.on(PageEvents.Dialog, dialog => this._onDialog(dialog)); this._pageEvents.on(PageEvents.PageError, error => (this._onError || noop).call(null, error.message, error.stack)); this.event = { key: { A: 65, B: 66, C: 67, Home: ['Home'], Delete: ['Delete'], Backspace: ['Backspace'], Cut: ['Cut'], Paste: ['Paste'] }, modifier: { shift: 'Shift' } }; } /** * @param {!Array} args */ _onConsole(...args) { if (!this.onConsoleMessage) return; const text = args.join(' '); this.onConsoleMessage(text); } /** * @return {string} */ currentFrameName() { return this.frameName; } /** * @return {number} */ childFramesCount() { return this.framesCount; } /** * @return {!Array} */ childFramesName() { return this.framesName; } /** * @param {(string|number)} frameName * @return {boolean} */ switchToChildFrame(frame) { return this.switchToFrame(frame); } /** * @return {string} */ get frameName() { return this._currentFrame.name(); } /** * @return {number} */ get framesCount() { return this._currentFrame.childFrames().length; } /** * @return {!Array} */ get framesName() { return this._currentFrame.childFrames().map(frame => frame.name()); } /** * @return {string} */ get focusedFrameName() { let focusedFrame = this._focusedFrame(); return focusedFrame ? focusedFrame.name() : ''; } /** * @return {?Frame} */ _focusedFrame() { let frames = this._currentFrame.childFrames().slice(); frames.push(this._currentFrame); let promises = frames.map(frame => frame.evaluate(() => document.hasFocus())); let result = await(Promise.all(promises)); for (let i = 0; i < result.length; ++i) { if (result[i]) return frames[i]; } return null; } switchToFocusedFrame() { let frame = this._focusedFrame(); this._currentFrame = frame; } /** * @param {(string|number)} frameName * @return {boolean} */ switchToFrame(frameName) { let frame = null; if (typeof frameName === 'string') frame = this._currentFrame.childFrames().find(frame => frame.name() === frameName); else if (typeof frameName === 'number') frame = this._currentFrame.childFrames()[frameName]; if (!frame) return false; this._currentFrame = frame; return true; } /** * @return {boolean} */ switchToParentFrame() { let frame = this._currentFrame.parentFrame(); if (!frame) return false; this._currentFrame = frame; return true; } switchToMainFrame() { this._currentFrame = this._page.mainFrame(); } get onInitialized() { return this._onInitialized; } set onInitialized(value) { if (typeof value !== 'function') this._onInitialized = undefined; else this._onInitialized = value; } /** * @return {?function(!Object, !Request)} */ get onResourceRequested() { return this._onResourceRequestedCallback; } /** * @return {?function(!Object, !Request)} callback */ set onResourceRequested(callback) { this._onResourceRequestedCallback = callback; this._page.setRequestInterceptor(callback ? resourceInterceptor : null); /** * @param {!InterceptedRequest} request */ function resourceInterceptor(request) { let requestData = new RequestData(request); let phantomRequest = new PhantomRequest(request); callback(requestData, phantomRequest); if (phantomRequest._aborted) request.abort(); else request.continue(); } } _onResponseReceived(response) { if (!this.onResourceReceived) return; let phantomResponse = new PhantomResponse(response, false /* isResponseFinished */); this.onResourceReceived.call(null, phantomResponse); } _onRequestFinished(request) { if (!this.onResourceReceived) return; let phantomResponse = new PhantomResponse(request.response(), true /* isResponseFinished */); this.onResourceReceived.call(null, phantomResponse); } /** * @param {string} url * @param {function()} callback */ includeJs(url, callback) { this._page.addScriptTag(url).then(callback); } /** * @return {!{width: number, height: number}} */ get viewportSize() { return this._page.viewport(); } /** * @return {!Object} */ get customHeaders() { return this._customHeaders; } /** * @param {!Object} value */ set customHeaders(value) { this._customHeaders = value; await(this._page.setHTTPHeaders(value)); } /** * @param {string} filePath */ injectJs(filePath) { if (!fs.existsSync(filePath)) filePath = path.resolve(this.libraryPath, filePath); if (!fs.existsSync(filePath)) return false; await(this._page.injectFile(filePath)); return true; } /** * @return {string} */ get plainText() { return await(this._page.plainText()); } /** * @return {string} */ get title() { return await(this._page.title()); } /** * @return {(function()|undefined)} */ get onError() { return this._onError; } /** * @param {(function()|undefined)} handler */ set onError(handler) { if (typeof handler !== 'function') handler = undefined; this._onError = handler; } /** * @return {(function()|undefined)} */ get onConfirm() { return this._onConfirmCallback; } /** * @param {function()} handler */ set onConfirm(handler) { if (typeof handler !== 'function') handler = undefined; this._onConfirmCallback = handler; } /** * @return {(function()|undefined)} */ get onAlert() { return this._onAlertCallback; } /** * @param {function()} handler */ set onAlert(handler) { if (typeof handler !== 'function') handler = undefined; this._onAlertCallback = handler; } /** * @param {!Dialog} dialog */ _onDialog(dialog) { if (dialog.type === 'alert' && this._onAlertCallback) { this._onAlertCallback.call(null, dialog.message()); await(dialog.accept()); } else if (dialog.type === 'confirm' && this._onConfirmCallback) { let result = this._onConfirmCallback.call(null, dialog.message()); await(result ? dialog.accept() : dialog.dismiss()); } } /** * @return {string} */ get url() { return await(this._page.url()); } /** * @param {string} html */ set content(html) { await(this._page.setContent(html)); } /** * @param {string} selector * @param {(string|!Array)} files */ uploadFile(selector, files) { if (typeof files === 'string') await(this._page.uploadFile(selector, files)); else await(this._page.uploadFile(selector, ...files)); } /** * @param {string} eventType * @param {!Array<*>} args */ sendEvent(eventType, ...args) { if (eventType.startsWith('key')) this._sendKeyboardEvent.apply(this, arguments); else this._sendMouseEvent.apply(this, arguments); } /** * @param {string} eventType * @param {string} keyOrKeys * @param {null} nop1 * @param {null} nop2 * @param {number} modifier */ _sendKeyboardEvent(eventType, keyOrKeys, nop1, nop2, modifier) { switch (eventType) { case 'keyup': if (typeof keyOrKeys === 'number') { await(this._page.keyboard.up(String.fromCharCode(keyOrKeys))); break; } for (let key of keyOrKeys) await(this._page.keyboard.up(key)); break; case 'keypress': if (modifier & 0x04000000) this._page.keyboard.down('Control'); if (modifier & 0x02000000) this._page.keyboard.down('Shift'); if (keyOrKeys instanceof Array) { this._page.keyboard.down(keyOrKeys[0]); await(this._page.keyboard.up(keyOrKeys[0])); } else if (typeof keyOrKeys === 'number') { await(this._page.type(String.fromCharCode(keyOrKeys))); } else { await(this._page.type(keyOrKeys)); } if (modifier & 0x02000000) this._page.keyboard.up('Shift'); if (modifier & 0x04000000) this._page.keyboard.up('Control'); break; case 'keydown': if (typeof keyOrKeys === 'number') { await(this._page.keyboard.down(String.fromCharCode(keyOrKeys))); break; } for (let key of keyOrKeys) await(this._page.keyboard.down(key)); break; } } /** * @param {string} eventType * @param {number} x * @param {number} y * @param {string|undefined} button * @param {number|undefined} modifier */ _sendMouseEvent(eventType, x, y, button, modifier) { if (modifier) await(this._page.keyboard.down(modifier)); switch (eventType) { case 'mousemove': await(this._page.mouse.move(x, y)); break; case 'mousedown': await(this._page.mouse.move(x, y)); await(this._page.mouse.down({button})); break; case 'mouseup': await(this._page.mouse.move(x, y)); await(this._page.mouse.up({button})); break; case 'doubleclick': await(this._page.mouse.click(x, y, {button})); await(this._page.mouse.click(x, y, {button, clickCount: 2})); break; case 'click': await(this._page.mouse.click(x, y, {button})); break; case 'contextmenu': await(this._page.mouse.click(x, y, {button: 'right'})); break; } if (modifier) await(this._page.keyboard.up(modifier)); } /** * @param {string} html * @param {function()=} callback */ open(url, callback) { console.assert(arguments.length <= 2, 'WebPage.open does not support METHOD and DATA arguments'); this._deferEvaluate = true; if (typeof this._onInitialized === 'function') this._onInitialized(); this._deferEvaluate = false; this.loading = true; this.loadingProgress = 50; const handleNavigation = (error, response) => { this.loadingProgress = 100; this.loading = false; if (error) { this.onResourceError.call(null, { url, errorString: 'SSL handshake failed' }); } let status = error ? 'fail' : 'success'; if (this.onLoadFinished) this.onLoadFinished.call(null, status); if (callback) callback.call(null, status); this.loadingProgress = 0; }; this._page.navigate(url).then(response => handleNavigation(null, response)) .catch(e => handleNavigation(e, null)); } /** * @param {!{width: number, height: number}} options */ set viewportSize(options) { await(this._page.setViewport(options)); } /** * @param {function()} fun * @param {!Array} args */ evaluate(fun, ...args) { if (this._deferEvaluate) return await(this._page.evaluateOnNewDocument(fun, ...args)); return await(this._currentFrame.evaluate(fun, ...args)); } /** * {string} fileName */ render(fileName) { if (fileName.endsWith('pdf')) { let options = {}; let paperSize = this.paperSize || {}; options.margin = paperSize.margin; options.format = paperSize.format; options.landscape = paperSize.orientation === 'landscape'; options.width = paperSize.width; options.height = paperSize.height; options.path = fileName; await(this._page.pdf(options)); } else { let options = {}; if (this.clipRect && (this.clipRect.left || this.clipRect.top || this.clipRect.width || this.clipRect.height)) { options.clip = { x: this.clipRect.left, y: this.clipRect.top, width: this.clipRect.width, height: this.clipRect.height }; } options.path = fileName; await(this._page.screenshot(options)); } } release() { this._page.close(); } close() { this._page.close(); } } class WebPageSettings { /** * @param {!Page} page */ constructor(page) { this._page = page; } /** * @param {string} value */ set userAgent(value) { await(this._page.setUserAgent(value)); } /** * @return {string} */ get userAgent() { return await(this._page.evaluate(() => window.navigator.userAgent)); } } class PhantomRequest { /** * @param {!InterceptedRequest} request */ constructor(request) { this._request = request; } /** * @param {string} key * @param {string} value */ setHeader(key, value) { this._request.headers.set(key, value); } abort() { this._aborted = true; } /** * @param {string} url */ changeUrl(newUrl) { this._request.url = newUrl; } } class PhantomResponse { /** * @param {!Response} response * @param {boolean} isResponseFinished */ constructor(response, isResponseFinished) { this.url = response.url; this.status = response.status; this.statusText = response.statusText; this.stage = isResponseFinished ? 'end' : 'start'; this.headers = []; for (let entry of response.headers.entries()) { this.headers.push({ name: entry[0], value: entry[1] }); } } } class RequestData { /** * @param {!InterceptedRequest} request */ constructor(request) { this.url = request.url, this.headers = {}; for (let entry of request.headers.entries()) this.headers[entry[0]] = entry[1]; } } // To prevent reenterability, eventemitters should emit events // only being in a consistent state. // This is not the case for 'ws' npm module: https://goo.gl/sy3dJY // // Since out phantomjs environment uses nested event loops, we // exploit this condition in 'ws', which probably never happens // in case of regular I/O. // // This class is a wrapper around EventEmitter which re-emits events asynchronously, // helping to overcome the issue. class AsyncEmitter extends EventEmitter { /** * @param {!Page} page */ constructor(page) { super(); this._page = page; this._symbol = Symbol('AsyncEmitter'); this.on('newListener', this._onListenerAdded); this.on('removeListener', this._onListenerRemoved); } _onListenerAdded(event, listener) { // Async listener calls original listener on next tick. let asyncListener = (...args) => { process.nextTick(() => listener.apply(null, args)); }; listener[this._symbol] = asyncListener; this._page.on(event, asyncListener); } _onListenerRemoved(event, listener) { this._page.removeListener(event, listener[this._symbol]); } } module.exports = WebPage;