lib/DOMWorld.js
/* eslint-env node, browser */
const fs = require('fs-extra')
const { helper, assert } = require('./helper')
const LifecycleWatcher = require('./LifecycleWatcher')
const WaitTask = require('./WaitTask')
/**
* @unrestricted
*/
class DOMWorld {
/**
* @param {!FrameManager} frameManager
* @param {!Frame} frame
* @param {!TimeoutSettings} timeoutSettings
*/
constructor (frameManager, frame, timeoutSettings) {
this._frameManager = frameManager
this._frame = frame
this._timeoutSettings = timeoutSettings
/** @type {?Promise<ElementHandle>} */
this._documentPromise = null
/** @type {?Promise<ExecutionContext>} */
this._contextPromise = null
this._contextResolveCallback = null
this._setContext(null)
/** @type {!Set<WaitTask>} */
this._waitTasks = new Set()
this._detached = false
}
/**
* @return {!Frame}
*/
frame () {
return this._frame
}
/**
* @return {?Promise<ExecutionContext>}
*/
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 {string} selector
* @return {Promise<ElementHandle>}
*/
querySelector (selector) {
return this.$(selector)
}
/**
* @param {string} selector
* @return {Promise<Array<ElementHandle>>}
*/
querySelectorAll (selector) {
return this.$$(selector)
}
/**
* @param {string} selector
* @param {Function|String} pageFunction
* @param {...*} args
* @return {Promise<Object>}
*/
querySelectorEval (selector, pageFunction, ...args) {
return this.$eval(selector, pageFunction, ...args)
}
/**
* @param {string} selector
* @param {Function|String} pageFunction
* @param {...*} args
* @return {Promise<Object>}
*/
querySelectorAllEval (selector, pageFunction, ...args) {
return this.$$eval(selector, pageFunction, ...args)
}
/**
* @param {string} expression
* @return {Promise<Array<ElementHandle>>}
*/
xpathQuery (expression) {
return this.$x(expression)
}
/**
* @return {Promise<String>}
*/
content () {
return this.evaluate(() => {
let retVal = ''
if (document.doctype) {
retVal = new XMLSerializer().serializeToString(document.doctype)
}
if (document.documentElement) {
return retVal + document.documentElement.outerHTML
}
return retVal
})
}
/**
* @param {string} selector
* @param {...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 (let i = 0; i < options.length; i++) {
const option = options[i]
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} selector
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options
* @return {Promise<ElementHandle>}
*/
waitForSelector (selector, options) {
return this._waitForSelectorOrXPath(selector, false, options)
}
/**
* @param {string} xpath
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options
* @return {Promise<ElementHandle>}
*/
waitForXPath (xpath, options) {
return this._waitForSelectorOrXPath(xpath, true, options)
}
/**
* @param {Function|string} pageFunction
* @param {!{polling?: string|number, timeout?: number}=} options
* @param {...*} args
* @return {Promise<JSHandle>}
*/
waitForFunction (pageFunction, options = {}, ...args) {
const {
polling = 'raf',
timeout = this._timeoutSettings.timeout()
} = options
return new WaitTask(
this,
pageFunction,
'function',
polling,
timeout,
...args
).promise
}
/**
* @param {string} elemId
* @return {Promise<ElementHandle|undefined>}
* @see https://developer.mozilla.org/en-US/docs/Web/API/Document/getElementById
* @since chrome-remote-interface-extra
*/
async getElementById (elemId) {
const document = await this._document()
return document.getElementById(elemId)
}
/**
* @param {Function|string} pageFunction
* @param {...*} args
* @return {Promise<JSHandle>}
*/
async evaluateHandle (pageFunction, ...args) {
const context = await this.executionContext()
return context.evaluateHandle(pageFunction, ...args)
}
/**
* @param {Function|string} pageFunction
* @param {...*} args
* @return {Promise<*>}
*/
async evaluate (pageFunction, ...args) {
const context = await this.executionContext()
return context.evaluate(pageFunction, ...args)
}
/**
* @param {string} selector
* @return {Promise<ElementHandle>}
*/
async $ (selector) {
const document = await this._document()
return document.$(selector)
}
/**
* @param {string} expression
* @return {Promise<Array<ElementHandle>>}
*/
async $x (expression) {
const document = await this._document()
return document.$x(expression)
}
/**
* @param {string} selector
* @param {Function|string} pageFunction
* @param {...*} args
* @return {Promise<Object>}
*/
async $eval (selector, pageFunction, ...args) {
const document = await this._document()
return document.$eval(selector, pageFunction, ...args)
}
/**
* @param {string} selector
* @param {Function|string} pageFunction
* @param {...*} args
* @return {Promise<Object>}
*/
async $$eval (selector, pageFunction, ...args) {
const document = await this._document()
return document.$$eval(selector, pageFunction, ...args)
}
/**
* @param {string} selector
* @return {Promise<Array<ElementHandle>>}
*/
async $$ (selector) {
const document = await this._document()
return document.$$(selector)
}
/**
* @param {string} html
* @param {!{timeout?: number, waitUntil?: string|Array<string>}=} options
*/
async setContent (html, options = {}) {
const {
waitUntil = ['load'],
timeout = this._timeoutSettings.navigationTimeout()
} = 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<ElementHandle>}
*/
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 fs.readFile(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<HTMLElement>}
*/
async function addScriptUrl (url, type) {
const script = document.createElement('script')
script.src = url
if (type) script.type = type
const promise = new Promise((resolve, reject) => {
script.onload = resolve
script.onerror = reject
})
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<ElementHandle>}
*/
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 fs.readFile(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<HTMLElement>}
*/
async function addStyleUrl (url) {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = url
const promise = new Promise((resolve, reject) => {
link.onload = resolve
link.onerror = reject
})
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((resolve, reject) => {
style.onload = resolve
style.onerror = reject
})
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
*/
async tap (selector) {
const handle = await this.$(selector)
assert(handle, 'No node found for selector: ' + selector)
await handle.tap()
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()
}
/**
* @return {Promise<string>}
*/
async title () {
return this.evaluate(() => document.title)
}
/**
* @param {?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(resolve => {
this._contextResolveCallback = resolve
})
}
}
_detach () {
this._detached = true
for (const waitTask of this._waitTasks) {
waitTask.terminate(
new Error('waitForFunction failed: frame got detached.')
)
}
}
/**
* @return {Promise<ElementHandle>}
*/
_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} selectorOrXPath
* @param {boolean} isXPath
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options
* @return {Promise<ElementHandle>}
*/
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
) {
let node
if (isXPath) {
node = document.evaluate(
selectorOrXPath,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
).singleNodeValue
} else {
node = 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)
}
}
}
}
/**
* @type {DOMWorld}
*/
module.exports = DOMWorld