45ab3e0332
This adds a proof-of-concept of `puppeteer-firefox`. This consists of two parts: - `//experimental/juggler` - patches to apply to Firefox. - `//experimental/puppeteer-firefox` - front-end code to be merged with Puppeteer. As things become more stable, we'll gradually move it out of the experimental folder.
1822 lines
51 KiB
JavaScript
1822 lines
51 KiB
JavaScript
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 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(Page.Events.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<!Frame>} */
|
|
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(Page.Events.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(Page.Events.FrameAttached, frame);
|
|
}
|
|
|
|
mainFrame() {
|
|
return this._mainFrame;
|
|
}
|
|
|
|
_onFrameDetached(params) {
|
|
const frame = this._frames.get(params.frameId);
|
|
this._frames.delete(params.frameId);
|
|
frame._detach();
|
|
this.emit(Page.Events.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(Page.Events.FrameNavigated, frame);
|
|
}
|
|
|
|
_onSameDocumentNavigation(params) {
|
|
const frame = this._frames.get(params.frameId);
|
|
frame._url = params.url;
|
|
this.emit(Page.Events.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<string>}} 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<string>}} 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<string>}} 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<string>}} 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<string>}} 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<string|Buffer>}
|
|
*/
|
|
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<!ElementHandle>}
|
|
*/
|
|
async addScriptTag(options) {
|
|
return await this._mainFrame.addScriptTag(options);
|
|
}
|
|
|
|
/**
|
|
* @param {!{content?: string, path?: string, url?: string}} options
|
|
* @return {!Promise<!ElementHandle>}
|
|
*/
|
|
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<!JSHandle>}
|
|
*/
|
|
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<!JSHandle>}
|
|
*/
|
|
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<!ElementHandle>}
|
|
*/
|
|
async waitForSelector(selector, options = {}) {
|
|
return await this._mainFrame.waitForSelector(selector, options);
|
|
}
|
|
|
|
/**
|
|
* @param {string} xpath
|
|
* @param {!{timeout?: number, visible?: boolean, hidden?: boolean}=} options
|
|
* @return {!Promise<!ElementHandle>}
|
|
*/
|
|
async waitForXPath(xpath, options = {}) {
|
|
return await this._mainFrame.waitForXPath(xpath, options);
|
|
}
|
|
|
|
/**
|
|
* @return {!Promise<string>}
|
|
*/
|
|
async title() {
|
|
return await this._mainFrame.title();
|
|
}
|
|
|
|
/**
|
|
* @param {string} selector
|
|
* @return {!Promise<?ElementHandle>}
|
|
*/
|
|
async $(selector) {
|
|
return await this._mainFrame.$(selector);
|
|
}
|
|
|
|
/**
|
|
* @param {string} selector
|
|
* @return {!Promise<!Array<!ElementHandle>>}
|
|
*/
|
|
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<!Array<!ElementHandle>>}
|
|
*/
|
|
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<string>} values
|
|
* @return {!Promise<!Array<string>>}
|
|
*/
|
|
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(Page.Events.Close);
|
|
}
|
|
|
|
_onEventFired({frameId, name}) {
|
|
const frame = this._frames.get(frameId);
|
|
frame._firedEvents.add(name.toLowerCase());
|
|
if (frame === this._mainFrame) {
|
|
if (name === 'load')
|
|
this.emit(Page.Events.Load);
|
|
else if (name === 'DOMContentLoaded')
|
|
this.emit(Page.Events.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(Page.Events.Console, new ConsoleMessage(type, args.map(arg => createHandle(frame, arg))));
|
|
}
|
|
|
|
/**
|
|
* @return {boolean}
|
|
*/
|
|
isClosed() {
|
|
return this._isClosed;
|
|
}
|
|
}
|
|
|
|
/** @enum {string} */
|
|
Page.Events = {
|
|
Close: 'close',
|
|
Console: 'console',
|
|
Dialog: 'dialog',
|
|
DOMContentLoaded: 'domcontentloaded',
|
|
FrameAttached: 'frameattached',
|
|
FrameDetached: 'framedetached',
|
|
FrameNavigated: 'framenavigated',
|
|
Load: 'load',
|
|
PageError: 'pageerror'
|
|
}
|
|
|
|
class ConsoleMessage {
|
|
/**
|
|
* @param {string} type
|
|
* @param {!Array<!JSHandle>} args
|
|
*/
|
|
constructor(type, args) {
|
|
this._type = type;
|
|
this._args = args;
|
|
}
|
|
|
|
/**
|
|
* @return {string}
|
|
*/
|
|
type() {
|
|
return this._type;
|
|
}
|
|
|
|
/**
|
|
* @return {!Array<!JSHandle>}
|
|
*/
|
|
args() {
|
|
return this._args;
|
|
}
|
|
|
|
/**
|
|
* @return {string}
|
|
*/
|
|
text() {
|
|
return this._args.map(arg => {
|
|
if (arg._objectId)
|
|
return arg.toString();
|
|
return arg._deserializeValue(arg._protocolValue);
|
|
}).join(' ');
|
|
}
|
|
}
|
|
|
|
class JSHandle {
|
|
|
|
/**
|
|
* @param {!Frame} frame
|
|
* @param {*} payload
|
|
*/
|
|
constructor(frame, payload) {
|
|
this._frame = frame;
|
|
this._session = this._frame._session;
|
|
this._frameId = this._frame._frameId;
|
|
this._objectId = payload.objectId;
|
|
this._type = payload.type;
|
|
this._subtype = payload.subtype;
|
|
this._protocolValue = {
|
|
unserializableValue: payload.unserializableValue,
|
|
value: payload.value,
|
|
objectId: payload.objectId,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
* @return {string}
|
|
*/
|
|
toString() {
|
|
if (this._objectId)
|
|
return 'JSHandle@' + (this._subtype || this._type);
|
|
return 'JSHandle:' + this._deserializeValue(this._protocolValue);
|
|
}
|
|
|
|
/**
|
|
* @param {string} propertyName
|
|
* @return {!Promise<?JSHandle>}
|
|
*/
|
|
async getProperty(propertyName) {
|
|
const objectHandle = await this._frame.evaluateHandle((object, propertyName) => {
|
|
const result = {__proto__: null};
|
|
result[propertyName] = object[propertyName];
|
|
return result;
|
|
}, this, propertyName);
|
|
const properties = await objectHandle.getProperties();
|
|
const result = properties.get(propertyName) || null;
|
|
await objectHandle.dispose();
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* @return {!Promise<Map<string, !JSHandle>>}
|
|
*/
|
|
async getProperties() {
|
|
const response = await this._session.send('Page.getObjectProperties', {
|
|
frameId: this._frameId,
|
|
objectId: this._objectId,
|
|
});
|
|
const result = new Map();
|
|
for (const property of response.properties) {
|
|
result.set(property.name, createHandle(this._frame, property.value, null));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
_deserializeValue({unserializableValue, value}) {
|
|
if (unserializableValue === 'Infinity')
|
|
return Infinity;
|
|
if (unserializableValue === '-Infinity')
|
|
return -Infinity;
|
|
if (unserializableValue === '-0')
|
|
return -0;
|
|
if (unserializableValue === 'NaN')
|
|
return NaN;
|
|
return value;
|
|
}
|
|
|
|
async jsonValue() {
|
|
if (!this._objectId)
|
|
return this._deserializeValue(this._protocolValue);
|
|
const simpleValue = await this._session.send('Page.evaluate', {
|
|
frameId: this._frameId,
|
|
returnByValue: true,
|
|
functionText: (e => e).toString(),
|
|
args: [this._protocolValue],
|
|
});
|
|
return this._deserializeValue(simpleValue.result);
|
|
}
|
|
|
|
/**
|
|
* @return {?ElementHandle}
|
|
*/
|
|
asElement() {
|
|
return null;
|
|
}
|
|
|
|
async dispose() {
|
|
if (!this._objectId)
|
|
return;
|
|
await this._session.send('Page.disposeObject', {
|
|
frameId: this._frameId,
|
|
objectId: this._objectId,
|
|
});
|
|
}
|
|
}
|
|
|
|
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 ElementHandle extends JSHandle {
|
|
|
|
/**
|
|
* @override
|
|
* @return {!ElementHandle}
|
|
*/
|
|
asElement() {
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* @return {!Promise<{width: number, height: number, x: number, y: number}>}
|
|
*/
|
|
async boundingBox() {
|
|
return await this._session.send('Page.getBoundingBox', {
|
|
frameId: this._frameId,
|
|
objectId: this._objectId,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {{encoding?: string, path?: string}} options
|
|
*/
|
|
async screenshot(options = {}) {
|
|
const clip = await this._session.send('Page.getBoundingBox', {
|
|
frameId: this._frameId,
|
|
objectId: this._objectId,
|
|
});
|
|
if (!clip)
|
|
throw new Error('Node is either not visible or not an HTMLElement');
|
|
await this._scrollIntoViewIfNeeded();
|
|
|
|
return await this._frame._page.screenshot(Object.assign({}, options, {
|
|
clip: {
|
|
x: Math.round(clip.x),
|
|
y: Math.round(clip.y),
|
|
width: Math.round(clip.width),
|
|
height: Math.round(clip.height),
|
|
},
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* @returns {!Promise<boolean>}
|
|
*/
|
|
isIntersectingViewport() {
|
|
return this._frame.evaluate(async element => {
|
|
const visibleRatio = await new Promise(resolve => {
|
|
const observer = new IntersectionObserver(entries => {
|
|
resolve(entries[0].intersectionRatio);
|
|
observer.disconnect();
|
|
});
|
|
observer.observe(element);
|
|
// Firefox doesn't call IntersectionObserver callback unless
|
|
// there are rafs.
|
|
requestAnimationFrame(() => {});
|
|
});
|
|
return visibleRatio > 0;
|
|
}, this);
|
|
}
|
|
|
|
/**
|
|
* @param {string} selector
|
|
* @return {!Promise<?ElementHandle>}
|
|
*/
|
|
async $(selector) {
|
|
const handle = await this._frame.evaluateHandle(
|
|
(element, selector) => element.querySelector(selector),
|
|
this, selector
|
|
);
|
|
const element = handle.asElement();
|
|
if (element)
|
|
return element;
|
|
await handle.dispose();
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param {string} selector
|
|
* @return {!Promise<!Array<!ElementHandle>>}
|
|
*/
|
|
async $$(selector) {
|
|
const arrayHandle = await this._frame.evaluateHandle(
|
|
(element, selector) => element.querySelectorAll(selector),
|
|
this, selector
|
|
);
|
|
const properties = await arrayHandle.getProperties();
|
|
await arrayHandle.dispose();
|
|
const result = [];
|
|
for (const property of properties.values()) {
|
|
const elementHandle = property.asElement();
|
|
if (elementHandle)
|
|
result.push(elementHandle);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* @param {string} selector
|
|
* @param {Function|String} pageFunction
|
|
* @param {!Array<*>} args
|
|
* @return {!Promise<(!Object|undefined)>}
|
|
*/
|
|
async $eval(selector, pageFunction, ...args) {
|
|
const elementHandle = await this.$(selector);
|
|
if (!elementHandle)
|
|
throw new Error(`Error: failed to find element matching selector "${selector}"`);
|
|
const result = await this._frame.evaluate(pageFunction, elementHandle, ...args);
|
|
await elementHandle.dispose();
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* @param {string} selector
|
|
* @param {Function|String} pageFunction
|
|
* @param {!Array<*>} args
|
|
* @return {!Promise<(!Object|undefined)>}
|
|
*/
|
|
async $$eval(selector, pageFunction, ...args) {
|
|
const arrayHandle = await this._frame.evaluateHandle(
|
|
(element, selector) => Array.from(element.querySelectorAll(selector)),
|
|
this, selector
|
|
);
|
|
|
|
const result = await this._frame.evaluate(pageFunction, arrayHandle, ...args);
|
|
await arrayHandle.dispose();
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* @param {string} expression
|
|
* @return {!Promise<!Array<!ElementHandle>>}
|
|
*/
|
|
async $x(expression) {
|
|
const arrayHandle = await this._frame.evaluateHandle(
|
|
(element, expression) => {
|
|
const document = element.ownerDocument || element;
|
|
const iterator = document.evaluate(expression, element, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE);
|
|
const array = [];
|
|
let item;
|
|
while ((item = iterator.iterateNext()))
|
|
array.push(item);
|
|
return array;
|
|
},
|
|
this, expression
|
|
);
|
|
const properties = await arrayHandle.getProperties();
|
|
await arrayHandle.dispose();
|
|
const result = [];
|
|
for (const property of properties.values()) {
|
|
const elementHandle = property.asElement();
|
|
if (elementHandle)
|
|
result.push(elementHandle);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async _scrollIntoViewIfNeeded() {
|
|
const error = await this._frame.evaluate(async(element) => {
|
|
if (!element.isConnected)
|
|
return 'Node is detached from document';
|
|
if (element.nodeType !== Node.ELEMENT_NODE)
|
|
return 'Node is not of type HTMLElement';
|
|
const visibleRatio = await new Promise(resolve => {
|
|
const observer = new IntersectionObserver(entries => {
|
|
resolve(entries[0].intersectionRatio);
|
|
observer.disconnect();
|
|
});
|
|
observer.observe(element);
|
|
// Firefox doesn't call IntersectionObserver callback unless
|
|
// there are rafs.
|
|
requestAnimationFrame(() => {});
|
|
});
|
|
if (visibleRatio !== 1.0)
|
|
element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
|
|
return false;
|
|
}, this);
|
|
if (error)
|
|
throw new Error(error);
|
|
}
|
|
|
|
/**
|
|
* @param {!{delay?: number, button?: string, clickCount?: number}=} options
|
|
*/
|
|
async click(options) {
|
|
await this._scrollIntoViewIfNeeded();
|
|
const {x, y} = await this._clickablePoint();
|
|
await this._frame._page.mouse.click(x, y, options);
|
|
}
|
|
|
|
async hover() {
|
|
await this._scrollIntoViewIfNeeded();
|
|
const {x, y} = await this._clickablePoint();
|
|
await this._frame._page.mouse.move(x, y);
|
|
}
|
|
|
|
async focus() {
|
|
await this._frame.evaluate(element => element.focus(), this);
|
|
}
|
|
|
|
/**
|
|
* @param {string} text
|
|
* @param {{delay: (number|undefined)}=} options
|
|
*/
|
|
async type(text, options) {
|
|
await this.focus();
|
|
await this._frame._page.keyboard.type(text, options);
|
|
}
|
|
|
|
/**
|
|
* @param {string} key
|
|
* @param {!{delay?: number}=} options
|
|
*/
|
|
async press(key, options) {
|
|
await this.focus();
|
|
await this._frame._page.keyboard.press(key, options);
|
|
}
|
|
|
|
|
|
/**
|
|
* @return {!Promise<!{x: number, y: number}>}
|
|
*/
|
|
async _clickablePoint() {
|
|
const result = await this._session.send('Page.getContentQuads', {
|
|
frameId: this._frameId,
|
|
objectId: this._objectId,
|
|
}).catch(debugError);
|
|
if (!result || !result.quads.length)
|
|
throw new Error('Node is either not visible or not an HTMLElement');
|
|
// Filter out quads that have too small area to click into.
|
|
const quads = result.quads.filter(quad => computeQuadArea(quad) > 1);
|
|
if (!quads.length)
|
|
throw new Error('Node is either not visible or not an HTMLElement');
|
|
// Return the middle point of the first quad.
|
|
return computeQuadCenter(quads[0]);
|
|
}
|
|
}
|
|
|
|
function createHandle(frame, result, exceptionDetails) {
|
|
if (exceptionDetails) {
|
|
if (exceptionDetails.value)
|
|
throw new Error('Evaluation failed: ' + JSON.stringify(exceptionDetails.value));
|
|
else
|
|
throw new Error('Evaluation failed: ' + exceptionDetails.text + '\n' + exceptionDetails.stack);
|
|
}
|
|
return result.subtype === 'node' ? new ElementHandle(frame, result) : new JSHandle(frame, result);
|
|
}
|
|
|
|
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<!Frame>} */
|
|
this._children = new Set();
|
|
this._isDetached = false;
|
|
|
|
this._firedEvents = new Set();
|
|
|
|
/** @type {!Set<!WaitTask>} */
|
|
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<string>} values
|
|
* @return {!Promise<!Array<string>>}
|
|
*/
|
|
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 <select> element.');
|
|
|
|
const options = Array.from(element.options);
|
|
element.value = undefined;
|
|
for (const option of options) {
|
|
option.selected = values.includes(option.value);
|
|
if (option.selected && !element.multiple)
|
|
break;
|
|
}
|
|
element.dispatchEvent(new Event('input', { 'bubbles': true }));
|
|
element.dispatchEvent(new Event('change', { 'bubbles': true }));
|
|
return options.filter(option => option.selected).map(option => option.value);
|
|
}, values);
|
|
}
|
|
|
|
/**
|
|
* @param {(string|number|Function)} selectorOrFunctionOrTimeout
|
|
* @param {!{polling?: string|number, timeout?: number, visible?: boolean, hidden?: boolean}=} options
|
|
* @param {!Array<*>} args
|
|
* @return {!Promise<!JSHandle>}
|
|
*/
|
|
waitFor(selectorOrFunctionOrTimeout, options, ...args) {
|
|
const xPathPattern = '//';
|
|
|
|
if (helper.isString(selectorOrFunctionOrTimeout)) {
|
|
const string = /** @type {string} */ (selectorOrFunctionOrTimeout);
|
|
if (string.startsWith(xPathPattern))
|
|
return this.waitForXPath(string, options);
|
|
return this.waitForSelector(string, options);
|
|
}
|
|
if (helper.isNumber(selectorOrFunctionOrTimeout))
|
|
return new Promise(fulfill => setTimeout(fulfill, /** @type {number} */ (selectorOrFunctionOrTimeout)));
|
|
if (typeof selectorOrFunctionOrTimeout === 'function')
|
|
return this.waitForFunction(selectorOrFunctionOrTimeout, options, ...args);
|
|
return Promise.reject(new Error('Unsupported target type: ' + (typeof selectorOrFunctionOrTimeout)));
|
|
}
|
|
|
|
/**
|
|
* @param {Function|string} pageFunction
|
|
* @param {!{polling?: string|number, timeout?: number}=} options
|
|
* @return {!Promise<!JSHandle>}
|
|
*/
|
|
waitForFunction(pageFunction, options = {}, ...args) {
|
|
const {
|
|
polling = 'raf',
|
|
timeout = 30000
|
|
} = options;
|
|
return new WaitTask(this, pageFunction, 'function', polling, timeout, ...args).promise;
|
|
}
|
|
|
|
/**
|
|
* @param {string} selector
|
|
* @param {!{timeout?: number, visible?: boolean, hidden?: boolean}=} options
|
|
* @return {!Promise<!ElementHandle>}
|
|
*/
|
|
waitForSelector(selector, options) {
|
|
return this._waitForSelectorOrXPath(selector, false, options);
|
|
}
|
|
|
|
/**
|
|
* @param {string} xpath
|
|
* @param {!{timeout?: number, visible?: boolean, hidden?: boolean}=} options
|
|
* @return {!Promise<!ElementHandle>}
|
|
*/
|
|
waitForXPath(xpath, options) {
|
|
return this._waitForSelectorOrXPath(xpath, true, options);
|
|
}
|
|
|
|
/**
|
|
* @param {string} selectorOrXPath
|
|
* @param {boolean} isXPath
|
|
* @param {!{timeout?: number, visible?: boolean, hidden?: boolean}=} options
|
|
* @return {!Promise<!ElementHandle>}
|
|
*/
|
|
_waitForSelectorOrXPath(selectorOrXPath, isXPath, options = {}) {
|
|
const {
|
|
visible: waitForVisible = false,
|
|
hidden: waitForHidden = false,
|
|
timeout = 30000
|
|
} = options;
|
|
const polling = waitForVisible || waitForHidden ? 'raf' : 'mutation';
|
|
const title = `${isXPath ? 'XPath' : 'selector'} "${selectorOrXPath}"${waitForHidden ? ' to be hidden' : ''}`;
|
|
return new WaitTask(this, predicate, title, polling, timeout, selectorOrXPath, isXPath, waitForVisible, waitForHidden).promise;
|
|
|
|
/**
|
|
* @param {string} selectorOrXPath
|
|
* @param {boolean} isXPath
|
|
* @param {boolean} waitForVisible
|
|
* @param {boolean} waitForHidden
|
|
* @return {?Node|boolean}
|
|
*/
|
|
function predicate(selectorOrXPath, isXPath, waitForVisible, waitForHidden) {
|
|
const node = isXPath
|
|
? document.evaluate(selectorOrXPath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue
|
|
: document.querySelector(selectorOrXPath);
|
|
if (!node)
|
|
return waitForHidden;
|
|
if (!waitForVisible && !waitForHidden)
|
|
return node;
|
|
const element = /** @type {Element} */ (node.nodeType === Node.TEXT_NODE ? node.parentElement : node);
|
|
|
|
const style = window.getComputedStyle(element);
|
|
const isVisible = style && style.visibility !== 'hidden' && hasVisibleBoundingBox();
|
|
const success = (waitForVisible === isVisible || waitForHidden === !isVisible);
|
|
return success ? node : null;
|
|
|
|
/**
|
|
* @return {boolean}
|
|
*/
|
|
function hasVisibleBoundingBox() {
|
|
const rect = element.getBoundingClientRect();
|
|
return !!(rect.top || rect.bottom || rect.width || rect.height);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return {!Promise<String>}
|
|
*/
|
|
async content() {
|
|
return await this.evaluate(() => {
|
|
let retVal = '';
|
|
if (document.doctype)
|
|
retVal = new XMLSerializer().serializeToString(document.doctype);
|
|
if (document.documentElement)
|
|
retVal += document.documentElement.outerHTML;
|
|
return retVal;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {string} html
|
|
*/
|
|
async setContent(html) {
|
|
await this.evaluate(html => {
|
|
document.open();
|
|
document.write(html);
|
|
document.close();
|
|
}, html);
|
|
}
|
|
|
|
async evaluate(pageFunction, ...args) {
|
|
try {
|
|
const handle = await this.evaluateHandle(pageFunction, ...args);
|
|
const result = await handle.jsonValue();
|
|
await handle.dispose();
|
|
return result;
|
|
} catch (e) {
|
|
if (e.message.includes('cyclic object value') || e.message.includes('Object is not serializable'))
|
|
return undefined;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
_document() {
|
|
if (!this._documentPromise)
|
|
this._documentPromise = this.evaluateHandle('document').then(handle => handle.asElement());
|
|
return this._documentPromise;
|
|
}
|
|
|
|
/**
|
|
* @param {string} selector
|
|
* @return {!Promise<?ElementHandle>}
|
|
*/
|
|
async $(selector) {
|
|
const document = await this._document();
|
|
return document.$(selector);
|
|
}
|
|
|
|
/**
|
|
* @param {string} selector
|
|
* @return {!Promise<!Array<!ElementHandle>>}
|
|
*/
|
|
async $$(selector) {
|
|
const document = await this._document();
|
|
return document.$$(selector);
|
|
}
|
|
|
|
/**
|
|
* @param {string} selector
|
|
* @param {Function|String} pageFunction
|
|
* @param {!Array<*>} args
|
|
* @return {!Promise<(!Object|undefined)>}
|
|
*/
|
|
async $eval(selector, pageFunction, ...args) {
|
|
const document = await this._document();
|
|
return document.$eval(selector, pageFunction, ...args);
|
|
}
|
|
|
|
/**
|
|
* @param {string} selector
|
|
* @param {Function|String} pageFunction
|
|
* @param {!Array<*>} args
|
|
* @return {!Promise<(!Object|undefined)>}
|
|
*/
|
|
async $$eval(selector, pageFunction, ...args) {
|
|
const document = await this._document();
|
|
return document.$$eval(selector, pageFunction, ...args);
|
|
}
|
|
|
|
/**
|
|
* @param {string} expression
|
|
* @return {!Promise<!Array<!ElementHandle>>}
|
|
*/
|
|
async $x(expression) {
|
|
const document = await this._document();
|
|
return document.$x(expression);
|
|
}
|
|
|
|
async evaluateHandle(pageFunction, ...args) {
|
|
if (helper.isString(pageFunction)) {
|
|
const payload = await this._session.send('Page.evaluate', {script: pageFunction, frameId: this._frameId});
|
|
return createHandle(this, payload.result, payload.exceptionDetails);
|
|
}
|
|
args = args.map(arg => {
|
|
if (arg instanceof JSHandle)
|
|
return arg._protocolValue;
|
|
if (Object.is(arg, Infinity))
|
|
return {unserializableValue: 'Infinity'};
|
|
if (Object.is(arg, -Infinity))
|
|
return {unserializableValue: '-Infinity'};
|
|
if (Object.is(arg, -0))
|
|
return {unserializableValue: '-0'};
|
|
if (Object.is(arg, NaN))
|
|
return {unserializableValue: 'NaN'};
|
|
return {value: arg};
|
|
});
|
|
const payload = await this._session.send('Page.evaluate', {
|
|
functionText: pageFunction.toString(),
|
|
args,
|
|
frameId: this._frameId
|
|
});
|
|
return createHandle(this, payload.result, payload.exceptionDetails);
|
|
}
|
|
|
|
/**
|
|
* @param {!{content?: string, path?: string, type?: string, url?: string}} options
|
|
* @return {!Promise<!ElementHandle>}
|
|
*/
|
|
async addScriptTag(options) {
|
|
if (typeof options.url === 'string') {
|
|
const url = options.url;
|
|
try {
|
|
return (await this.evaluateHandle(addScriptUrl, url, options.type)).asElement();
|
|
} catch (error) {
|
|
throw new Error(`Loading script from ${url} failed`);
|
|
}
|
|
}
|
|
|
|
if (typeof options.path === 'string') {
|
|
let contents = await readFileAsync(options.path, 'utf8');
|
|
contents += '//# sourceURL=' + options.path.replace(/\n/g, '');
|
|
return (await this.evaluateHandle(addScriptContent, contents, options.type)).asElement();
|
|
}
|
|
|
|
if (typeof options.content === 'string') {
|
|
return (await this.evaluateHandle(addScriptContent, options.content, options.type)).asElement();
|
|
}
|
|
|
|
throw new Error('Provide an object with a `url`, `path` or `content` property');
|
|
|
|
/**
|
|
* @param {string} url
|
|
* @param {string} type
|
|
* @return {!Promise<!HTMLElement>}
|
|
*/
|
|
async function addScriptUrl(url, type) {
|
|
const script = document.createElement('script');
|
|
script.src = url;
|
|
if (type)
|
|
script.type = type;
|
|
const promise = new Promise((res, rej) => {
|
|
script.onload = res;
|
|
script.onerror = rej;
|
|
});
|
|
document.head.appendChild(script);
|
|
await promise;
|
|
return script;
|
|
}
|
|
|
|
/**
|
|
* @param {string} content
|
|
* @param {string} type
|
|
* @return {!HTMLElement}
|
|
*/
|
|
function addScriptContent(content, type = 'text/javascript') {
|
|
const script = document.createElement('script');
|
|
script.type = type;
|
|
script.text = content;
|
|
let error = null;
|
|
script.onerror = e => error = e;
|
|
document.head.appendChild(script);
|
|
if (error)
|
|
throw error;
|
|
return script;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {!{content?: string, path?: string, url?: string}} options
|
|
* @return {!Promise<!ElementHandle>}
|
|
*/
|
|
async addStyleTag(options) {
|
|
if (typeof options.url === 'string') {
|
|
const url = options.url;
|
|
try {
|
|
return (await this.evaluateHandle(addStyleUrl, url)).asElement();
|
|
} catch (error) {
|
|
throw new Error(`Loading style from ${url} failed`);
|
|
}
|
|
}
|
|
|
|
if (typeof options.path === 'string') {
|
|
let contents = await readFileAsync(options.path, 'utf8');
|
|
contents += '/*# sourceURL=' + options.path.replace(/\n/g, '') + '*/';
|
|
return (await this.evaluateHandle(addStyleContent, contents)).asElement();
|
|
}
|
|
|
|
if (typeof options.content === 'string') {
|
|
return (await this.evaluateHandle(addStyleContent, options.content)).asElement();
|
|
}
|
|
|
|
throw new Error('Provide an object with a `url`, `path` or `content` property');
|
|
|
|
/**
|
|
* @param {string} url
|
|
* @return {!Promise<!HTMLElement>}
|
|
*/
|
|
async function addStyleUrl(url) {
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = url;
|
|
const promise = new Promise((res, rej) => {
|
|
link.onload = res;
|
|
link.onerror = rej;
|
|
});
|
|
document.head.appendChild(link);
|
|
await promise;
|
|
return link;
|
|
}
|
|
|
|
/**
|
|
* @param {string} content
|
|
* @return {!Promise<!HTMLElement>}
|
|
*/
|
|
async function addStyleContent(content) {
|
|
const style = document.createElement('style');
|
|
style.type = 'text/css';
|
|
style.appendChild(document.createTextNode(content));
|
|
const promise = new Promise((res, rej) => {
|
|
style.onload = res;
|
|
style.onerror = rej;
|
|
});
|
|
document.head.appendChild(style);
|
|
await promise;
|
|
return style;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return {!Promise<string>}
|
|
*/
|
|
async title() {
|
|
return this.evaluate(() => document.title);
|
|
}
|
|
|
|
name() {
|
|
return this._name;
|
|
}
|
|
|
|
isDetached() {
|
|
return this._isDetached;
|
|
}
|
|
|
|
childFrames() {
|
|
return Array.from(this._children);
|
|
}
|
|
|
|
url() {
|
|
return this._url;
|
|
}
|
|
|
|
parentFrame() {
|
|
return this._parentFrame;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
class WaitTask {
|
|
/**
|
|
* @param {!Frame} frame
|
|
* @param {Function|string} predicateBody
|
|
* @param {string|number} polling
|
|
* @param {number} timeout
|
|
* @param {!Array<*>} args
|
|
*/
|
|
constructor(frame, predicateBody, title, polling, timeout, ...args) {
|
|
if (helper.isString(polling))
|
|
assert(polling === 'raf' || polling === 'mutation', 'Unknown polling option: ' + polling);
|
|
else if (helper.isNumber(polling))
|
|
assert(polling > 0, 'Cannot poll with non-positive interval: ' + polling);
|
|
else
|
|
throw new Error('Unknown polling options: ' + polling);
|
|
|
|
this._frame = frame;
|
|
this._polling = polling;
|
|
this._timeout = timeout;
|
|
this._predicateBody = helper.isString(predicateBody) ? 'return ' + predicateBody : 'return (' + predicateBody + ')(...args)';
|
|
this._args = args;
|
|
this._runCount = 0;
|
|
frame._waitTasks.add(this);
|
|
this.promise = new Promise((resolve, reject) => {
|
|
this._resolve = resolve;
|
|
this._reject = reject;
|
|
});
|
|
// Since page navigation requires us to re-install the pageScript, we should track
|
|
// timeout on our end.
|
|
if (timeout) {
|
|
const timeoutError = new TimeoutError(`waiting for ${title} failed: timeout ${timeout}ms exceeded`);
|
|
this._timeoutTimer = setTimeout(() => this.terminate(timeoutError), timeout);
|
|
}
|
|
this.rerun();
|
|
}
|
|
|
|
/**
|
|
* @param {!Error} error
|
|
*/
|
|
terminate(error) {
|
|
this._terminated = true;
|
|
this._reject(error);
|
|
this._cleanup();
|
|
}
|
|
|
|
async rerun() {
|
|
const runCount = ++this._runCount;
|
|
/** @type {?JSHandle} */
|
|
let success = null;
|
|
let error = null;
|
|
try {
|
|
success = await this._frame.evaluateHandle(waitForPredicatePageFunction, this._predicateBody, this._polling, this._timeout, ...this._args);
|
|
} catch (e) {
|
|
error = e;
|
|
}
|
|
|
|
if (this._terminated || runCount !== this._runCount) {
|
|
if (success)
|
|
await success.dispose();
|
|
return;
|
|
}
|
|
|
|
// Ignore timeouts in pageScript - we track timeouts ourselves.
|
|
// If the frame's execution context has already changed, `frame.evaluate` will
|
|
// throw an error - ignore this predicate run altogether.
|
|
if (!error && await this._frame.evaluate(s => !s, success).catch(e => true)) {
|
|
await success.dispose();
|
|
return;
|
|
}
|
|
|
|
// When the page is navigated, the promise is rejected.
|
|
// Try again right away.
|
|
if (error && error.message.includes('Execution context was destroyed')) {
|
|
this.rerun();
|
|
return;
|
|
}
|
|
|
|
if (error)
|
|
this._reject(error);
|
|
else
|
|
this._resolve(success);
|
|
|
|
this._cleanup();
|
|
}
|
|
|
|
_cleanup() {
|
|
clearTimeout(this._timeoutTimer);
|
|
this._frame._waitTasks.delete(this);
|
|
this._runningTask = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {string} predicateBody
|
|
* @param {string} polling
|
|
* @param {number} timeout
|
|
* @return {!Promise<*>}
|
|
*/
|
|
async function waitForPredicatePageFunction(predicateBody, polling, timeout, ...args) {
|
|
const predicate = new Function('...args', predicateBody);
|
|
let timedOut = false;
|
|
if (timeout)
|
|
setTimeout(() => timedOut = true, timeout);
|
|
if (polling === 'raf')
|
|
return await pollRaf();
|
|
if (polling === 'mutation')
|
|
return await pollMutation();
|
|
if (typeof polling === 'number')
|
|
return await pollInterval(polling);
|
|
|
|
/**
|
|
* @return {!Promise<*>}
|
|
*/
|
|
function pollMutation() {
|
|
const success = predicate.apply(null, args);
|
|
if (success)
|
|
return Promise.resolve(success);
|
|
|
|
let fulfill;
|
|
const result = new Promise(x => fulfill = x);
|
|
const observer = new MutationObserver(mutations => {
|
|
if (timedOut) {
|
|
observer.disconnect();
|
|
fulfill();
|
|
}
|
|
const success = predicate.apply(null, args);
|
|
if (success) {
|
|
observer.disconnect();
|
|
fulfill(success);
|
|
}
|
|
});
|
|
observer.observe(document, {
|
|
childList: true,
|
|
subtree: true,
|
|
attributes: true
|
|
});
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* @return {!Promise<*>}
|
|
*/
|
|
function pollRaf() {
|
|
let fulfill;
|
|
const result = new Promise(x => fulfill = x);
|
|
onRaf();
|
|
return result;
|
|
|
|
function onRaf() {
|
|
if (timedOut) {
|
|
fulfill();
|
|
return;
|
|
}
|
|
const success = predicate.apply(null, args);
|
|
if (success)
|
|
fulfill(success);
|
|
else
|
|
requestAnimationFrame(onRaf);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {number} pollInterval
|
|
* @return {!Promise<*>}
|
|
*/
|
|
function pollInterval(pollInterval) {
|
|
let fulfill;
|
|
const result = new Promise(x => fulfill = x);
|
|
onTimeout();
|
|
return result;
|
|
|
|
function onTimeout() {
|
|
if (timedOut) {
|
|
fulfill();
|
|
return;
|
|
}
|
|
const success = predicate.apply(null, args);
|
|
if (success)
|
|
fulfill(success);
|
|
else
|
|
setTimeout(onTimeout, pollInterval);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
class NextNavigationWatchdog {
|
|
constructor(session, navigatedFrame) {
|
|
this._navigatedFrame = navigatedFrame;
|
|
this._promise = new Promise(x => this._resolveCallback = x);
|
|
this._navigation = null;
|
|
this._eventListeners = [
|
|
helper.addEventListener(session, 'Page.navigationStarted', this._onNavigationStarted.bind(this)),
|
|
helper.addEventListener(session, 'Page.sameDocumentNavigation', this._onSameDocumentNavigation.bind(this)),
|
|
];
|
|
}
|
|
|
|
promise() {
|
|
return this._promise;
|
|
}
|
|
|
|
navigation() {
|
|
return this._navigation;
|
|
}
|
|
|
|
_onNavigationStarted(params) {
|
|
if (params.frameId === this._navigatedFrame._frameId) {
|
|
this._navigation = {
|
|
navigationId: params.navigationId,
|
|
url: params.url,
|
|
};
|
|
this._resolveCallback();
|
|
}
|
|
}
|
|
|
|
_onSameDocumentNavigation(params) {
|
|
if (params.frameId === this._navigatedFrame._frameId) {
|
|
this._navigation = {
|
|
navigationId: null,
|
|
};
|
|
this._resolveCallback();
|
|
}
|
|
}
|
|
|
|
dispose() {
|
|
helper.removeEventListeners(this._eventListeners);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @internal
|
|
*/
|
|
class NavigationWatchdog {
|
|
constructor(session, navigatedFrame, targetNavigationId, targetURL, firedEvents) {
|
|
this._navigatedFrame = navigatedFrame;
|
|
this._targetNavigationId = targetNavigationId;
|
|
this._firedEvents = firedEvents;
|
|
this._targetURL = targetURL;
|
|
|
|
this._promise = new Promise(x => this._resolveCallback = x);
|
|
|
|
const check = this._checkNavigationComplete.bind(this);
|
|
this._eventListeners = [
|
|
helper.addEventListener(session, 'Page.eventFired', check),
|
|
helper.addEventListener(session, 'Page.frameAttached', check),
|
|
helper.addEventListener(session, 'Page.frameDetached', check),
|
|
helper.addEventListener(session, 'Page.navigationStarted', check),
|
|
helper.addEventListener(session, 'Page.navigationCommitted', check),
|
|
helper.addEventListener(session, 'Page.navigationAborted', this._onNavigationAborted.bind(this)),
|
|
];
|
|
check();
|
|
}
|
|
|
|
_checkNavigationComplete() {
|
|
if (this._navigatedFrame._lastCommittedNavigationId === this._targetNavigationId
|
|
&& checkFiredEvents(this._navigatedFrame, this._firedEvents)) {
|
|
this._resolveCallback(null);
|
|
}
|
|
|
|
function checkFiredEvents(frame, firedEvents) {
|
|
for (const subframe of frame._children) {
|
|
if (!checkFiredEvents(subframe, firedEvents))
|
|
return false;
|
|
}
|
|
return firedEvents.every(event => frame._firedEvents.has(event));
|
|
}
|
|
}
|
|
|
|
_onNavigationAborted(params) {
|
|
if (params.frameId === this._navigatedFrame._frameId && params.navigationId === this._targetNavigationId)
|
|
this._resolveCallback(new Error('Navigation to ' + this._targetURL + ' failed: ' + params.errorText));
|
|
}
|
|
|
|
promise() {
|
|
return this._promise;
|
|
}
|
|
|
|
dispose() {
|
|
helper.removeEventListeners(this._eventListeners);
|
|
}
|
|
}
|
|
|
|
function computeQuadArea(quad) {
|
|
// Compute sum of all directed areas of adjacent triangles
|
|
// https://en.wikipedia.org/wiki/Polygon#Simple_polygons
|
|
let area = 0;
|
|
const points = [quad.p1, quad.p2, quad.p3, quad.p4];
|
|
for (let i = 0; i < points.length; ++i) {
|
|
const p1 = points[i];
|
|
const p2 = points[(i + 1) % points.length];
|
|
area += (p1.x * p2.y - p2.x * p1.y) / 2;
|
|
}
|
|
return Math.abs(area);
|
|
}
|
|
|
|
function computeQuadCenter(quad) {
|
|
let x = 0, y = 0;
|
|
for (const point of [quad.p1, quad.p2, quad.p3, quad.p4]) {
|
|
x += point.x;
|
|
y += point.y;
|
|
}
|
|
return {x: x / 4, y: y / 4};
|
|
}
|
|
|
|
|
|
module.exports = {Page};
|