const {helper, assert, debugError} = require('./helper'); const {Keyboard, Mouse} = require('./Input'); const {constants} = require('./common'); const {Dialog} = require('./Dialog'); const {TimeoutError} = require('./Errors'); const fs = require('fs'); const mime = require('mime'); const util = require('util'); const EventEmitter = require('events'); const {JSHandle, createHandle} = require('./JSHandle'); const {Events} = require('./Events'); const writeFileAsync = util.promisify(fs.writeFile); const readFileAsync = util.promisify(fs.readFile); /** * @internal */ class PageSession extends EventEmitter { constructor(connection, pageId) { super(); this._connection = connection; this._pageId = pageId; const wrapperSymbol = Symbol('listenerWrapper'); function wrapperListener(listener, params) { if (params.pageId === pageId) listener.call(null, params); } this.on('removeListener', (eventName, listener) => { this._connection.removeListener(eventName, listener[wrapperSymbol]); }); this.on('newListener', (eventName, listener) => { if (!listener[wrapperSymbol]) listener[wrapperSymbol] = wrapperListener.bind(null, listener); this._connection.on(eventName, listener[wrapperSymbol]); }); } async send(method, params = {}) { params = Object.assign({}, params, {pageId: this._pageId}); return await this._connection.send(method, params); } } class Page extends EventEmitter { /** * * @param {!Puppeteer.Connection} connection * @param {!Puppeteer.Target} target * @param {string} pageId * @param {?Puppeteer.Viewport} defaultViewport */ static async create(connection, target, pageId, defaultViewport) { const session = new PageSession(connection, pageId); const page = new Page(session, target); await session.send('Page.enable'); if (defaultViewport) await page.setViewport(defaultViewport); return page; } /** * @param {!PageSession} session * @param {!Puppeteer.Target} target */ constructor(session, target) { super(); this._session = session; this._target = target; this._keyboard = new Keyboard(session); this._mouse = new Mouse(session, this._keyboard); this._isClosed = false; this._mainFrame = null; this._frames = new Map(); this._eventListeners = [ helper.addEventListener(this._session, 'Page.eventFired', this._onEventFired.bind(this)), helper.addEventListener(this._session, 'Page.uncaughtError', this._onUncaughtError.bind(this)), helper.addEventListener(this._session, 'Page.consoleAPICalled', this._onConsole.bind(this)), helper.addEventListener(this._session, 'Page.dialogOpened', this._onDialogOpened.bind(this)), helper.addEventListener(this._session, 'Browser.tabClosed', this._onClosed.bind(this)), helper.addEventListener(this._session, 'Page.frameAttached', this._onFrameAttached.bind(this)), helper.addEventListener(this._session, 'Page.frameDetached', this._onFrameDetached.bind(this)), helper.addEventListener(this._session, 'Page.navigationCommitted', this._onNavigationCommitted.bind(this)), helper.addEventListener(this._session, 'Page.sameDocumentNavigation', this._onSameDocumentNavigation.bind(this)), ]; this._viewport = null; } _onUncaughtError(params) { let error = new Error(params.message); error.stack = params.stack; this.emit(Events.Page.PageError, error); } viewport() { return this._viewport; } /** * @param {!Puppeteer.Viewport} viewport */ async setViewport(viewport) { const { width, height, isMobile = false, deviceScaleFactor = 1, hasTouch = false, isLandscape = false, } = viewport; 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; this._viewport = viewport; if (oldIsMobile !== isMobile || oldHasTouch !== hasTouch) await this.reload(); } /** * @param {function()|string} pageFunction * @param {!Array<*>} args */ async evaluateOnNewDocument(pageFunction, ...args) { const script = helper.evaluationString(pageFunction, ...args); await this._session.send('Page.addScriptToEvaluateOnNewDocument', { script }); } browser() { return this._target.browser(); } target() { return this._target; } url() { return this._mainFrame.url(); } frames() { /** @type {!Array} */ let frames = []; collect(this._mainFrame); return frames; function collect(frame) { frames.push(frame); for (const subframe of frame._children) collect(subframe); } } _onDialogOpened(params) { this.emit(Events.Page.Dialog, new Dialog(this._session, params)); } _onFrameAttached(params) { const frame = new Frame(this._session, this, params.frameId); const parentFrame = this._frames.get(params.parentFrameId) || null; if (parentFrame) { frame._parentFrame = parentFrame; parentFrame._children.add(frame); } else { assert(!this._mainFrame, 'INTERNAL ERROR: re-attaching main frame!'); this._mainFrame = frame; } this._frames.set(params.frameId, frame); this.emit(Events.Page.FrameAttached, frame); } mainFrame() { return this._mainFrame; } _onFrameDetached(params) { const frame = this._frames.get(params.frameId); this._frames.delete(params.frameId); frame._detach(); this.emit(Events.Page.FrameDetached, frame); } _onNavigationCommitted(params) { const frame = this._frames.get(params.frameId); frame._navigated(params.url, params.name, params.navigationId); frame._DOMContentLoadedFired = false; frame._loadFired = false; this.emit(Events.Page.FrameNavigated, frame); } _onSameDocumentNavigation(params) { const frame = this._frames.get(params.frameId); frame._url = params.url; this.emit(Events.Page.FrameNavigated, frame); } get keyboard(){ return this._keyboard; } get mouse(){ return this._mouse; } _normalizeWaitUntil(waitUntil) { if (!Array.isArray(waitUntil)) waitUntil = [waitUntil]; for (const condition of waitUntil) { if (condition !== 'load' && condition !== 'domcontentloaded') throw new Error('Unknown waitUntil condition: ' + condition); } return waitUntil; } /** * @param {!{timeout?: number, waitUntil?: string|!Array}} options */ async waitForNavigation(options = {}) { const { timeout = constants.DEFAULT_NAVIGATION_TIMEOUT, waitUntil = ['load'], } = options; const frame = this._mainFrame; const normalizedWaitUntil = this._normalizeWaitUntil(waitUntil); const timeoutError = new TimeoutError('Navigation Timeout Exceeded: ' + timeout + 'ms'); let timeoutCallback; const timeoutPromise = new Promise(resolve => timeoutCallback = resolve.bind(null, timeoutError)); const timeoutId = timeout ? setTimeout(timeoutCallback, timeout) : null; const nextNavigationDog = new NextNavigationWatchdog(this._session, frame); const error1 = await Promise.race([ nextNavigationDog.promise(), timeoutPromise, ]); nextNavigationDog.dispose(); // If timeout happened first - throw. if (error1) { clearTimeout(timeoutId); throw error1; } const {navigationId, url} = nextNavigationDog.navigation(); if (!navigationId) { // Same document navigation happened. clearTimeout(timeoutId); return; } const watchDog = new NavigationWatchdog(this._session, frame, navigationId, url, normalizedWaitUntil); const error = await Promise.race([ timeoutPromise, watchDog.promise(), ]); watchDog.dispose(); clearTimeout(timeoutId); if (error) throw error; } /** * @param {string} url * @param {!{timeout?: number, waitUntil?: string|!Array}} options */ async goto(url, options = {}) { const { timeout = constants.DEFAULT_NAVIGATION_TIMEOUT, waitUntil = ['load'], } = options; const frame = this._mainFrame; const normalizedWaitUntil = this._normalizeWaitUntil(waitUntil); const {navigationId} = await this._session.send('Page.navigate', { frameId: frame._frameId, url, }); if (!navigationId) return; const timeoutError = new TimeoutError('Navigation Timeout Exceeded: ' + timeout + 'ms'); let timeoutCallback; const timeoutPromise = new Promise(resolve => timeoutCallback = resolve.bind(null, timeoutError)); const timeoutId = timeout ? setTimeout(timeoutCallback, timeout) : null; const watchDog = new NavigationWatchdog(this._session, frame, navigationId, url, normalizedWaitUntil); const error = await Promise.race([ timeoutPromise, watchDog.promise(), ]); watchDog.dispose(); clearTimeout(timeoutId); if (error) throw error; } /** * @param {!{timeout?: number, waitUntil?: string|!Array}} options */ async goBack(options = {}) { const { timeout = constants.DEFAULT_NAVIGATION_TIMEOUT, waitUntil = ['load'], } = options; const frame = this._mainFrame; const normalizedWaitUntil = this._normalizeWaitUntil(waitUntil); const {navigationId, navigationURL} = await this._session.send('Page.goBack', { frameId: frame._frameId, }); if (!navigationId) return; const timeoutError = new TimeoutError('Navigation Timeout Exceeded: ' + timeout + 'ms'); let timeoutCallback; const timeoutPromise = new Promise(resolve => timeoutCallback = resolve.bind(null, timeoutError)); const timeoutId = timeout ? setTimeout(timeoutCallback, timeout) : null; const watchDog = new NavigationWatchdog(this._session, frame, navigationId, navigationURL, normalizedWaitUntil); const error = await Promise.race([ timeoutPromise, watchDog.promise(), ]); watchDog.dispose(); clearTimeout(timeoutId); if (error) throw error; } /** * @param {!{timeout?: number, waitUntil?: string|!Array}} options */ async goForward(options = {}) { const { timeout = constants.DEFAULT_NAVIGATION_TIMEOUT, waitUntil = ['load'], } = options; const frame = this._mainFrame; const normalizedWaitUntil = this._normalizeWaitUntil(waitUntil); const {navigationId, navigationURL} = await this._session.send('Page.goForward', { frameId: frame._frameId, }); if (!navigationId) return; const timeoutError = new TimeoutError('Navigation Timeout Exceeded: ' + timeout + 'ms'); let timeoutCallback; const timeoutPromise = new Promise(resolve => timeoutCallback = resolve.bind(null, timeoutError)); const timeoutId = timeout ? setTimeout(timeoutCallback, timeout) : null; const watchDog = new NavigationWatchdog(this._session, frame, navigationId, navigationURL, normalizedWaitUntil); const error = await Promise.race([ timeoutPromise, watchDog.promise(), ]); watchDog.dispose(); clearTimeout(timeoutId); if (error) throw error; } /** * @param {!{timeout?: number, waitUntil?: string|!Array}} options */ async reload(options = {}) { const { timeout = constants.DEFAULT_NAVIGATION_TIMEOUT, waitUntil = ['load'], } = options; const frame = this._mainFrame; const normalizedWaitUntil = this._normalizeWaitUntil(waitUntil); const {navigationId, navigationURL} = await this._session.send('Page.reload', { frameId: frame._frameId, }); if (!navigationId) return; const timeoutError = new TimeoutError('Navigation Timeout Exceeded: ' + timeout + 'ms'); let timeoutCallback; const timeoutPromise = new Promise(resolve => timeoutCallback = resolve.bind(null, timeoutError)); const timeoutId = timeout ? setTimeout(timeoutCallback, timeout) : null; const watchDog = new NavigationWatchdog(this._session, frame, navigationId, navigationURL, normalizedWaitUntil); const error = await Promise.race([ timeoutPromise, watchDog.promise(), ]); watchDog.dispose(); clearTimeout(timeoutId); if (error) throw error; } /** * @param {{fullPage?: boolean, clip?: {width: number, height: number, x: number, y: number}, encoding?: string, path?: string}} options * @return {Promise} */ async screenshot(options = {}) { const {data} = await this._session.send('Page.screenshot', { mimeType: getScreenshotMimeType(options), fullPage: options.fullPage, clip: options.clip, }); const buffer = options.encoding === 'base64' ? data : Buffer.from(data, 'base64'); if (options.path) await writeFileAsync(options.path, buffer); return buffer; } async evaluate(pageFunction, ...args) { return await this._mainFrame.evaluate(pageFunction, ...args); } /** * @param {!{content?: string, path?: string, type?: string, url?: string}} options * @return {!Promise} */ async addScriptTag(options) { return await this._mainFrame.addScriptTag(options); } /** * @param {!{content?: string, path?: string, url?: string}} options * @return {!Promise} */ async addStyleTag(options) { return await this._mainFrame.addStyleTag(options); } /** * @param {string} selector * @param {!{delay?: number, button?: string, clickCount?: number}=} options */ async click(selector, options = {}) { return await this._mainFrame.click(selector, options); } /** * @param {string} selector * @param {string} text * @param {{delay: (number|undefined)}=} options */ async type(selector, text, options) { return await this._mainFrame.type(selector, text, options); } /** * @param {string} selector */ async focus(selector) { return await this._mainFrame.focus(selector); } /** * @param {string} selector */ async hover(selector) { return await this._mainFrame.hover(selector); } /** * @param {(string|number|Function)} selectorOrFunctionOrTimeout * @param {!{polling?: string|number, timeout?: number, visible?: boolean, hidden?: boolean}=} options * @param {!Array<*>} args * @return {!Promise} */ async waitFor(selectorOrFunctionOrTimeout, options = {}, ...args) { return await this._mainFrame.waitFor(selectorOrFunctionOrTimeout, options, ...args); } /** * @param {Function|string} pageFunction * @param {!{polling?: string|number, timeout?: number}=} options * @return {!Promise} */ async waitForFunction(pageFunction, options = {}, ...args) { return await this._mainFrame.waitForFunction(pageFunction, options, ...args); } /** * @param {string} selector * @param {!{timeout?: number, visible?: boolean, hidden?: boolean}=} options * @return {!Promise} */ async waitForSelector(selector, options = {}) { return await this._mainFrame.waitForSelector(selector, options); } /** * @param {string} xpath * @param {!{timeout?: number, visible?: boolean, hidden?: boolean}=} options * @return {!Promise} */ async waitForXPath(xpath, options = {}) { return await this._mainFrame.waitForXPath(xpath, options); } /** * @return {!Promise} */ async title() { return await this._mainFrame.title(); } /** * @param {string} selector * @return {!Promise} */ async $(selector) { return await this._mainFrame.$(selector); } /** * @param {string} selector * @return {!Promise>} */ async $$(selector) { return await this._mainFrame.$$(selector); } /** * @param {string} selector * @param {Function|String} pageFunction * @param {!Array<*>} args * @return {!Promise<(!Object|undefined)>} */ async $eval(selector, pageFunction, ...args) { return await this._mainFrame.$eval(selector, pageFunction, ...args); } /** * @param {string} selector * @param {Function|String} pageFunction * @param {!Array<*>} args * @return {!Promise<(!Object|undefined)>} */ async $$eval(selector, pageFunction, ...args) { return await this._mainFrame.$$eval(selector, pageFunction, ...args); } /** * @param {string} expression * @return {!Promise>} */ async $x(expression) { return await this._mainFrame.$x(expression); } async evaluateHandle(pageFunction, ...args) { return await this._mainFrame.evaluateHandle(pageFunction, ...args); } /** * @param {string} selector * @param {!Array} values * @return {!Promise>} */ async select(selector, ...values) { return await this._mainFrame.select(selector, ...values); } async close() { await this._session.send('Browser.closePage' ); } async content() { return await this._mainFrame.content(); } /** * @param {string} html */ async setContent(html) { return await this._mainFrame.setContent(html); } _onClosed() { this._isClosed = true; helper.removeEventListeners(this._eventListeners); this.emit(Events.Page.Close); } _onEventFired({frameId, name}) { const frame = this._frames.get(frameId); frame._firedEvents.add(name.toLowerCase()); if (frame === this._mainFrame) { if (name === 'load') this.emit(Events.Page.Load); else if (name === 'DOMContentLoaded') this.emit(Events.Page.DOMContentLoaded); } } _onLoadFired({frameId}) { const frame = this._frames.get(frameId); frame._firedEvents.add('load'); } _onConsole({type, args, frameId}) { const frame = this._frames.get(frameId); this.emit(Events.Page.Console, new ConsoleMessage(type, args.map(arg => createHandle(frame, arg)))); } /** * @return {boolean} */ isClosed() { return this._isClosed; } } class ConsoleMessage { /** * @param {string} type * @param {!Array} args */ constructor(type, args) { this._type = type; this._args = args; } /** * @return {string} */ type() { return this._type; } /** * @return {!Array} */ args() { return this._args; } /** * @return {string} */ text() { return this._args.map(arg => { if (arg._objectId) return arg.toString(); return arg._deserializeValue(arg._protocolValue); }).join(' '); } } function getScreenshotMimeType(options) { // 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) { if (options.type === 'png') return 'image/png'; if (options.type === 'jpeg') return 'image/jpeg'; throw new Error('Unknown options.type value: ' + options.type); } if (options.path) { const fileType = mime.getType(options.path); if (fileType === 'image/png' || fileType === 'image/jpeg') return fileType; throw new Error('Unsupported screnshot mime type: ' + fileType); } return 'image/png'; } class Frame { /** * @param {*} session * @param {!Page} page * @param {string} frameId */ constructor(session, page, frameId) { this._session = session; this._page = page; this._frameId = frameId; /** @type {?Frame} */ this._parentFrame = null; this._url = ''; this._name = ''; /** @type {!Set} */ this._children = new Set(); this._isDetached = false; this._firedEvents = new Set(); /** @type {!Set} */ this._waitTasks = new Set(); this._documentPromise = null; } /** * @param {string} selector * @param {!{delay?: number, button?: string, clickCount?: number}=} options */ async click(selector, options = {}) { const handle = await this.$(selector); assert(handle, 'No node found for selector: ' + selector); await handle.click(options); await handle.dispose(); } /** * @param {string} selector * @param {string} text * @param {{delay: (number|undefined)}=} options */ async type(selector, text, options) { const handle = await this.$(selector); assert(handle, 'No node found for selector: ' + selector); await handle.type(text, options); await handle.dispose(); } /** * @param {string} selector */ async focus(selector) { const handle = await this.$(selector); assert(handle, 'No node found for selector: ' + selector); await handle.focus(); await handle.dispose(); } /** * @param {string} selector */ async hover(selector) { const handle = await this.$(selector); assert(handle, 'No node found for selector: ' + selector); await handle.hover(); await handle.dispose(); } _detach() { this._parentFrame._children.delete(this); this._parentFrame = null; this._isDetached = true; for (const waitTask of this._waitTasks) waitTask.terminate(new Error('waitForFunction failed: frame got detached.')); } _navigated(url, name, navigationId) { this._url = url; this._name = name; this._lastCommittedNavigationId = navigationId; this._documentPromise = null; this._firedEvents.clear(); } /** * @param {string} selector * @param {!Array} values * @return {!Promise>} */ select(selector, ...values) { for (const value of values) assert(helper.isString(value), 'Values must be strings. Found value "' + value + '" of type "' + (typeof value) + '"'); return this.$eval(selector, (element, values) => { if (element.nodeName.toLowerCase() !== 'select') throw new Error('Element is not a