diff --git a/experimental/puppeteer-firefox/lib/DOMWorld.js b/experimental/puppeteer-firefox/lib/DOMWorld.js new file mode 100644 index 0000000000000..97bafc0311f33 --- /dev/null +++ b/experimental/puppeteer-firefox/lib/DOMWorld.js @@ -0,0 +1,615 @@ +const {helper, assert} = require('./helper'); +const {TimeoutError} = require('./Errors'); +const fs = require('fs'); +const util = require('util'); +const readFileAsync = util.promisify(fs.readFile); + +class DOMWorld { + constructor(frame, timeoutSettings) { + this._frame = frame; + this._timeoutSettings = timeoutSettings; + + this._documentPromise = null; + this._contextPromise; + this._contextResolveCallback = null; + this._setContext(null); + + /** @type {!Set} */ + this._waitTasks = new Set(); + this._detached = false; + } + + frame() { + return this._frame; + } + + _setContext(context) { + if (context) { + this._contextResolveCallback.call(null, context); + this._contextResolveCallback = null; + for (const waitTask of this._waitTasks) + waitTask.rerun(); + } else { + this._documentPromise = null; + this._contextPromise = new Promise(fulfill => { + this._contextResolveCallback = fulfill; + }); + } + } + + _detach() { + this._detached = true; + for (const waitTask of this._waitTasks) + waitTask.terminate(new Error('waitForFunction failed: frame got detached.')); + } + + async executionContext() { + if (this._detached) + throw new Error(`Execution Context is not available in detached frame "${this.url()}" (are you trying to evaluate?)`); + return this._contextPromise; + } + + async evaluateHandle(pageFunction, ...args) { + const context = await this.executionContext(); + return context.evaluateHandle(pageFunction, ...args); + } + + async evaluate(pageFunction, ...args) { + const context = await this.executionContext(); + return context.evaluate(pageFunction, ...args); + } + + /** + * @param {string} selector + * @return {!Promise} + */ + async $(selector) { + const document = await this._document(); + return document.$(selector); + } + + _document() { + if (!this._documentPromise) + this._documentPromise = this.evaluateHandle('document').then(handle => handle.asElement()); + return this._documentPromise; + } + + /** + * @param {string} expression + * @return {!Promise>} + */ + async $x(expression) { + const document = await this._document(); + return document.$x(expression); + } + + /** + * @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} selector + * @return {!Promise>} + */ + async $$(selector) { + const document = await this._document(); + return document.$$(selector); + } + + /** + * @return {!Promise} + */ + 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); + } + + /** + * @param {!{content?: string, path?: string, type?: string, url?: string}} options + * @return {!Promise} + */ + 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} + */ + 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} + */ + 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} + */ + 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} + */ + 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; + } + } + + /** + * @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 + */ + 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(); + } + + /** + * @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 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); + return this._mainWorld.select(selector, ...values); } /** @@ -377,11 +327,7 @@ class Frame { * @return {!Promise} */ waitForFunction(pageFunction, options = {}, ...args) { - const { - polling = 'raf', - timeout = this._timeoutSettings.timeout(), - } = options; - return new WaitTask(this, pageFunction, 'function', polling, timeout, ...args).promise; + return this._mainWorld.waitForFunction(pageFunction, options, ...args); } /** @@ -390,7 +336,7 @@ class Frame { * @return {!Promise} */ waitForSelector(selector, options) { - return this._waitForSelectorOrXPath(selector, false, options); + return this._mainWorld.waitForSelector(selector, options); } /** @@ -399,97 +345,25 @@ class Frame { * @return {!Promise} */ 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} - */ - async _waitForSelectorOrXPath(selectorOrXPath, isXPath, options = {}) { - const { - visible: waitForVisible = false, - hidden: waitForHidden = false, - timeout = this._timeoutSettings.timeout(), - } = options; - const polling = waitForVisible || waitForHidden ? 'raf' : 'mutation'; - const title = `${isXPath ? 'XPath' : 'selector'} "${selectorOrXPath}"${waitForHidden ? ' to be hidden' : ''}`; - const waitTask = new WaitTask(this, predicate, title, polling, timeout, selectorOrXPath, isXPath, waitForVisible, waitForHidden); - const handle = await waitTask.promise; - if (!handle.asElement()) { - await handle.dispose(); - return null; - } - return handle.asElement(); - - /** - * @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 this._mainWorld.waitForXPath(xpath, options); } /** * @return {!Promise} */ 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; - }); + return this._mainWorld.content(); } /** * @param {string} html */ async setContent(html) { - await this.evaluate(html => { - document.open(); - document.write(html); - document.close(); - }, html); + return this._mainWorld.setContent(html); } async evaluate(pageFunction, ...args) { - const context = await this.executionContext(); - return context.evaluate(pageFunction, ...args); - } - - _document() { - if (!this._documentPromise) - this._documentPromise = this.evaluateHandle('document').then(handle => handle.asElement()); - return this._documentPromise; + return this._mainWorld.evaluate(pageFunction, ...args); } /** @@ -497,8 +371,7 @@ class Frame { * @return {!Promise} */ async $(selector) { - const document = await this._document(); - return document.$(selector); + return this._mainWorld.$(selector); } /** @@ -506,8 +379,7 @@ class Frame { * @return {!Promise>} */ async $$(selector) { - const document = await this._document(); - return document.$$(selector); + return this._mainWorld.$$(selector); } /** @@ -517,8 +389,7 @@ class Frame { * @return {!Promise<(!Object|undefined)>} */ async $eval(selector, pageFunction, ...args) { - const document = await this._document(); - return document.$eval(selector, pageFunction, ...args); + return this._mainWorld.$eval(selector, pageFunction, ...args); } /** @@ -528,8 +399,7 @@ class Frame { * @return {!Promise<(!Object|undefined)>} */ async $$eval(selector, pageFunction, ...args) { - const document = await this._document(); - return document.$$eval(selector, pageFunction, ...args); + return this._mainWorld.$$eval(selector, pageFunction, ...args); } /** @@ -537,13 +407,11 @@ class Frame { * @return {!Promise>} */ async $x(expression) { - const document = await this._document(); - return document.$x(expression); + return this._mainWorld.$x(expression); } async evaluateHandle(pageFunction, ...args) { - const context = await this.executionContext(); - return context.evaluateHandle(pageFunction, ...args); + return this._mainWorld.evaluateHandle(pageFunction, ...args); } /** @@ -551,62 +419,7 @@ class Frame { * @return {!Promise} */ 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} - */ - 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; - } + return this._mainWorld.addScriptTag(options); } /** @@ -614,67 +427,14 @@ class Frame { * @return {!Promise} */ 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} - */ - 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} - */ - 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 this._mainWorld.addStyleTag(options); } /** * @return {!Promise} */ async title() { - return this.evaluate(() => document.title); + return this._mainWorld.title(); } name() { @@ -698,194 +458,6 @@ class Frame { } } -/** - * @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); - } - } -} - function normalizeWaitUntil(waitUntil) { if (!Array.isArray(waitUntil)) waitUntil = [waitUntil]; diff --git a/experimental/puppeteer-firefox/package.json b/experimental/puppeteer-firefox/package.json index cd6f591e2e84d..4be4a567331a9 100644 --- a/experimental/puppeteer-firefox/package.json +++ b/experimental/puppeteer-firefox/package.json @@ -24,6 +24,7 @@ "mime": "^2.0.3", "progress": "^2.0.1", "proxy-from-env": "^1.0.0", - "rimraf": "^2.6.1" + "rimraf": "^2.6.1", + "ws": "^6.1.0" } }