From 4ac00caf9db30b3883e0ed3e5da93160872c4648 Mon Sep 17 00:00:00 2001 From: Andrey Lushnikov Date: Tue, 15 Jan 2019 17:21:23 -0800 Subject: [PATCH] refactor: split out DOMWorld from Frame (#3780) This patch splits out `IsolatedWorld` class from Frame. The `IsolatedWorld` abstraction is an execution context with a designated set of DOM wrappers. References #2671 --- lib/DOMWorld.js | 719 ++++++++++++++++++++++++ lib/FrameManager.js | 543 +----------------- utils/doclint/check_public_api/index.js | 1 + 3 files changed, 748 insertions(+), 515 deletions(-) create mode 100644 lib/DOMWorld.js diff --git a/lib/DOMWorld.js b/lib/DOMWorld.js new file mode 100644 index 00000000000..c466ceb6c62 --- /dev/null +++ b/lib/DOMWorld.js @@ -0,0 +1,719 @@ +/** + * Copyright 2019 Google Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const fs = require('fs'); +const {helper, assert} = require('./helper'); +const {LifecycleWatcher} = require('./LifecycleWatcher'); +const {TimeoutError} = require('./Errors'); +const readFileAsync = helper.promisify(fs.readFile); + +/** + * @unrestricted + */ +class DOMWorld { + /** + * @param {!Puppeteer.FrameManager} frameManager + * @param {!Puppeteer.Frame} frame + */ + constructor(frameManager, frame) { + this._frameManager = frameManager; + this._frame = frame; + + /** @type {?Promise} */ + this._documentPromise = null; + /** @type {!Promise} */ + this._contextPromise; + this._contextResolveCallback = null; + this._setContext(null); + + /** @type {!Set} */ + this._waitTasks = new Set(); + this._detached = false; + } + + /** + * @param {?Puppeteer.ExecutionContext} context + */ + _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.')); + } + + /** + * @return {!Promise} + */ + executionContext() { + if (this._detached) + throw new Error(`Execution Context is not available in detached frame "${this._frame.url()}" (are you trying to evaluate?)`); + return this._contextPromise; + } + + /** + * @param {Function|string} pageFunction + * @param {!Array<*>} args + * @return {!Promise} + */ + async evaluateHandle(pageFunction, ...args) { + const context = await this.executionContext(); + return context.evaluateHandle(pageFunction, ...args); + } + + /** + * @param {Function|string} pageFunction + * @param {!Array<*>} args + * @return {!Promise<*>} + */ + 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(); + const value = await document.$(selector); + return value; + } + + /** + * @return {!Promise} + */ + async _document() { + if (this._documentPromise) + return this._documentPromise; + this._documentPromise = this.executionContext().then(async context => { + const document = await context.evaluateHandle('document'); + return document.asElement(); + }); + return this._documentPromise; + } + + /** + * @param {string} expression + * @return {!Promise>} + */ + async $x(expression) { + const document = await this._document(); + const value = await document.$x(expression); + return value; + } + + /** + * @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(); + const value = await document.$$eval(selector, pageFunction, ...args); + return value; + } + + /** + * @param {string} selector + * @return {!Promise>} + */ + async $$(selector) { + const document = await this._document(); + const value = await document.$$(selector); + return value; + } + + /** + * @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 + * @param {!{timeout?: number, waitUntil?: string|!Array}=} options + */ + async setContent(html, options = {}) { + const { + waitUntil = ['load'], + timeout = 30000, + } = options; + // We rely upon the fact that document.open() will reset frame lifecycle with "init" + // lifecycle event. @see https://crrev.com/608658 + await this.evaluate(html => { + document.open(); + document.write(html); + document.close(); + }, html); + const watcher = new LifecycleWatcher(this._frameManager, this._frame, waitUntil, timeout); + const error = await Promise.race([ + watcher.timeoutOrTerminationPromise(), + watcher.lifecyclePromise(), + ]); + watcher.dispose(); + if (error) + throw error; + } + + /** + * @param {!{url?: string, path?: string, content?: string, type?: string}} options + * @return {!Promise} + */ + async addScriptTag(options) { + const { + url = null, + path = null, + content = null, + type = '' + } = options; + if (url !== null) { + try { + const context = await this.executionContext(); + return (await context.evaluateHandle(addScriptUrl, url, type)).asElement(); + } catch (error) { + throw new Error(`Loading script from ${url} failed`); + } + } + + if (path !== null) { + let contents = await readFileAsync(path, 'utf8'); + contents += '//# sourceURL=' + path.replace(/\n/g, ''); + const context = await this.executionContext(); + return (await context.evaluateHandle(addScriptContent, contents, type)).asElement(); + } + + if (content !== null) { + const context = await this.executionContext(); + return (await context.evaluateHandle(addScriptContent, content, 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 {!{url?: string, path?: string, content?: string}} options + * @return {!Promise} + */ + async addStyleTag(options) { + const { + url = null, + path = null, + content = null + } = options; + if (url !== null) { + try { + const context = await this.executionContext(); + return (await context.evaluateHandle(addStyleUrl, url)).asElement(); + } catch (error) { + throw new Error(`Loading style from ${url} failed`); + } + } + + if (path !== null) { + let contents = await readFileAsync(path, 'utf8'); + contents += '/*# sourceURL=' + path.replace(/\n/g, '') + '*/'; + const context = await this.executionContext(); + return (await context.evaluateHandle(addStyleContent, contents)).asElement(); + } + + if (content !== null) { + const context = await this.executionContext(); + return (await context.evaluateHandle(addStyleContent, 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?: "left"|"right"|"middle", 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); } /** * @param {string} selector */ async tap(selector) { - const handle = await this.$(selector); - assert(handle, 'No node found for selector: ' + selector); - await handle.tap(); - await handle.dispose(); + return this._mainWorld.tap(selector); } /** @@ -790,10 +559,7 @@ class Frame { * @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(); + return this._mainWorld.type(selector, text, options); } /** @@ -803,19 +569,7 @@ class Frame { * @return {!Promise} */ 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))); + return this._mainWorld.waitFor(selectorOrFunctionOrTimeout, options, ...args); } /** @@ -824,7 +578,7 @@ class Frame { * @return {!Promise} */ waitForSelector(selector, options) { - return this._waitForSelectorOrXPath(selector, false, options); + return this._mainWorld.waitForSelector(selector, options); } /** @@ -833,7 +587,7 @@ class Frame { * @return {!Promise} */ waitForXPath(xpath, options) { - return this._waitForSelectorOrXPath(xpath, true, options); + return this._mainWorld.waitForXPath(xpath, options); } /** @@ -842,66 +596,14 @@ class Frame { * @return {!Promise} */ waitForFunction(pageFunction, options = {}, ...args) { - const { - polling = 'raf', - timeout = 30000 - } = options; - return new WaitTask(this, pageFunction, 'function', polling, timeout, ...args).promise; + return this._mainWorld.waitForFunction(pageFunction, options, ...args); } /** * @return {!Promise} */ async title() { - return this.evaluate(() => document.title); - } - - /** - * @param {string} selectorOrXPath - * @param {boolean} isXPath - * @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options - * @return {!Promise} - */ - _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 this._mainWorld.title(); } /** @@ -939,9 +641,8 @@ class Frame { } _detach() { - for (const waitTask of this._waitTasks) - waitTask.terminate(new Error('waitForFunction failed: frame got detached.')); this._detached = true; + this._mainWorld._detach(); if (this._parentFrame) this._parentFrame._childFrames.delete(this); this._parentFrame = null; @@ -949,194 +650,6 @@ class Frame { } helper.tracePublicAPI(Frame); -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 {?Puppeteer.JSHandle} */ - let success = null; - let error = null; - try { - success = await (await this._frame.executionContext()).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. - // We will try again in the new execution context. - if (error && error.message.includes('Execution context was destroyed')) - return; - - // We could have tried to evaluate in a context which was already - // destroyed. - if (error && error.message.includes('Cannot find context with specified id')) - 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 assertNoLegacyNavigationOptions(options) { assert(options['networkIdleTimeout'] === undefined, 'ERROR: networkIdleTimeout option is no longer supported.'); assert(options['networkIdleInflight'] === undefined, 'ERROR: networkIdleInflight option is no longer supported.'); diff --git a/utils/doclint/check_public_api/index.js b/utils/doclint/check_public_api/index.js index a80f6e9de31..0deb9019d23 100644 --- a/utils/doclint/check_public_api/index.js +++ b/utils/doclint/check_public_api/index.js @@ -24,6 +24,7 @@ const EXCLUDE_CLASSES = new Set([ 'CSSCoverage', 'Connection', 'CustomError', + 'DOMWorld', 'EmulationManager', 'FrameManager', 'JSCoverage',