puppeteer-core
Advanced tools
Comparing version 2.1.1 to 3.0.0
@@ -151,4 +151,4 @@ <!-- gen:toc --> | ||
Puppeteer tests are located in [`test/test.js`](https://github.com/puppeteer/puppeteer/blob/master/test/test.js) | ||
and are written with a [TestRunner](https://github.com/puppeteer/puppeteer/tree/master/utils/testrunner) framework. | ||
Puppeteer tests are located in the test directory ([`test`](https://github.com/puppeteer/puppeteer/blob/master/test/) and are written using Mocha. See [`test/README.md`](https://github.com/puppeteer/puppeteer/blob/master/test/) for more details. | ||
Despite being named 'unit', these are integration tests, making sure public API methods and events work as expected. | ||
@@ -263,5 +263,4 @@ | ||
- **WHY**: this is to avoid adding unnecessary files to the npm package. | ||
3. Run `npm install` to make sure the latest `lib/protocol.d.ts` is generated. | ||
4. Run [`npx pkgfiles`](https://www.npmjs.com/package/pkgfiles) to make sure you don't publish anything unnecessary. | ||
5. Run `npm publish`. This publishes the `puppeteer` package. | ||
3. Run [`npx pkgfiles`](https://www.npmjs.com/package/pkgfiles) to make sure you don't publish anything unnecessary. | ||
4. Run `npm publish`. This publishes the `puppeteer` package. | ||
3. Publish `puppeteer-core` to npm. | ||
@@ -268,0 +267,0 @@ 1. Run `./utils/prepare_puppeteer_core.js`. The script changes the name inside `package.json` to `puppeteer-core`. |
12
index.js
@@ -19,2 +19,3 @@ /** | ||
const api = require('./lib/api'); | ||
const {Page} = require('./lib/Page'); | ||
for (const className in api) { | ||
@@ -26,9 +27,16 @@ // Puppeteer-web excludes certain classes from bundle, e.g. BrowserFetcher. | ||
// Expose alias for deprecated method. | ||
Page.prototype.emulateMedia = Page.prototype.emulateMediaType; | ||
// If node does not support async await, use the compiled version. | ||
const Puppeteer = require('./lib/Puppeteer'); | ||
const packageJson = require('./package.json'); | ||
const preferredRevision = packageJson.puppeteer.chromium_revision; | ||
let preferredRevision = packageJson.puppeteer.chromium_revision; | ||
const isPuppeteerCore = packageJson.name === 'puppeteer-core'; | ||
// puppeteer-core ignores environment variables | ||
const product = isPuppeteerCore ? undefined : process.env.PUPPETEER_PRODUCT || process.env.npm_config_puppeteer_product || process.env.npm_package_config_puppeteer_product; | ||
if (!isPuppeteerCore && product === 'firefox') | ||
preferredRevision = packageJson.puppeteer.firefox_revision; | ||
const puppeteer = new Puppeteer(__dirname, preferredRevision, isPuppeteerCore); | ||
const puppeteer = new Puppeteer(__dirname, preferredRevision, isPuppeteerCore, product); | ||
@@ -35,0 +43,0 @@ // The introspection in `Helper.installAsyncStackHooks` references `Puppeteer._launcher` |
264
install.js
@@ -17,2 +17,41 @@ /** | ||
const fs = require('fs'); | ||
const path = require('path'); | ||
const child_process = require('child_process'); | ||
const {promisify} = require('util'); | ||
const fsAccess = promisify(fs.access); | ||
const exec = promisify(child_process.exec); | ||
const fileExists = async filePath => fsAccess(filePath).then(() => true).catch(() => false); | ||
/* | ||
* Now Puppeteer is built with TypeScript, we need to ensure that | ||
* locally we have the generated output before trying to install. | ||
* | ||
* For users installing puppeteer this is fine, they will have the | ||
* generated lib/ directory as we ship it when we publish to npm. | ||
* | ||
* However, if you're cloning the repo to contribute, you won't have the | ||
* generated lib/ directory so this script checks if we need to run | ||
* TypeScript first to ensure the output exists and is in the right | ||
* place. | ||
*/ | ||
async function compileTypeScript() { | ||
return exec('npm run tsc').catch(err => { | ||
console.error('Error running TypeScript', err); | ||
process.exit(1); | ||
}); | ||
} | ||
async function ensureLibDirectoryExists() { | ||
const libPath = path.join(__dirname, 'lib'); | ||
const libExists = await fileExists(libPath); | ||
if (libExists) return; | ||
logPolitely('Compiling TypeScript before install...'); | ||
await compileTypeScript(); | ||
} | ||
/** | ||
@@ -24,101 +63,131 @@ * This file is part of public API. | ||
* `puppeteer-core` package doesn't include this step at all. However, it's | ||
* still possible to install Chromium using this script when necessary. | ||
* still possible to install a supported browser using this script when | ||
* necessary. | ||
*/ | ||
if (process.env.PUPPETEER_SKIP_CHROMIUM_DOWNLOAD) { | ||
logPolitely('**INFO** Skipping Chromium download. "PUPPETEER_SKIP_CHROMIUM_DOWNLOAD" environment variable was found.'); | ||
return; | ||
} | ||
if (process.env.NPM_CONFIG_PUPPETEER_SKIP_CHROMIUM_DOWNLOAD || process.env.npm_config_puppeteer_skip_chromium_download) { | ||
logPolitely('**INFO** Skipping Chromium download. "PUPPETEER_SKIP_CHROMIUM_DOWNLOAD" was set in npm config.'); | ||
return; | ||
} | ||
if (process.env.NPM_PACKAGE_CONFIG_PUPPETEER_SKIP_CHROMIUM_DOWNLOAD || process.env.npm_package_config_puppeteer_skip_chromium_download) { | ||
logPolitely('**INFO** Skipping Chromium download. "PUPPETEER_SKIP_CHROMIUM_DOWNLOAD" was set in project config.'); | ||
return; | ||
} | ||
const supportedProducts = { | ||
'chrome': 'Chromium', | ||
'firefox': 'Firefox Nightly' | ||
}; | ||
const downloadHost = process.env.PUPPETEER_DOWNLOAD_HOST || process.env.npm_config_puppeteer_download_host || process.env.npm_package_config_puppeteer_download_host; | ||
async function download() { | ||
await ensureLibDirectoryExists(); | ||
const puppeteer = require('./index'); | ||
const browserFetcher = puppeteer.createBrowserFetcher({ host: downloadHost }); | ||
const downloadHost = process.env.PUPPETEER_DOWNLOAD_HOST || process.env.npm_config_puppeteer_download_host || process.env.npm_package_config_puppeteer_download_host; | ||
const puppeteer = require('./index'); | ||
const product = process.env.PUPPETEER_PRODUCT || process.env.npm_config_puppeteer_product || process.env.npm_package_config_puppeteer_product || 'chrome'; | ||
const browserFetcher = puppeteer.createBrowserFetcher({ product, host: downloadHost }); | ||
const revision = await getRevision(); | ||
await fetchBinary(revision); | ||
const revision = process.env.PUPPETEER_CHROMIUM_REVISION || process.env.npm_config_puppeteer_chromium_revision || process.env.npm_package_config_puppeteer_chromium_revision | ||
|| require('./package.json').puppeteer.chromium_revision; | ||
function getRevision() { | ||
if (product === 'chrome') { | ||
return process.env.PUPPETEER_CHROMIUM_REVISION || process.env.npm_config_puppeteer_chromium_revision || process.env.npm_package_config_puppeteer_chromium_revision | ||
|| require('./package.json').puppeteer.chromium_revision; | ||
} else if (product === 'firefox') { | ||
puppeteer._preferredRevision = require('./package.json').puppeteer.firefox_revision; | ||
return getFirefoxNightlyVersion(browserFetcher.host()).catch(error => { console.error(error); process.exit(1); }); | ||
} else { | ||
throw new Error(`Unsupported product ${product}`); | ||
} | ||
} | ||
const revisionInfo = browserFetcher.revisionInfo(revision); | ||
function fetchBinary(revision) { | ||
const revisionInfo = browserFetcher.revisionInfo(revision); | ||
// Do nothing if the revision is already downloaded. | ||
if (revisionInfo.local) { | ||
generateProtocolTypesIfNecessary(false /* updated */); | ||
return; | ||
} | ||
// Do nothing if the revision is already downloaded. | ||
if (revisionInfo.local) { | ||
logPolitely(`${supportedProducts[product]} is already in ${revisionInfo.folderPath}; skipping download.`); | ||
return; | ||
} | ||
// Override current environment proxy settings with npm configuration, if any. | ||
const NPM_HTTPS_PROXY = process.env.npm_config_https_proxy || process.env.npm_config_proxy; | ||
const NPM_HTTP_PROXY = process.env.npm_config_http_proxy || process.env.npm_config_proxy; | ||
const NPM_NO_PROXY = process.env.npm_config_no_proxy; | ||
// Override current environment proxy settings with npm configuration, if any. | ||
const NPM_HTTPS_PROXY = process.env.npm_config_https_proxy || process.env.npm_config_proxy; | ||
const NPM_HTTP_PROXY = process.env.npm_config_http_proxy || process.env.npm_config_proxy; | ||
const NPM_NO_PROXY = process.env.npm_config_no_proxy; | ||
if (NPM_HTTPS_PROXY) | ||
process.env.HTTPS_PROXY = NPM_HTTPS_PROXY; | ||
if (NPM_HTTP_PROXY) | ||
process.env.HTTP_PROXY = NPM_HTTP_PROXY; | ||
if (NPM_NO_PROXY) | ||
process.env.NO_PROXY = NPM_NO_PROXY; | ||
if (NPM_HTTPS_PROXY) | ||
process.env.HTTPS_PROXY = NPM_HTTPS_PROXY; | ||
if (NPM_HTTP_PROXY) | ||
process.env.HTTP_PROXY = NPM_HTTP_PROXY; | ||
if (NPM_NO_PROXY) | ||
process.env.NO_PROXY = NPM_NO_PROXY; | ||
browserFetcher.download(revisionInfo.revision, onProgress) | ||
.then(() => browserFetcher.localRevisions()) | ||
.then(onSuccess) | ||
.catch(onError); | ||
/** | ||
* @param {!Array<string>} | ||
* @return {!Promise} | ||
*/ | ||
function onSuccess(localRevisions) { | ||
logPolitely(`${supportedProducts[product]} (${revisionInfo.revision}) downloaded to ${revisionInfo.folderPath}`); | ||
localRevisions = localRevisions.filter(revision => revision !== revisionInfo.revision); | ||
const cleanupOldVersions = localRevisions.map(revision => browserFetcher.remove(revision)); | ||
Promise.all([...cleanupOldVersions]); | ||
} | ||
/** | ||
* @param {!Array<string>} | ||
* @return {!Promise} | ||
*/ | ||
function onSuccess(localRevisions) { | ||
logPolitely('Chromium downloaded to ' + revisionInfo.folderPath); | ||
localRevisions = localRevisions.filter(revision => revision !== revisionInfo.revision); | ||
// Remove previous chromium revisions. | ||
const cleanupOldVersions = localRevisions.map(revision => browserFetcher.remove(revision)); | ||
return Promise.all([...cleanupOldVersions, generateProtocolTypesIfNecessary(true /* updated */)]); | ||
} | ||
/** | ||
* @param {!Error} error | ||
*/ | ||
function onError(error) { | ||
console.error(`ERROR: Failed to set up ${supportedProducts[product]} r${revision}! Set "PUPPETEER_SKIP_DOWNLOAD" env variable to skip download.`); | ||
console.error(error); | ||
process.exit(1); | ||
} | ||
/** | ||
* @param {!Error} error | ||
*/ | ||
function onError(error) { | ||
console.error(`ERROR: Failed to download Chromium r${revision}! Set "PUPPETEER_SKIP_CHROMIUM_DOWNLOAD" env variable to skip download.`); | ||
console.error(error); | ||
process.exit(1); | ||
} | ||
let progressBar = null; | ||
let lastDownloadedBytes = 0; | ||
function onProgress(downloadedBytes, totalBytes) { | ||
if (!progressBar) { | ||
const ProgressBar = require('progress'); | ||
progressBar = new ProgressBar(`Downloading ${supportedProducts[product]} r${revision} - ${toMegabytes(totalBytes)} [:bar] :percent :etas `, { | ||
complete: '=', | ||
incomplete: ' ', | ||
width: 20, | ||
total: totalBytes, | ||
}); | ||
} | ||
const delta = downloadedBytes - lastDownloadedBytes; | ||
lastDownloadedBytes = downloadedBytes; | ||
progressBar.tick(delta); | ||
} | ||
let progressBar = null; | ||
let lastDownloadedBytes = 0; | ||
function onProgress(downloadedBytes, totalBytes) { | ||
if (!progressBar) { | ||
const ProgressBar = require('progress'); | ||
progressBar = new ProgressBar(`Downloading Chromium r${revision} - ${toMegabytes(totalBytes)} [:bar] :percent :etas `, { | ||
complete: '=', | ||
incomplete: ' ', | ||
width: 20, | ||
total: totalBytes, | ||
}); | ||
return browserFetcher.download(revisionInfo.revision, onProgress) | ||
.then(() => browserFetcher.localRevisions()) | ||
.then(onSuccess) | ||
.catch(onError); | ||
} | ||
const delta = downloadedBytes - lastDownloadedBytes; | ||
lastDownloadedBytes = downloadedBytes; | ||
progressBar.tick(delta); | ||
} | ||
function toMegabytes(bytes) { | ||
const mb = bytes / 1024 / 1024; | ||
return `${Math.round(mb * 10) / 10} Mb`; | ||
} | ||
function toMegabytes(bytes) { | ||
const mb = bytes / 1024 / 1024; | ||
return `${Math.round(mb * 10) / 10} Mb`; | ||
} | ||
function generateProtocolTypesIfNecessary(updated) { | ||
const fs = require('fs'); | ||
const path = require('path'); | ||
if (!fs.existsSync(path.join(__dirname, 'utils', 'protocol-types-generator'))) | ||
return; | ||
if (!updated && fs.existsSync(path.join(__dirname, 'lib', 'protocol.d.ts'))) | ||
return; | ||
return require('./utils/protocol-types-generator'); | ||
function getFirefoxNightlyVersion(host) { | ||
const https = require('https'); | ||
const promise = new Promise((resolve, reject) => { | ||
let data = ''; | ||
logPolitely(`Requesting latest Firefox Nightly version from ${host}`); | ||
https.get(host + '/', r => { | ||
if (r.statusCode >= 400) | ||
return reject(new Error(`Got status code ${r.statusCode}`)); | ||
r.on('data', chunk => { | ||
data += chunk; | ||
}); | ||
r.on('end', parseVersion); | ||
}).on('error', reject); | ||
function parseVersion() { | ||
const regex = /firefox\-(?<version>\d\d)\..*/gm; | ||
let result = 0; | ||
let match; | ||
while ((match = regex.exec(data)) !== null) { | ||
const version = parseInt(match.groups.version, 10); | ||
if (version > result) | ||
result = version; | ||
} | ||
if (result) | ||
resolve(result.toString()); | ||
else reject(new Error('Firefox version not found')); | ||
} | ||
}); | ||
return promise; | ||
} | ||
} | ||
@@ -134,1 +203,28 @@ | ||
if (process.env.PUPPETEER_SKIP_DOWNLOAD) { | ||
logPolitely('**INFO** Skipping browser download. "PUPPETEER_SKIP_DOWNLOAD" environment variable was found.'); | ||
return; | ||
} | ||
if (process.env.NPM_CONFIG_PUPPETEER_SKIP_DOWNLOAD || process.env.npm_config_puppeteer_skip_download) { | ||
logPolitely('**INFO** Skipping browser download. "PUPPETEER_SKIP_DOWNLOAD" was set in npm config.'); | ||
return; | ||
} | ||
if (process.env.NPM_PACKAGE_CONFIG_PUPPETEER_SKIP_DOWNLOAD || process.env.npm_package_config_puppeteer_skip_download) { | ||
logPolitely('**INFO** Skipping browser download. "PUPPETEER_SKIP_DOWNLOAD" was set in project config.'); | ||
return; | ||
} | ||
if (process.env.PUPPETEER_SKIP_CHROMIUM_DOWNLOAD) { | ||
logPolitely('**INFO** Skipping browser download. "PUPPETEER_SKIP_CHROMIUM_DOWNLOAD" environment variable was found.'); | ||
return; | ||
} | ||
if (process.env.NPM_CONFIG_PUPPETEER_SKIP_CHROMIUM_DOWNLOAD || process.env.npm_config_puppeteer_skip_chromium_download) { | ||
logPolitely('**INFO** Skipping browser download. "PUPPETEER_SKIP_CHROMIUM_DOWNLOAD" was set in npm config.'); | ||
return; | ||
} | ||
if (process.env.NPM_PACKAGE_CONFIG_PUPPETEER_SKIP_CHROMIUM_DOWNLOAD || process.env.npm_package_config_puppeteer_skip_chromium_download) { | ||
logPolitely('**INFO** Skipping browser download. "PUPPETEER_SKIP_CHROMIUM_DOWNLOAD" was set in project config.'); | ||
return; | ||
} | ||
download(); | ||
@@ -16,3 +16,2 @@ /** | ||
*/ | ||
/** | ||
@@ -54,45 +53,38 @@ * @typedef {Object} SerializedAXNode | ||
*/ | ||
class Accessibility { | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
*/ | ||
constructor(client) { | ||
this._client = client; | ||
} | ||
/** | ||
* @param {{interestingOnly?: boolean, root?: ?Puppeteer.ElementHandle}=} options | ||
* @return {!Promise<!SerializedAXNode>} | ||
*/ | ||
async snapshot(options = {}) { | ||
const { | ||
interestingOnly = true, | ||
root = null, | ||
} = options; | ||
const {nodes} = await this._client.send('Accessibility.getFullAXTree'); | ||
let backendNodeId = null; | ||
if (root) { | ||
const {node} = await this._client.send('DOM.describeNode', {objectId: root._remoteObject.objectId}); | ||
backendNodeId = node.backendNodeId; | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
*/ | ||
constructor(client) { | ||
this._client = client; | ||
} | ||
const defaultRoot = AXNode.createTree(nodes); | ||
let needle = defaultRoot; | ||
if (backendNodeId) { | ||
needle = defaultRoot.find(node => node._payload.backendDOMNodeId === backendNodeId); | ||
if (!needle) | ||
return null; | ||
/** | ||
* @param {{interestingOnly?: boolean, root?: ?Puppeteer.ElementHandle}=} options | ||
* @return {!Promise<!SerializedAXNode>} | ||
*/ | ||
async snapshot(options = {}) { | ||
const { interestingOnly = true, root = null, } = options; | ||
const { nodes } = await this._client.send('Accessibility.getFullAXTree'); | ||
let backendNodeId = null; | ||
if (root) { | ||
const { node } = await this._client.send('DOM.describeNode', { objectId: root._remoteObject.objectId }); | ||
backendNodeId = node.backendNodeId; | ||
} | ||
const defaultRoot = AXNode.createTree(nodes); | ||
let needle = defaultRoot; | ||
if (backendNodeId) { | ||
needle = defaultRoot.find(node => node._payload.backendDOMNodeId === backendNodeId); | ||
if (!needle) | ||
return null; | ||
} | ||
if (!interestingOnly) | ||
return serializeTree(needle)[0]; | ||
/** @type {!Set<!AXNode>} */ | ||
const interestingNodes = new Set(); | ||
collectInterestingNodes(interestingNodes, defaultRoot, false); | ||
if (!interestingNodes.has(needle)) | ||
return null; | ||
return serializeTree(needle, interestingNodes)[0]; | ||
} | ||
if (!interestingOnly) | ||
return serializeTree(needle)[0]; | ||
/** @type {!Set<!AXNode>} */ | ||
const interestingNodes = new Set(); | ||
collectInterestingNodes(interestingNodes, defaultRoot, false); | ||
if (!interestingNodes.has(needle)) | ||
return null; | ||
return serializeTree(needle, interestingNodes)[0]; | ||
} | ||
} | ||
/** | ||
@@ -104,11 +96,10 @@ * @param {!Set<!AXNode>} collection | ||
function collectInterestingNodes(collection, node, insideControl) { | ||
if (node.isInteresting(insideControl)) | ||
collection.add(node); | ||
if (node.isLeafNode()) | ||
return; | ||
insideControl = insideControl || node.isControl(); | ||
for (const child of node._children) | ||
collectInterestingNodes(collection, child, insideControl); | ||
if (node.isInteresting(insideControl)) | ||
collection.add(node); | ||
if (node.isLeafNode()) | ||
return; | ||
insideControl = insideControl || node.isControl(); | ||
for (const child of node._children) | ||
collectInterestingNodes(collection, child, insideControl); | ||
} | ||
/** | ||
@@ -120,310 +111,308 @@ * @param {!AXNode} node | ||
function serializeTree(node, whitelistedNodes) { | ||
/** @type {!Array<!SerializedAXNode>} */ | ||
const children = []; | ||
for (const child of node._children) | ||
children.push(...serializeTree(child, whitelistedNodes)); | ||
if (whitelistedNodes && !whitelistedNodes.has(node)) | ||
return children; | ||
const serializedNode = node.serialize(); | ||
if (children.length) | ||
serializedNode.children = children; | ||
return [serializedNode]; | ||
/** @type {!Array<!SerializedAXNode>} */ | ||
const children = []; | ||
for (const child of node._children) | ||
children.push(...serializeTree(child, whitelistedNodes)); | ||
if (whitelistedNodes && !whitelistedNodes.has(node)) | ||
return children; | ||
const serializedNode = node.serialize(); | ||
if (children.length) | ||
serializedNode.children = children; | ||
return [serializedNode]; | ||
} | ||
class AXNode { | ||
/** | ||
* @param {!Protocol.Accessibility.AXNode} payload | ||
*/ | ||
constructor(payload) { | ||
this._payload = payload; | ||
/** @type {!Array<!AXNode>} */ | ||
this._children = []; | ||
this._richlyEditable = false; | ||
this._editable = false; | ||
this._focusable = false; | ||
this._expanded = false; | ||
this._hidden = false; | ||
this._name = this._payload.name ? this._payload.name.value : ''; | ||
this._role = this._payload.role ? this._payload.role.value : 'Unknown'; | ||
this._cachedHasFocusableChild; | ||
for (const property of this._payload.properties || []) { | ||
if (property.name === 'editable') { | ||
this._richlyEditable = property.value.value === 'richtext'; | ||
this._editable = true; | ||
} | ||
if (property.name === 'focusable') | ||
this._focusable = property.value.value; | ||
if (property.name === 'expanded') | ||
this._expanded = property.value.value; | ||
if (property.name === 'hidden') | ||
this._hidden = property.value.value; | ||
/** | ||
* @param {!Protocol.Accessibility.AXNode} payload | ||
*/ | ||
constructor(payload) { | ||
this._payload = payload; | ||
/** @type {!Array<!AXNode>} */ | ||
this._children = []; | ||
this._richlyEditable = false; | ||
this._editable = false; | ||
this._focusable = false; | ||
this._expanded = false; | ||
this._hidden = false; | ||
this._name = this._payload.name ? this._payload.name.value : ''; | ||
this._role = this._payload.role ? this._payload.role.value : 'Unknown'; | ||
this._cachedHasFocusableChild; | ||
for (const property of this._payload.properties || []) { | ||
if (property.name === 'editable') { | ||
this._richlyEditable = property.value.value === 'richtext'; | ||
this._editable = true; | ||
} | ||
if (property.name === 'focusable') | ||
this._focusable = property.value.value; | ||
if (property.name === 'expanded') | ||
this._expanded = property.value.value; | ||
if (property.name === 'hidden') | ||
this._hidden = property.value.value; | ||
} | ||
} | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
_isPlainTextField() { | ||
if (this._richlyEditable) | ||
return false; | ||
if (this._editable) | ||
return true; | ||
return this._role === 'textbox' || this._role === 'ComboBox' || this._role === 'searchbox'; | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
_isTextOnlyObject() { | ||
const role = this._role; | ||
return (role === 'LineBreak' || role === 'text' || | ||
/** | ||
* @return {boolean} | ||
*/ | ||
_isPlainTextField() { | ||
if (this._richlyEditable) | ||
return false; | ||
if (this._editable) | ||
return true; | ||
return this._role === 'textbox' || this._role === 'ComboBox' || this._role === 'searchbox'; | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
_isTextOnlyObject() { | ||
const role = this._role; | ||
return (role === 'LineBreak' || role === 'text' || | ||
role === 'InlineTextBox'); | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
_hasFocusableChild() { | ||
if (this._cachedHasFocusableChild === undefined) { | ||
this._cachedHasFocusableChild = false; | ||
for (const child of this._children) { | ||
if (child._focusable || child._hasFocusableChild()) { | ||
this._cachedHasFocusableChild = true; | ||
break; | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
_hasFocusableChild() { | ||
if (this._cachedHasFocusableChild === undefined) { | ||
this._cachedHasFocusableChild = false; | ||
for (const child of this._children) { | ||
if (child._focusable || child._hasFocusableChild()) { | ||
this._cachedHasFocusableChild = true; | ||
break; | ||
} | ||
} | ||
} | ||
} | ||
return this._cachedHasFocusableChild; | ||
} | ||
return this._cachedHasFocusableChild; | ||
} | ||
/** | ||
* @param {function(AXNode):boolean} predicate | ||
* @return {?AXNode} | ||
*/ | ||
find(predicate) { | ||
if (predicate(this)) | ||
return this; | ||
for (const child of this._children) { | ||
const result = child.find(predicate); | ||
if (result) | ||
return result; | ||
/** | ||
* @param {function(AXNode):boolean} predicate | ||
* @return {?AXNode} | ||
*/ | ||
find(predicate) { | ||
if (predicate(this)) | ||
return this; | ||
for (const child of this._children) { | ||
const result = child.find(predicate); | ||
if (result) | ||
return result; | ||
} | ||
return null; | ||
} | ||
return null; | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
isLeafNode() { | ||
if (!this._children.length) | ||
return true; | ||
// These types of objects may have children that we use as internal | ||
// implementation details, but we want to expose them as leaves to platform | ||
// accessibility APIs because screen readers might be confused if they find | ||
// any children. | ||
if (this._isPlainTextField() || this._isTextOnlyObject()) | ||
return true; | ||
// Roles whose children are only presentational according to the ARIA and | ||
// HTML5 Specs should be hidden from screen readers. | ||
// (Note that whilst ARIA buttons can have only presentational children, HTML5 | ||
// buttons are allowed to have content.) | ||
switch (this._role) { | ||
case 'doc-cover': | ||
case 'graphics-symbol': | ||
case 'img': | ||
case 'Meter': | ||
case 'scrollbar': | ||
case 'slider': | ||
case 'separator': | ||
case 'progressbar': | ||
return true; | ||
default: | ||
break; | ||
} | ||
// Here and below: Android heuristics | ||
if (this._hasFocusableChild()) | ||
return false; | ||
if (this._focusable && this._name) | ||
return true; | ||
if (this._role === 'heading' && this._name) | ||
return true; | ||
return false; | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
isControl() { | ||
switch (this._role) { | ||
case 'button': | ||
case 'checkbox': | ||
case 'ColorWell': | ||
case 'combobox': | ||
case 'DisclosureTriangle': | ||
case 'listbox': | ||
case 'menu': | ||
case 'menubar': | ||
case 'menuitem': | ||
case 'menuitemcheckbox': | ||
case 'menuitemradio': | ||
case 'radio': | ||
case 'scrollbar': | ||
case 'searchbox': | ||
case 'slider': | ||
case 'spinbutton': | ||
case 'switch': | ||
case 'tab': | ||
case 'textbox': | ||
case 'tree': | ||
return true; | ||
default: | ||
/** | ||
* @return {boolean} | ||
*/ | ||
isLeafNode() { | ||
if (!this._children.length) | ||
return true; | ||
// These types of objects may have children that we use as internal | ||
// implementation details, but we want to expose them as leaves to platform | ||
// accessibility APIs because screen readers might be confused if they find | ||
// any children. | ||
if (this._isPlainTextField() || this._isTextOnlyObject()) | ||
return true; | ||
// Roles whose children are only presentational according to the ARIA and | ||
// HTML5 Specs should be hidden from screen readers. | ||
// (Note that whilst ARIA buttons can have only presentational children, HTML5 | ||
// buttons are allowed to have content.) | ||
switch (this._role) { | ||
case 'doc-cover': | ||
case 'graphics-symbol': | ||
case 'img': | ||
case 'Meter': | ||
case 'scrollbar': | ||
case 'slider': | ||
case 'separator': | ||
case 'progressbar': | ||
return true; | ||
default: | ||
break; | ||
} | ||
// Here and below: Android heuristics | ||
if (this._hasFocusableChild()) | ||
return false; | ||
if (this._focusable && this._name) | ||
return true; | ||
if (this._role === 'heading' && this._name) | ||
return true; | ||
return false; | ||
} | ||
} | ||
/** | ||
* @param {boolean} insideControl | ||
* @return {boolean} | ||
*/ | ||
isInteresting(insideControl) { | ||
const role = this._role; | ||
if (role === 'Ignored' || this._hidden) | ||
return false; | ||
if (this._focusable || this._richlyEditable) | ||
return true; | ||
// If it's not focusable but has a control role, then it's interesting. | ||
if (this.isControl()) | ||
return true; | ||
// A non focusable child of a control is not interesting | ||
if (insideControl) | ||
return false; | ||
return this.isLeafNode() && !!this._name; | ||
} | ||
/** | ||
* @return {!SerializedAXNode} | ||
*/ | ||
serialize() { | ||
/** @type {!Map<string, number|string|boolean>} */ | ||
const properties = new Map(); | ||
for (const property of this._payload.properties || []) | ||
properties.set(property.name.toLowerCase(), property.value.value); | ||
if (this._payload.name) | ||
properties.set('name', this._payload.name.value); | ||
if (this._payload.value) | ||
properties.set('value', this._payload.value.value); | ||
if (this._payload.description) | ||
properties.set('description', this._payload.description.value); | ||
/** @type {SerializedAXNode} */ | ||
const node = { | ||
role: this._role | ||
}; | ||
/** @type {!Array<keyof SerializedAXNode>} */ | ||
const userStringProperties = [ | ||
'name', | ||
'value', | ||
'description', | ||
'keyshortcuts', | ||
'roledescription', | ||
'valuetext', | ||
]; | ||
for (const userStringProperty of userStringProperties) { | ||
if (!properties.has(userStringProperty)) | ||
continue; | ||
node[userStringProperty] = properties.get(userStringProperty); | ||
/** | ||
* @return {boolean} | ||
*/ | ||
isControl() { | ||
switch (this._role) { | ||
case 'button': | ||
case 'checkbox': | ||
case 'ColorWell': | ||
case 'combobox': | ||
case 'DisclosureTriangle': | ||
case 'listbox': | ||
case 'menu': | ||
case 'menubar': | ||
case 'menuitem': | ||
case 'menuitemcheckbox': | ||
case 'menuitemradio': | ||
case 'radio': | ||
case 'scrollbar': | ||
case 'searchbox': | ||
case 'slider': | ||
case 'spinbutton': | ||
case 'switch': | ||
case 'tab': | ||
case 'textbox': | ||
case 'tree': | ||
return true; | ||
default: | ||
return false; | ||
} | ||
} | ||
/** @type {!Array<keyof SerializedAXNode>} */ | ||
const booleanProperties = [ | ||
'disabled', | ||
'expanded', | ||
'focused', | ||
'modal', | ||
'multiline', | ||
'multiselectable', | ||
'readonly', | ||
'required', | ||
'selected', | ||
]; | ||
for (const booleanProperty of booleanProperties) { | ||
// WebArea's treat focus differently than other nodes. They report whether their frame has focus, | ||
// not whether focus is specifically on the root node. | ||
if (booleanProperty === 'focused' && this._role === 'WebArea') | ||
continue; | ||
const value = properties.get(booleanProperty); | ||
if (!value) | ||
continue; | ||
node[booleanProperty] = value; | ||
/** | ||
* @param {boolean} insideControl | ||
* @return {boolean} | ||
*/ | ||
isInteresting(insideControl) { | ||
const role = this._role; | ||
if (role === 'Ignored' || this._hidden) | ||
return false; | ||
if (this._focusable || this._richlyEditable) | ||
return true; | ||
// If it's not focusable but has a control role, then it's interesting. | ||
if (this.isControl()) | ||
return true; | ||
// A non focusable child of a control is not interesting | ||
if (insideControl) | ||
return false; | ||
return this.isLeafNode() && !!this._name; | ||
} | ||
/** @type {!Array<keyof SerializedAXNode>} */ | ||
const tristateProperties = [ | ||
'checked', | ||
'pressed', | ||
]; | ||
for (const tristateProperty of tristateProperties) { | ||
if (!properties.has(tristateProperty)) | ||
continue; | ||
const value = properties.get(tristateProperty); | ||
node[tristateProperty] = value === 'mixed' ? 'mixed' : value === 'true' ? true : false; | ||
/** | ||
* @return {!SerializedAXNode} | ||
*/ | ||
serialize() { | ||
/** @type {!Map<string, number|string|boolean>} */ | ||
const properties = new Map(); | ||
for (const property of this._payload.properties || []) | ||
properties.set(property.name.toLowerCase(), property.value.value); | ||
if (this._payload.name) | ||
properties.set('name', this._payload.name.value); | ||
if (this._payload.value) | ||
properties.set('value', this._payload.value.value); | ||
if (this._payload.description) | ||
properties.set('description', this._payload.description.value); | ||
/** @type {SerializedAXNode} */ | ||
const node = { | ||
role: this._role | ||
}; | ||
/** @enum {'name'|'value'|'description'|'keyshortcuts'|'roledescription'|'valuetext'} */ | ||
let UserStringProperties; // eslint-disable-line no-unused-vars | ||
/** @type {!Array<UserStringProperties>} */ | ||
const userStringProperties = [ | ||
'name', | ||
'value', | ||
'description', | ||
'keyshortcuts', | ||
'roledescription', | ||
'valuetext', | ||
]; | ||
/** | ||
* @param {UserStringProperties} key | ||
*/ | ||
const getUserStringPropertyValue = key => /** @type string */ (properties.get(key)); | ||
for (const userStringProperty of userStringProperties) { | ||
if (!properties.has(userStringProperty)) | ||
continue; | ||
node[userStringProperty] = getUserStringPropertyValue(userStringProperty); | ||
} | ||
/** @enum {'disabled'|'expanded'|'focused'|'modal'|'multiline'|'multiselectable'|'readonly'|'required'|'selected'} */ | ||
let BooleanProperties; // eslint-disable-line no-unused-vars | ||
/** @type {!Array<BooleanProperties>} */ | ||
const booleanProperties = [ | ||
'disabled', | ||
'expanded', | ||
'focused', | ||
'modal', | ||
'multiline', | ||
'multiselectable', | ||
'readonly', | ||
'required', | ||
'selected', | ||
]; | ||
/** | ||
* @param {BooleanProperties} key | ||
*/ | ||
const getBooleanPropertyValue = key => /** @type boolean */ (properties.get(key)); | ||
for (const booleanProperty of booleanProperties) { | ||
// WebArea's treat focus differently than other nodes. They report whether their frame has focus, | ||
// not whether focus is specifically on the root node. | ||
if (booleanProperty === 'focused' && this._role === 'WebArea') | ||
continue; | ||
const value = getBooleanPropertyValue(booleanProperty); | ||
if (!value) | ||
continue; | ||
node[booleanProperty] = getBooleanPropertyValue(booleanProperty); | ||
} | ||
/** @enum {'checked'|'pressed'} */ | ||
let TristateProperties; // eslint-disable-line no-unused-vars | ||
/** @type {!Array<TristateProperties>} */ | ||
const tristateProperties = [ | ||
'checked', | ||
'pressed', | ||
]; | ||
for (const tristateProperty of tristateProperties) { | ||
if (!properties.has(tristateProperty)) | ||
continue; | ||
const value = properties.get(tristateProperty); | ||
node[tristateProperty] = value === 'mixed' ? 'mixed' : value === 'true' ? true : false; | ||
} | ||
/** @enum {'level'|'valuemax'|'valuemin'} */ | ||
let NumericalProperties; // eslint-disable-line no-unused-vars | ||
/** @type {!Array<NumericalProperties>} */ | ||
const numericalProperties = [ | ||
'level', | ||
'valuemax', | ||
'valuemin', | ||
]; | ||
/** | ||
* @param {NumericalProperties} key | ||
*/ | ||
const getNumericalPropertyValue = key => /** @type number */ (properties.get(key)); | ||
for (const numericalProperty of numericalProperties) { | ||
if (!properties.has(numericalProperty)) | ||
continue; | ||
node[numericalProperty] = getNumericalPropertyValue(numericalProperty); | ||
} | ||
/** @enum {'autocomplete'|'haspopup'|'invalid'|'orientation'} */ | ||
let TokenProperties; // eslint-disable-line no-unused-vars | ||
/** @type {!Array<TokenProperties>} */ | ||
const tokenProperties = [ | ||
'autocomplete', | ||
'haspopup', | ||
'invalid', | ||
'orientation', | ||
]; | ||
/** | ||
* @param {TokenProperties} key | ||
*/ | ||
const getTokenPropertyValue = key => /** @type string */ (properties.get(key)); | ||
for (const tokenProperty of tokenProperties) { | ||
const value = getTokenPropertyValue(tokenProperty); | ||
if (!value || value === 'false') | ||
continue; | ||
node[tokenProperty] = getTokenPropertyValue(tokenProperty); | ||
} | ||
return node; | ||
} | ||
/** @type {!Array<keyof SerializedAXNode>} */ | ||
const numericalProperties = [ | ||
'level', | ||
'valuemax', | ||
'valuemin', | ||
]; | ||
for (const numericalProperty of numericalProperties) { | ||
if (!properties.has(numericalProperty)) | ||
continue; | ||
node[numericalProperty] = properties.get(numericalProperty); | ||
/** | ||
* @param {!Array<!Protocol.Accessibility.AXNode>} payloads | ||
* @return {!AXNode} | ||
*/ | ||
static createTree(payloads) { | ||
/** @type {!Map<string, !AXNode>} */ | ||
const nodeById = new Map(); | ||
for (const payload of payloads) | ||
nodeById.set(payload.nodeId, new AXNode(payload)); | ||
for (const node of nodeById.values()) { | ||
for (const childId of node._payload.childIds || []) | ||
node._children.push(nodeById.get(childId)); | ||
} | ||
return nodeById.values().next().value; | ||
} | ||
/** @type {!Array<keyof SerializedAXNode>} */ | ||
const tokenProperties = [ | ||
'autocomplete', | ||
'haspopup', | ||
'invalid', | ||
'orientation', | ||
]; | ||
for (const tokenProperty of tokenProperties) { | ||
const value = properties.get(tokenProperty); | ||
if (!value || value === 'false') | ||
continue; | ||
node[tokenProperty] = value; | ||
} | ||
return node; | ||
} | ||
/** | ||
* @param {!Array<!Protocol.Accessibility.AXNode>} payloads | ||
* @return {!AXNode} | ||
*/ | ||
static createTree(payloads) { | ||
/** @type {!Map<string, !AXNode>} */ | ||
const nodeById = new Map(); | ||
for (const payload of payloads) | ||
nodeById.set(payload.nodeId, new AXNode(payload)); | ||
for (const node of nodeById.values()) { | ||
for (const childId of node._payload.childIds || []) | ||
node._children.push(nodeById.get(childId)); | ||
} | ||
return nodeById.values().next().value; | ||
} | ||
} | ||
module.exports = {Accessibility}; | ||
module.exports = { Accessibility }; |
@@ -16,29 +16,28 @@ /** | ||
*/ | ||
module.exports = { | ||
Accessibility: require('./Accessibility').Accessibility, | ||
Browser: require('./Browser').Browser, | ||
BrowserContext: require('./Browser').BrowserContext, | ||
BrowserFetcher: require('./BrowserFetcher'), | ||
CDPSession: require('./Connection').CDPSession, | ||
ConsoleMessage: require('./Page').ConsoleMessage, | ||
Coverage: require('./Coverage').Coverage, | ||
Dialog: require('./Dialog').Dialog, | ||
ElementHandle: require('./JSHandle').ElementHandle, | ||
ExecutionContext: require('./ExecutionContext').ExecutionContext, | ||
FileChooser: require('./Page').FileChooser, | ||
Frame: require('./FrameManager').Frame, | ||
JSHandle: require('./JSHandle').JSHandle, | ||
Keyboard: require('./Input').Keyboard, | ||
Mouse: require('./Input').Mouse, | ||
Page: require('./Page').Page, | ||
Puppeteer: require('./Puppeteer'), | ||
Request: require('./NetworkManager').Request, | ||
Response: require('./NetworkManager').Response, | ||
SecurityDetails: require('./NetworkManager').SecurityDetails, | ||
Target: require('./Target').Target, | ||
TimeoutError: require('./Errors').TimeoutError, | ||
Touchscreen: require('./Input').Touchscreen, | ||
Tracing: require('./Tracing'), | ||
Worker: require('./Worker').Worker, | ||
Accessibility: require('./Accessibility').Accessibility, | ||
Browser: require('./Browser').Browser, | ||
BrowserContext: require('./Browser').BrowserContext, | ||
BrowserFetcher: require('./BrowserFetcher'), | ||
CDPSession: require('./Connection').CDPSession, | ||
ConsoleMessage: require('./Page').ConsoleMessage, | ||
Coverage: require('./Coverage').Coverage, | ||
Dialog: require('./Dialog').Dialog, | ||
ElementHandle: require('./JSHandle').ElementHandle, | ||
ExecutionContext: require('./ExecutionContext').ExecutionContext, | ||
FileChooser: require('./Page').FileChooser, | ||
Frame: require('./FrameManager').Frame, | ||
JSHandle: require('./JSHandle').JSHandle, | ||
Keyboard: require('./Input').Keyboard, | ||
Mouse: require('./Input').Mouse, | ||
Page: require('./Page').Page, | ||
Puppeteer: require('./Puppeteer'), | ||
Request: require('./NetworkManager').Request, | ||
Response: require('./NetworkManager').Response, | ||
SecurityDetails: require('./NetworkManager').SecurityDetails, | ||
Target: require('./Target').Target, | ||
TimeoutError: require('./Errors').TimeoutError, | ||
Touchscreen: require('./Input').Touchscreen, | ||
Tracing: require('./Tracing'), | ||
Worker: require('./Worker').Worker, | ||
}; |
@@ -16,369 +16,326 @@ /** | ||
*/ | ||
const { helper, assert } = require('./helper'); | ||
const {Target} = require('./Target'); | ||
const { Target } = require('./Target'); | ||
const EventEmitter = require('events'); | ||
const {TaskQueue} = require('./TaskQueue'); | ||
const {Events} = require('./Events'); | ||
const { TaskQueue } = require('./TaskQueue'); | ||
const { Events } = require('./Events'); | ||
class Browser extends EventEmitter { | ||
/** | ||
* @param {!Puppeteer.Connection} connection | ||
* @param {!Array<string>} contextIds | ||
* @param {boolean} ignoreHTTPSErrors | ||
* @param {?Puppeteer.Viewport} defaultViewport | ||
* @param {?Puppeteer.ChildProcess} process | ||
* @param {function()=} closeCallback | ||
*/ | ||
static async create(connection, contextIds, ignoreHTTPSErrors, defaultViewport, process, closeCallback) { | ||
const browser = new Browser(connection, contextIds, ignoreHTTPSErrors, defaultViewport, process, closeCallback); | ||
await connection.send('Target.setDiscoverTargets', {discover: true}); | ||
return browser; | ||
} | ||
/** | ||
* @param {!Puppeteer.Connection} connection | ||
* @param {!Array<string>} contextIds | ||
* @param {boolean} ignoreHTTPSErrors | ||
* @param {?Puppeteer.Viewport} defaultViewport | ||
* @param {?Puppeteer.ChildProcess} process | ||
* @param {(function():Promise)=} closeCallback | ||
*/ | ||
constructor(connection, contextIds, ignoreHTTPSErrors, defaultViewport, process, closeCallback) { | ||
super(); | ||
this._ignoreHTTPSErrors = ignoreHTTPSErrors; | ||
this._defaultViewport = defaultViewport; | ||
this._process = process; | ||
this._screenshotTaskQueue = new TaskQueue(); | ||
this._connection = connection; | ||
this._closeCallback = closeCallback || new Function(); | ||
this._defaultContext = new BrowserContext(this._connection, this, null); | ||
/** @type {Map<string, BrowserContext>} */ | ||
this._contexts = new Map(); | ||
for (const contextId of contextIds) | ||
this._contexts.set(contextId, new BrowserContext(this._connection, this, contextId)); | ||
/** @type {Map<string, Target>} */ | ||
this._targets = new Map(); | ||
this._connection.on(Events.Connection.Disconnected, () => this.emit(Events.Browser.Disconnected)); | ||
this._connection.on('Target.targetCreated', this._targetCreated.bind(this)); | ||
this._connection.on('Target.targetDestroyed', this._targetDestroyed.bind(this)); | ||
this._connection.on('Target.targetInfoChanged', this._targetInfoChanged.bind(this)); | ||
} | ||
/** | ||
* @return {?Puppeteer.ChildProcess} | ||
*/ | ||
process() { | ||
return this._process; | ||
} | ||
/** | ||
* @return {!Promise<!BrowserContext>} | ||
*/ | ||
async createIncognitoBrowserContext() { | ||
const {browserContextId} = await this._connection.send('Target.createBrowserContext'); | ||
const context = new BrowserContext(this._connection, this, browserContextId); | ||
this._contexts.set(browserContextId, context); | ||
return context; | ||
} | ||
/** | ||
* @return {!Array<!BrowserContext>} | ||
*/ | ||
browserContexts() { | ||
return [this._defaultContext, ...Array.from(this._contexts.values())]; | ||
} | ||
/** | ||
* @return {!BrowserContext} | ||
*/ | ||
defaultBrowserContext() { | ||
return this._defaultContext; | ||
} | ||
/** | ||
* @param {?string} contextId | ||
*/ | ||
async _disposeContext(contextId) { | ||
await this._connection.send('Target.disposeBrowserContext', {browserContextId: contextId || undefined}); | ||
this._contexts.delete(contextId); | ||
} | ||
/** | ||
* @param {!Protocol.Target.targetCreatedPayload} event | ||
*/ | ||
async _targetCreated(event) { | ||
const targetInfo = event.targetInfo; | ||
const {browserContextId} = targetInfo; | ||
const context = (browserContextId && this._contexts.has(browserContextId)) ? this._contexts.get(browserContextId) : this._defaultContext; | ||
const target = new Target(targetInfo, context, () => this._connection.createSession(targetInfo), this._ignoreHTTPSErrors, this._defaultViewport, this._screenshotTaskQueue); | ||
assert(!this._targets.has(event.targetInfo.targetId), 'Target should not exist before targetCreated'); | ||
this._targets.set(event.targetInfo.targetId, target); | ||
if (await target._initializedPromise) { | ||
this.emit(Events.Browser.TargetCreated, target); | ||
context.emit(Events.BrowserContext.TargetCreated, target); | ||
/** | ||
* @param {!Puppeteer.Connection} connection | ||
* @param {!Array<string>} contextIds | ||
* @param {boolean} ignoreHTTPSErrors | ||
* @param {?Puppeteer.Viewport} defaultViewport | ||
* @param {?Puppeteer.ChildProcess} process | ||
* @param {function()=} closeCallback | ||
*/ | ||
static async create(connection, contextIds, ignoreHTTPSErrors, defaultViewport, process, closeCallback) { | ||
const browser = new Browser(connection, contextIds, ignoreHTTPSErrors, defaultViewport, process, closeCallback); | ||
await connection.send('Target.setDiscoverTargets', { discover: true }); | ||
return browser; | ||
} | ||
} | ||
/** | ||
* @param {{targetId: string}} event | ||
*/ | ||
async _targetDestroyed(event) { | ||
const target = this._targets.get(event.targetId); | ||
target._initializedCallback(false); | ||
this._targets.delete(event.targetId); | ||
target._closedCallback(); | ||
if (await target._initializedPromise) { | ||
this.emit(Events.Browser.TargetDestroyed, target); | ||
target.browserContext().emit(Events.BrowserContext.TargetDestroyed, target); | ||
/** | ||
* @param {!Puppeteer.Connection} connection | ||
* @param {!Array<string>} contextIds | ||
* @param {boolean} ignoreHTTPSErrors | ||
* @param {?Puppeteer.Viewport} defaultViewport | ||
* @param {?Puppeteer.ChildProcess} process | ||
* @param {(function():Promise)=} closeCallback | ||
*/ | ||
constructor(connection, contextIds, ignoreHTTPSErrors, defaultViewport, process, closeCallback) { | ||
super(); | ||
this._ignoreHTTPSErrors = ignoreHTTPSErrors; | ||
this._defaultViewport = defaultViewport; | ||
this._process = process; | ||
this._screenshotTaskQueue = new TaskQueue(); | ||
this._connection = connection; | ||
this._closeCallback = closeCallback || new Function(); | ||
this._defaultContext = new BrowserContext(this._connection, this, null); | ||
/** @type {Map<string, BrowserContext>} */ | ||
this._contexts = new Map(); | ||
for (const contextId of contextIds) | ||
this._contexts.set(contextId, new BrowserContext(this._connection, this, contextId)); | ||
/** @type {Map<string, Target>} */ | ||
this._targets = new Map(); | ||
this._connection.on(Events.Connection.Disconnected, () => this.emit(Events.Browser.Disconnected)); | ||
this._connection.on('Target.targetCreated', this._targetCreated.bind(this)); | ||
this._connection.on('Target.targetDestroyed', this._targetDestroyed.bind(this)); | ||
this._connection.on('Target.targetInfoChanged', this._targetInfoChanged.bind(this)); | ||
} | ||
} | ||
/** | ||
* @param {!Protocol.Target.targetInfoChangedPayload} event | ||
*/ | ||
_targetInfoChanged(event) { | ||
const target = this._targets.get(event.targetInfo.targetId); | ||
assert(target, 'target should exist before targetInfoChanged'); | ||
const previousURL = target.url(); | ||
const wasInitialized = target._isInitialized; | ||
target._targetInfoChanged(event.targetInfo); | ||
if (wasInitialized && previousURL !== target.url()) { | ||
this.emit(Events.Browser.TargetChanged, target); | ||
target.browserContext().emit(Events.BrowserContext.TargetChanged, target); | ||
/** | ||
* @return {?Puppeteer.ChildProcess} | ||
*/ | ||
process() { | ||
return this._process; | ||
} | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
wsEndpoint() { | ||
return this._connection.url(); | ||
} | ||
/** | ||
* @return {!Promise<!Puppeteer.Page>} | ||
*/ | ||
async newPage() { | ||
return this._defaultContext.newPage(); | ||
} | ||
/** | ||
* @param {?string} contextId | ||
* @return {!Promise<!Puppeteer.Page>} | ||
*/ | ||
async _createPageInContext(contextId) { | ||
const {targetId} = await this._connection.send('Target.createTarget', {url: 'about:blank', browserContextId: contextId || undefined}); | ||
const target = await this._targets.get(targetId); | ||
assert(await target._initializedPromise, 'Failed to create target for page'); | ||
const page = await target.page(); | ||
return page; | ||
} | ||
/** | ||
* @return {!Array<!Target>} | ||
*/ | ||
targets() { | ||
return Array.from(this._targets.values()).filter(target => target._isInitialized); | ||
} | ||
/** | ||
* @return {!Target} | ||
*/ | ||
target() { | ||
return this.targets().find(target => target.type() === 'browser'); | ||
} | ||
/** | ||
* @param {function(!Target):boolean} predicate | ||
* @param {{timeout?: number}=} options | ||
* @return {!Promise<!Target>} | ||
*/ | ||
async waitForTarget(predicate, options = {}) { | ||
const { | ||
timeout = 30000 | ||
} = options; | ||
const existingTarget = this.targets().find(predicate); | ||
if (existingTarget) | ||
return existingTarget; | ||
let resolve; | ||
const targetPromise = new Promise(x => resolve = x); | ||
this.on(Events.Browser.TargetCreated, check); | ||
this.on(Events.Browser.TargetChanged, check); | ||
try { | ||
if (!timeout) | ||
return await targetPromise; | ||
return await helper.waitWithTimeout(targetPromise, 'target', timeout); | ||
} finally { | ||
this.removeListener(Events.Browser.TargetCreated, check); | ||
this.removeListener(Events.Browser.TargetChanged, check); | ||
/** | ||
* @return {!Promise<!BrowserContext>} | ||
*/ | ||
async createIncognitoBrowserContext() { | ||
const { browserContextId } = await this._connection.send('Target.createBrowserContext'); | ||
const context = new BrowserContext(this._connection, this, browserContextId); | ||
this._contexts.set(browserContextId, context); | ||
return context; | ||
} | ||
/** | ||
* @param {!Target} target | ||
* @return {!Array<!BrowserContext>} | ||
*/ | ||
function check(target) { | ||
if (predicate(target)) | ||
resolve(target); | ||
browserContexts() { | ||
return [this._defaultContext, ...Array.from(this._contexts.values())]; | ||
} | ||
} | ||
/** | ||
* @return {!Promise<!Array<!Puppeteer.Page>>} | ||
*/ | ||
async pages() { | ||
const contextPages = await Promise.all(this.browserContexts().map(context => context.pages())); | ||
// Flatten array. | ||
return contextPages.reduce((acc, x) => acc.concat(x), []); | ||
} | ||
/** | ||
* @return {!Promise<string>} | ||
*/ | ||
async version() { | ||
const version = await this._getVersion(); | ||
return version.product; | ||
} | ||
/** | ||
* @return {!Promise<string>} | ||
*/ | ||
async userAgent() { | ||
const version = await this._getVersion(); | ||
return version.userAgent; | ||
} | ||
async close() { | ||
await this._closeCallback.call(null); | ||
this.disconnect(); | ||
} | ||
disconnect() { | ||
this._connection.dispose(); | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
isConnected() { | ||
return !this._connection._closed; | ||
} | ||
/** | ||
* @return {!Promise<!Object>} | ||
*/ | ||
_getVersion() { | ||
return this._connection.send('Browser.getVersion'); | ||
} | ||
/** | ||
* @return {!BrowserContext} | ||
*/ | ||
defaultBrowserContext() { | ||
return this._defaultContext; | ||
} | ||
/** | ||
* @param {?string} contextId | ||
*/ | ||
async _disposeContext(contextId) { | ||
await this._connection.send('Target.disposeBrowserContext', { browserContextId: contextId || undefined }); | ||
this._contexts.delete(contextId); | ||
} | ||
/** | ||
* @param {!Protocol.Target.targetCreatedPayload} event | ||
*/ | ||
async _targetCreated(event) { | ||
const targetInfo = event.targetInfo; | ||
const { browserContextId } = targetInfo; | ||
const context = (browserContextId && this._contexts.has(browserContextId)) ? this._contexts.get(browserContextId) : this._defaultContext; | ||
const target = new Target(targetInfo, context, () => this._connection.createSession(targetInfo), this._ignoreHTTPSErrors, this._defaultViewport, this._screenshotTaskQueue); | ||
assert(!this._targets.has(event.targetInfo.targetId), 'Target should not exist before targetCreated'); | ||
this._targets.set(event.targetInfo.targetId, target); | ||
if (await target._initializedPromise) { | ||
this.emit(Events.Browser.TargetCreated, target); | ||
context.emit(Events.BrowserContext.TargetCreated, target); | ||
} | ||
} | ||
/** | ||
* @param {{targetId: string}} event | ||
*/ | ||
async _targetDestroyed(event) { | ||
const target = this._targets.get(event.targetId); | ||
target._initializedCallback(false); | ||
this._targets.delete(event.targetId); | ||
target._closedCallback(); | ||
if (await target._initializedPromise) { | ||
this.emit(Events.Browser.TargetDestroyed, target); | ||
target.browserContext().emit(Events.BrowserContext.TargetDestroyed, target); | ||
} | ||
} | ||
/** | ||
* @param {!Protocol.Target.targetInfoChangedPayload} event | ||
*/ | ||
_targetInfoChanged(event) { | ||
const target = this._targets.get(event.targetInfo.targetId); | ||
assert(target, 'target should exist before targetInfoChanged'); | ||
const previousURL = target.url(); | ||
const wasInitialized = target._isInitialized; | ||
target._targetInfoChanged(event.targetInfo); | ||
if (wasInitialized && previousURL !== target.url()) { | ||
this.emit(Events.Browser.TargetChanged, target); | ||
target.browserContext().emit(Events.BrowserContext.TargetChanged, target); | ||
} | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
wsEndpoint() { | ||
return this._connection.url(); | ||
} | ||
/** | ||
* @return {!Promise<!Puppeteer.Page>} | ||
*/ | ||
async newPage() { | ||
return this._defaultContext.newPage(); | ||
} | ||
/** | ||
* @param {?string} contextId | ||
* @return {!Promise<!Puppeteer.Page>} | ||
*/ | ||
async _createPageInContext(contextId) { | ||
const { targetId } = await this._connection.send('Target.createTarget', { url: 'about:blank', browserContextId: contextId || undefined }); | ||
const target = await this._targets.get(targetId); | ||
assert(await target._initializedPromise, 'Failed to create target for page'); | ||
const page = await target.page(); | ||
return page; | ||
} | ||
/** | ||
* @return {!Array<!Target>} | ||
*/ | ||
targets() { | ||
return Array.from(this._targets.values()).filter(target => target._isInitialized); | ||
} | ||
/** | ||
* @return {!Target} | ||
*/ | ||
target() { | ||
return this.targets().find(target => target.type() === 'browser'); | ||
} | ||
/** | ||
* @param {function(!Target):boolean} predicate | ||
* @param {{timeout?: number}=} options | ||
* @return {!Promise<!Target>} | ||
*/ | ||
async waitForTarget(predicate, options = {}) { | ||
const { timeout = 30000 } = options; | ||
const existingTarget = this.targets().find(predicate); | ||
if (existingTarget) | ||
return existingTarget; | ||
let resolve; | ||
const targetPromise = new Promise(x => resolve = x); | ||
this.on(Events.Browser.TargetCreated, check); | ||
this.on(Events.Browser.TargetChanged, check); | ||
try { | ||
if (!timeout) | ||
return await targetPromise; | ||
return await helper.waitWithTimeout(targetPromise, 'target', timeout); | ||
} | ||
finally { | ||
this.removeListener(Events.Browser.TargetCreated, check); | ||
this.removeListener(Events.Browser.TargetChanged, check); | ||
} | ||
/** | ||
* @param {!Target} target | ||
*/ | ||
function check(target) { | ||
if (predicate(target)) | ||
resolve(target); | ||
} | ||
} | ||
/** | ||
* @return {!Promise<!Array<!Puppeteer.Page>>} | ||
*/ | ||
async pages() { | ||
const contextPages = await Promise.all(this.browserContexts().map(context => context.pages())); | ||
// Flatten array. | ||
return contextPages.reduce((acc, x) => acc.concat(x), []); | ||
} | ||
/** | ||
* @return {!Promise<string>} | ||
*/ | ||
async version() { | ||
const version = await this._getVersion(); | ||
return version.product; | ||
} | ||
/** | ||
* @return {!Promise<string>} | ||
*/ | ||
async userAgent() { | ||
const version = await this._getVersion(); | ||
return version.userAgent; | ||
} | ||
async close() { | ||
await this._closeCallback.call(null); | ||
this.disconnect(); | ||
} | ||
disconnect() { | ||
this._connection.dispose(); | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
isConnected() { | ||
return !this._connection._closed; | ||
} | ||
/** | ||
* @return {!Promise<!Object>} | ||
*/ | ||
_getVersion() { | ||
return this._connection.send('Browser.getVersion'); | ||
} | ||
} | ||
class BrowserContext extends EventEmitter { | ||
/** | ||
* @param {!Puppeteer.Connection} connection | ||
* @param {!Browser} browser | ||
* @param {?string} contextId | ||
*/ | ||
constructor(connection, browser, contextId) { | ||
super(); | ||
this._connection = connection; | ||
this._browser = browser; | ||
this._id = contextId; | ||
} | ||
/** | ||
* @return {!Array<!Target>} target | ||
*/ | ||
targets() { | ||
return this._browser.targets().filter(target => target.browserContext() === this); | ||
} | ||
/** | ||
* @param {function(!Target):boolean} predicate | ||
* @param {{timeout?: number}=} options | ||
* @return {!Promise<!Target>} | ||
*/ | ||
waitForTarget(predicate, options) { | ||
return this._browser.waitForTarget(target => target.browserContext() === this && predicate(target), options); | ||
} | ||
/** | ||
* @return {!Promise<!Array<!Puppeteer.Page>>} | ||
*/ | ||
async pages() { | ||
const pages = await Promise.all( | ||
this.targets() | ||
/** | ||
* @param {!Puppeteer.Connection} connection | ||
* @param {!Browser} browser | ||
* @param {?string} contextId | ||
*/ | ||
constructor(connection, browser, contextId) { | ||
super(); | ||
this._connection = connection; | ||
this._browser = browser; | ||
this._id = contextId; | ||
} | ||
/** | ||
* @return {!Array<!Target>} target | ||
*/ | ||
targets() { | ||
return this._browser.targets().filter(target => target.browserContext() === this); | ||
} | ||
/** | ||
* @param {function(!Target):boolean} predicate | ||
* @param {{timeout?: number}=} options | ||
* @return {!Promise<!Target>} | ||
*/ | ||
waitForTarget(predicate, options) { | ||
return this._browser.waitForTarget(target => target.browserContext() === this && predicate(target), options); | ||
} | ||
/** | ||
* @return {!Promise<!Array<!Puppeteer.Page>>} | ||
*/ | ||
async pages() { | ||
const pages = await Promise.all(this.targets() | ||
.filter(target => target.type() === 'page') | ||
.map(target => target.page()) | ||
); | ||
return pages.filter(page => !!page); | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
isIncognito() { | ||
return !!this._id; | ||
} | ||
/** | ||
* @param {string} origin | ||
* @param {!Array<string>} permissions | ||
*/ | ||
async overridePermissions(origin, permissions) { | ||
const webPermissionToProtocol = new Map([ | ||
['geolocation', 'geolocation'], | ||
['midi', 'midi'], | ||
['notifications', 'notifications'], | ||
['push', 'push'], | ||
['camera', 'videoCapture'], | ||
['microphone', 'audioCapture'], | ||
['background-sync', 'backgroundSync'], | ||
['ambient-light-sensor', 'sensors'], | ||
['accelerometer', 'sensors'], | ||
['gyroscope', 'sensors'], | ||
['magnetometer', 'sensors'], | ||
['accessibility-events', 'accessibilityEvents'], | ||
['clipboard-read', 'clipboardRead'], | ||
['clipboard-write', 'clipboardWrite'], | ||
['payment-handler', 'paymentHandler'], | ||
// chrome-specific permissions we have. | ||
['midi-sysex', 'midiSysex'], | ||
]); | ||
permissions = permissions.map(permission => { | ||
const protocolPermission = webPermissionToProtocol.get(permission); | ||
if (!protocolPermission) | ||
throw new Error('Unknown permission: ' + permission); | ||
return protocolPermission; | ||
}); | ||
await this._connection.send('Browser.grantPermissions', {origin, browserContextId: this._id || undefined, permissions}); | ||
} | ||
async clearPermissionOverrides() { | ||
await this._connection.send('Browser.resetPermissions', {browserContextId: this._id || undefined}); | ||
} | ||
/** | ||
* @return {!Promise<!Puppeteer.Page>} | ||
*/ | ||
newPage() { | ||
return this._browser._createPageInContext(this._id); | ||
} | ||
/** | ||
* @return {!Browser} | ||
*/ | ||
browser() { | ||
return this._browser; | ||
} | ||
async close() { | ||
assert(this._id, 'Non-incognito profiles cannot be closed!'); | ||
await this._browser._disposeContext(this._id); | ||
} | ||
.map(target => target.page())); | ||
return pages.filter(page => !!page); | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
isIncognito() { | ||
return !!this._id; | ||
} | ||
/** | ||
* @param {string} origin | ||
* @param {!Array<string>} permissions | ||
*/ | ||
async overridePermissions(origin, permissions) { | ||
const webPermissionToProtocol = new Map([ | ||
['geolocation', 'geolocation'], | ||
['midi', 'midi'], | ||
['notifications', 'notifications'], | ||
['push', 'push'], | ||
['camera', 'videoCapture'], | ||
['microphone', 'audioCapture'], | ||
['background-sync', 'backgroundSync'], | ||
['ambient-light-sensor', 'sensors'], | ||
['accelerometer', 'sensors'], | ||
['gyroscope', 'sensors'], | ||
['magnetometer', 'sensors'], | ||
['accessibility-events', 'accessibilityEvents'], | ||
['clipboard-read', 'clipboardRead'], | ||
['clipboard-write', 'clipboardWrite'], | ||
['payment-handler', 'paymentHandler'], | ||
// chrome-specific permissions we have. | ||
['midi-sysex', 'midiSysex'], | ||
]); | ||
permissions = permissions.map(permission => { | ||
const protocolPermission = webPermissionToProtocol.get(permission); | ||
if (!protocolPermission) | ||
throw new Error('Unknown permission: ' + permission); | ||
return protocolPermission; | ||
}); | ||
await this._connection.send('Browser.grantPermissions', { origin, browserContextId: this._id || undefined, permissions }); | ||
} | ||
async clearPermissionOverrides() { | ||
await this._connection.send('Browser.resetPermissions', { browserContextId: this._id || undefined }); | ||
} | ||
/** | ||
* @return {!Promise<!Puppeteer.Page>} | ||
*/ | ||
newPage() { | ||
return this._browser._createPageInContext(this._id); | ||
} | ||
/** | ||
* @return {!Browser} | ||
*/ | ||
browser() { | ||
return this._browser; | ||
} | ||
async close() { | ||
assert(this._id, 'Non-incognito profiles cannot be closed!'); | ||
await this._browser._disposeContext(this._id); | ||
} | ||
} | ||
module.exports = {Browser, BrowserContext}; | ||
module.exports = { Browser, BrowserContext }; |
@@ -16,3 +16,2 @@ /** | ||
*/ | ||
const os = require('os'); | ||
@@ -22,5 +21,7 @@ const fs = require('fs'); | ||
const util = require('util'); | ||
const childProcess = require('child_process'); | ||
const extract = require('extract-zip'); | ||
const debugFetcher = require('debug')(`puppeteer:fetcher`); | ||
const URL = require('url'); | ||
const {helper, assert} = require('./helper'); | ||
const { helper, assert } = require('./helper'); | ||
const removeRecursive = require('rimraf'); | ||
@@ -31,14 +32,28 @@ // @ts-ignore | ||
const getProxyForUrl = require('proxy-from-env').getProxyForUrl; | ||
const DEFAULT_DOWNLOAD_HOST = 'https://storage.googleapis.com'; | ||
const supportedPlatforms = ['mac', 'linux', 'win32', 'win64']; | ||
const downloadURLs = { | ||
linux: '%s/chromium-browser-snapshots/Linux_x64/%d/%s.zip', | ||
mac: '%s/chromium-browser-snapshots/Mac/%d/%s.zip', | ||
win32: '%s/chromium-browser-snapshots/Win/%d/%s.zip', | ||
win64: '%s/chromium-browser-snapshots/Win_x64/%d/%s.zip', | ||
chrome: { | ||
linux: '%s/chromium-browser-snapshots/Linux_x64/%d/%s.zip', | ||
mac: '%s/chromium-browser-snapshots/Mac/%d/%s.zip', | ||
win32: '%s/chromium-browser-snapshots/Win/%d/%s.zip', | ||
win64: '%s/chromium-browser-snapshots/Win_x64/%d/%s.zip', | ||
}, | ||
firefox: { | ||
linux: '%s/firefox-%s.0a1.en-US.%s-x86_64.tar.bz2', | ||
mac: '%s/firefox-%s.0a1.en-US.%s.dmg', | ||
win32: '%s/firefox-%s.0a1.en-US.%s.zip', | ||
win64: '%s/firefox-%s.0a1.en-US.%s.zip', | ||
}, | ||
}; | ||
const browserConfig = { | ||
chrome: { | ||
host: 'https://storage.googleapis.com', | ||
destination: '.local-chromium', | ||
}, | ||
firefox: { | ||
host: 'https://archive.mozilla.org/pub/firefox/nightly/latest-mozilla-central', | ||
destination: '.local-firefox', | ||
} | ||
}; | ||
/** | ||
* @param {string} product | ||
* @param {string} platform | ||
@@ -48,15 +63,20 @@ * @param {string} revision | ||
*/ | ||
function archiveName(platform, revision) { | ||
if (platform === 'linux') | ||
return 'chrome-linux'; | ||
if (platform === 'mac') | ||
return 'chrome-mac'; | ||
if (platform === 'win32' || platform === 'win64') { | ||
// Windows archive name changed at r591479. | ||
return parseInt(revision, 10) > 591479 ? 'chrome-win' : 'chrome-win32'; | ||
} | ||
return null; | ||
function archiveName(product, platform, revision) { | ||
if (product === 'chrome') { | ||
if (platform === 'linux') | ||
return 'chrome-linux'; | ||
if (platform === 'mac') | ||
return 'chrome-mac'; | ||
if (platform === 'win32' || platform === 'win64') { | ||
// Windows archive name changed at r591479. | ||
return parseInt(revision, 10) > 591479 ? 'chrome-win' : 'chrome-win32'; | ||
} | ||
} | ||
else if (product === 'firefox') { | ||
return platform; | ||
} | ||
return null; | ||
} | ||
/** | ||
* @param {string} product | ||
* @param {string} platform | ||
@@ -67,6 +87,6 @@ * @param {string} host | ||
*/ | ||
function downloadURL(platform, host, revision) { | ||
return util.format(downloadURLs[platform], host, revision, archiveName(platform, revision)); | ||
function downloadURL(product, platform, host, revision) { | ||
const url = util.format(downloadURLs[product][platform], host, revision, archiveName(product, platform, revision)); | ||
return url; | ||
} | ||
const readdirAsync = helper.promisify(fs.readdir.bind(fs)); | ||
@@ -76,148 +96,168 @@ const mkdirAsync = helper.promisify(fs.mkdir.bind(fs)); | ||
const chmodAsync = helper.promisify(fs.chmod.bind(fs)); | ||
function existsAsync(filePath) { | ||
let fulfill = null; | ||
const promise = new Promise(x => fulfill = x); | ||
fs.access(filePath, err => fulfill(!err)); | ||
return promise; | ||
let fulfill = null; | ||
const promise = new Promise(x => fulfill = x); | ||
fs.access(filePath, err => fulfill(!err)); | ||
return promise; | ||
} | ||
class BrowserFetcher { | ||
/** | ||
* @param {string} projectRoot | ||
* @param {!BrowserFetcher.Options=} options | ||
*/ | ||
constructor(projectRoot, options = {}) { | ||
this._downloadsFolder = options.path || path.join(projectRoot, '.local-chromium'); | ||
this._downloadHost = options.host || DEFAULT_DOWNLOAD_HOST; | ||
this._platform = options.platform || ''; | ||
if (!this._platform) { | ||
const platform = os.platform(); | ||
if (platform === 'darwin') | ||
this._platform = 'mac'; | ||
else if (platform === 'linux') | ||
this._platform = 'linux'; | ||
else if (platform === 'win32') | ||
this._platform = os.arch() === 'x64' ? 'win64' : 'win32'; | ||
assert(this._platform, 'Unsupported platform: ' + os.platform()); | ||
/** | ||
* @param {string} projectRoot | ||
* @param {!BrowserFetcher.Options=} options | ||
*/ | ||
constructor(projectRoot, options = {}) { | ||
this._product = (options.product || 'chrome').toLowerCase(); | ||
assert(this._product === 'chrome' || this._product === 'firefox', `Unknown product: "${options.product}"`); | ||
this._downloadsFolder = options.path || path.join(projectRoot, browserConfig[this._product].destination); | ||
this._downloadHost = options.host || browserConfig[this._product].host; | ||
this._platform = options.platform || ''; | ||
if (!this._platform) { | ||
const platform = os.platform(); | ||
if (platform === 'darwin') | ||
this._platform = 'mac'; | ||
else if (platform === 'linux') | ||
this._platform = 'linux'; | ||
else if (platform === 'win32') | ||
this._platform = os.arch() === 'x64' ? 'win64' : 'win32'; | ||
assert(this._platform, 'Unsupported platform: ' + os.platform()); | ||
} | ||
assert(downloadURLs[this._product][this._platform], 'Unsupported platform: ' + this._platform); | ||
} | ||
assert(supportedPlatforms.includes(this._platform), 'Unsupported platform: ' + this._platform); | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
platform() { | ||
return this._platform; | ||
} | ||
/** | ||
* @param {string} revision | ||
* @return {!Promise<boolean>} | ||
*/ | ||
canDownload(revision) { | ||
const url = downloadURL(this._platform, this._downloadHost, revision); | ||
let resolve; | ||
const promise = new Promise(x => resolve = x); | ||
const request = httpRequest(url, 'HEAD', response => { | ||
resolve(response.statusCode === 200); | ||
}); | ||
request.on('error', error => { | ||
console.error(error); | ||
resolve(false); | ||
}); | ||
return promise; | ||
} | ||
/** | ||
* @param {string} revision | ||
* @param {?function(number, number):void} progressCallback | ||
* @return {!Promise<!BrowserFetcher.RevisionInfo>} | ||
*/ | ||
async download(revision, progressCallback) { | ||
const url = downloadURL(this._platform, this._downloadHost, revision); | ||
const zipPath = path.join(this._downloadsFolder, `download-${this._platform}-${revision}.zip`); | ||
const folderPath = this._getFolderPath(revision); | ||
if (await existsAsync(folderPath)) | ||
return this.revisionInfo(revision); | ||
if (!(await existsAsync(this._downloadsFolder))) | ||
await mkdirAsync(this._downloadsFolder); | ||
try { | ||
await downloadFile(url, zipPath, progressCallback); | ||
await extractZip(zipPath, folderPath); | ||
} finally { | ||
if (await existsAsync(zipPath)) | ||
await unlinkAsync(zipPath); | ||
/** | ||
* @return {string} | ||
*/ | ||
platform() { | ||
return this._platform; | ||
} | ||
const revisionInfo = this.revisionInfo(revision); | ||
if (revisionInfo) | ||
await chmodAsync(revisionInfo.executablePath, 0o755); | ||
return revisionInfo; | ||
} | ||
/** | ||
* @return {!Promise<!Array<string>>} | ||
*/ | ||
async localRevisions() { | ||
if (!await existsAsync(this._downloadsFolder)) | ||
return []; | ||
const fileNames = await readdirAsync(this._downloadsFolder); | ||
return fileNames.map(fileName => parseFolderPath(fileName)).filter(entry => entry && entry.platform === this._platform).map(entry => entry.revision); | ||
} | ||
/** | ||
* @param {string} revision | ||
*/ | ||
async remove(revision) { | ||
const folderPath = this._getFolderPath(revision); | ||
assert(await existsAsync(folderPath), `Failed to remove: revision ${revision} is not downloaded`); | ||
await new Promise(fulfill => removeRecursive(folderPath, fulfill)); | ||
} | ||
/** | ||
* @param {string} revision | ||
* @return {!BrowserFetcher.RevisionInfo} | ||
*/ | ||
revisionInfo(revision) { | ||
const folderPath = this._getFolderPath(revision); | ||
let executablePath = ''; | ||
if (this._platform === 'mac') | ||
executablePath = path.join(folderPath, archiveName(this._platform, revision), 'Chromium.app', 'Contents', 'MacOS', 'Chromium'); | ||
else if (this._platform === 'linux') | ||
executablePath = path.join(folderPath, archiveName(this._platform, revision), 'chrome'); | ||
else if (this._platform === 'win32' || this._platform === 'win64') | ||
executablePath = path.join(folderPath, archiveName(this._platform, revision), 'chrome.exe'); | ||
else | ||
throw new Error('Unsupported platform: ' + this._platform); | ||
const url = downloadURL(this._platform, this._downloadHost, revision); | ||
const local = fs.existsSync(folderPath); | ||
return {revision, executablePath, folderPath, local, url}; | ||
} | ||
/** | ||
* @param {string} revision | ||
* @return {string} | ||
*/ | ||
_getFolderPath(revision) { | ||
return path.join(this._downloadsFolder, this._platform + '-' + revision); | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
product() { | ||
return this._product; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
host() { | ||
return this._downloadHost; | ||
} | ||
/** | ||
* @param {string} revision | ||
* @return {!Promise<boolean>} | ||
*/ | ||
canDownload(revision) { | ||
const url = downloadURL(this._product, this._platform, this._downloadHost, revision); | ||
let resolve; | ||
const promise = new Promise(x => resolve = x); | ||
const request = httpRequest(url, 'HEAD', response => { | ||
resolve(response.statusCode === 200); | ||
}); | ||
request.on('error', error => { | ||
console.error(error); | ||
resolve(false); | ||
}); | ||
return promise; | ||
} | ||
/** | ||
* @param {string} revision | ||
* @param {?function(number, number):void} progressCallback | ||
* @return {!Promise<!BrowserFetcher.RevisionInfo>} | ||
*/ | ||
async download(revision, progressCallback) { | ||
const url = downloadURL(this._product, this._platform, this._downloadHost, revision); | ||
const fileName = url.split('/').pop(); | ||
const archivePath = path.join(this._downloadsFolder, fileName); | ||
const outputPath = this._getFolderPath(revision); | ||
if (await existsAsync(outputPath)) | ||
return this.revisionInfo(revision); | ||
if (!(await existsAsync(this._downloadsFolder))) | ||
await mkdirAsync(this._downloadsFolder); | ||
try { | ||
await downloadFile(url, archivePath, progressCallback); | ||
await install(archivePath, outputPath); | ||
} | ||
finally { | ||
if (await existsAsync(archivePath)) | ||
await unlinkAsync(archivePath); | ||
} | ||
const revisionInfo = this.revisionInfo(revision); | ||
if (revisionInfo) | ||
await chmodAsync(revisionInfo.executablePath, 0o755); | ||
return revisionInfo; | ||
} | ||
/** | ||
* @return {!Promise<!Array<string>>} | ||
*/ | ||
async localRevisions() { | ||
if (!await existsAsync(this._downloadsFolder)) | ||
return []; | ||
const fileNames = await readdirAsync(this._downloadsFolder); | ||
return fileNames.map(fileName => parseFolderPath(this._product, fileName)).filter(entry => entry && entry.platform === this._platform).map(entry => entry.revision); | ||
} | ||
/** | ||
* @param {string} revision | ||
*/ | ||
async remove(revision) { | ||
const folderPath = this._getFolderPath(revision); | ||
assert(await existsAsync(folderPath), `Failed to remove: revision ${revision} is not downloaded`); | ||
await new Promise(fulfill => removeRecursive(folderPath, fulfill)); | ||
} | ||
/** | ||
* @param {string} revision | ||
* @return {!BrowserFetcher.RevisionInfo} | ||
*/ | ||
revisionInfo(revision) { | ||
const folderPath = this._getFolderPath(revision); | ||
let executablePath = ''; | ||
if (this._product === 'chrome') { | ||
if (this._platform === 'mac') | ||
executablePath = path.join(folderPath, archiveName(this._product, this._platform, revision), 'Chromium.app', 'Contents', 'MacOS', 'Chromium'); | ||
else if (this._platform === 'linux') | ||
executablePath = path.join(folderPath, archiveName(this._product, this._platform, revision), 'chrome'); | ||
else if (this._platform === 'win32' || this._platform === 'win64') | ||
executablePath = path.join(folderPath, archiveName(this._product, this._platform, revision), 'chrome.exe'); | ||
else | ||
throw new Error('Unsupported platform: ' + this._platform); | ||
} | ||
else if (this._product === 'firefox') { | ||
if (this._platform === 'mac') | ||
executablePath = path.join(folderPath, 'Firefox Nightly.app', 'Contents', 'MacOS', 'firefox'); | ||
else if (this._platform === 'linux') | ||
executablePath = path.join(folderPath, 'firefox', 'firefox'); | ||
else if (this._platform === 'win32' || this._platform === 'win64') | ||
executablePath = path.join(folderPath, 'firefox', 'firefox.exe'); | ||
else | ||
throw new Error('Unsupported platform: ' + this._platform); | ||
} | ||
else { | ||
throw new Error('Unsupported product: ' + this._product); | ||
} | ||
const url = downloadURL(this._product, this._platform, this._downloadHost, revision); | ||
const local = fs.existsSync(folderPath); | ||
debugFetcher({ revision, executablePath, folderPath, local, url, product: this._product }); | ||
return { revision, executablePath, folderPath, local, url, product: this._product }; | ||
} | ||
/** | ||
* @param {string} revision | ||
* @return {string} | ||
*/ | ||
_getFolderPath(revision) { | ||
return path.join(this._downloadsFolder, this._platform + '-' + revision); | ||
} | ||
} | ||
module.exports = BrowserFetcher; | ||
/** | ||
* @param {string} folderPath | ||
* @return {?{platform: string, revision: string}} | ||
* @return {?{product: string, platform: string, revision: string}} | ||
*/ | ||
function parseFolderPath(folderPath) { | ||
const name = path.basename(folderPath); | ||
const splits = name.split('-'); | ||
if (splits.length !== 2) | ||
return null; | ||
const [platform, revision] = splits; | ||
if (!supportedPlatforms.includes(platform)) | ||
return null; | ||
return {platform, revision}; | ||
function parseFolderPath(product, folderPath) { | ||
const name = path.basename(folderPath); | ||
const splits = name.split('-'); | ||
if (splits.length !== 2) | ||
return null; | ||
const [platform, revision] = splits; | ||
if (!downloadURLs[product][platform]) | ||
return null; | ||
return { product, platform, revision }; | ||
} | ||
/** | ||
@@ -230,91 +270,165 @@ * @param {string} url | ||
function downloadFile(url, destinationPath, progressCallback) { | ||
let fulfill, reject; | ||
let downloadedBytes = 0; | ||
let totalBytes = 0; | ||
const promise = new Promise((x, y) => { fulfill = x; reject = y; }); | ||
const request = httpRequest(url, 'GET', response => { | ||
if (response.statusCode !== 200) { | ||
const error = new Error(`Download failed: server returned code ${response.statusCode}. URL: ${url}`); | ||
// consume response data to free up memory | ||
response.resume(); | ||
reject(error); | ||
return; | ||
debugFetcher(`Downloading binary from ${url}`); | ||
let fulfill, reject; | ||
let downloadedBytes = 0; | ||
let totalBytes = 0; | ||
const promise = new Promise((x, y) => { fulfill = x; reject = y; }); | ||
const request = httpRequest(url, 'GET', response => { | ||
if (response.statusCode !== 200) { | ||
const error = new Error(`Download failed: server returned code ${response.statusCode}. URL: ${url}`); | ||
// consume response data to free up memory | ||
response.resume(); | ||
reject(error); | ||
return; | ||
} | ||
const file = fs.createWriteStream(destinationPath); | ||
file.on('finish', () => fulfill()); | ||
file.on('error', error => reject(error)); | ||
response.pipe(file); | ||
totalBytes = parseInt(/** @type {string} */ (response.headers['content-length']), 10); | ||
if (progressCallback) | ||
response.on('data', onData); | ||
}); | ||
request.on('error', error => reject(error)); | ||
return promise; | ||
function onData(chunk) { | ||
downloadedBytes += chunk.length; | ||
progressCallback(downloadedBytes, totalBytes); | ||
} | ||
const file = fs.createWriteStream(destinationPath); | ||
file.on('finish', () => fulfill()); | ||
file.on('error', error => reject(error)); | ||
response.pipe(file); | ||
totalBytes = parseInt(/** @type {string} */ (response.headers['content-length']), 10); | ||
if (progressCallback) | ||
response.on('data', onData); | ||
}); | ||
request.on('error', error => reject(error)); | ||
return promise; | ||
function onData(chunk) { | ||
downloadedBytes += chunk.length; | ||
progressCallback(downloadedBytes, totalBytes); | ||
} | ||
} | ||
/** | ||
* @param {string} zipPath | ||
* Install from a zip, tar.bz2 or dmg file. | ||
* | ||
* @param {string} archivePath | ||
* @param {string} folderPath | ||
* @return {!Promise<?Error>} | ||
*/ | ||
function extractZip(zipPath, folderPath) { | ||
return new Promise((fulfill, reject) => extract(zipPath, {dir: folderPath}, err => { | ||
if (err) | ||
reject(err); | ||
function install(archivePath, folderPath) { | ||
debugFetcher(`Installing ${archivePath} to ${folderPath}`); | ||
if (archivePath.endsWith('.zip')) | ||
return extractZip(archivePath, folderPath); | ||
else if (archivePath.endsWith('.tar.bz2')) | ||
return extractTar(archivePath, folderPath); | ||
else if (archivePath.endsWith('.dmg')) | ||
return mkdirAsync(folderPath).then(() => installDMG(archivePath, folderPath)); | ||
else | ||
fulfill(); | ||
})); | ||
throw new Error(`Unsupported archive format: ${archivePath}`); | ||
} | ||
/** | ||
* @param {string} zipPath | ||
* @param {string} folderPath | ||
* @return {!Promise<?Error>} | ||
*/ | ||
async function extractZip(zipPath, folderPath) { | ||
try { | ||
await extract(zipPath, { dir: folderPath }); | ||
} | ||
catch (error) { | ||
return error; | ||
} | ||
} | ||
/** | ||
* @param {string} tarPath | ||
* @param {string} folderPath | ||
* @return {!Promise<?Error>} | ||
*/ | ||
function extractTar(tarPath, folderPath) { | ||
const tar = require('tar-fs'); | ||
// @ts-ignore | ||
const bzip = require('unbzip2-stream'); | ||
return new Promise((fulfill, reject) => { | ||
const tarStream = tar.extract(folderPath); | ||
tarStream.on('error', reject); | ||
tarStream.on('finish', fulfill); | ||
const readStream = fs.createReadStream(tarPath); | ||
readStream.on('data', () => { process.stdout.write('\rExtracting...'); }); | ||
readStream.pipe(bzip()).pipe(tarStream); | ||
}); | ||
} | ||
/** | ||
* Install *.app directory from dmg file | ||
* | ||
* @param {string} dmgPath | ||
* @param {string} folderPath | ||
* @return {!Promise<?Error>} | ||
*/ | ||
function installDMG(dmgPath, folderPath) { | ||
let mountPath; | ||
function mountAndCopy(fulfill, reject) { | ||
const mountCommand = `hdiutil attach -nobrowse -noautoopen "${dmgPath}"`; | ||
childProcess.exec(mountCommand, (err, stdout, stderr) => { | ||
if (err) | ||
return reject(err); | ||
const volumes = stdout.match(/\/Volumes\/(.*)/m); | ||
if (!volumes) | ||
return reject(new Error(`Could not find volume path in ${stdout}`)); | ||
mountPath = volumes[0]; | ||
readdirAsync(mountPath).then(fileNames => { | ||
const appName = fileNames.filter(item => typeof item === 'string' && item.endsWith('.app'))[0]; | ||
if (!appName) | ||
return reject(new Error(`Cannot find app in ${mountPath}`)); | ||
const copyPath = path.join(mountPath, appName); | ||
debugFetcher(`Copying ${copyPath} to ${folderPath}`); | ||
childProcess.exec(`cp -R "${copyPath}" "${folderPath}"`, (err, stdout) => { | ||
if (err) | ||
reject(err); | ||
else | ||
fulfill(); | ||
}); | ||
}).catch(reject); | ||
}); | ||
} | ||
function unmount() { | ||
if (!mountPath) | ||
return; | ||
const unmountCommand = `hdiutil detach "${mountPath}" -quiet`; | ||
debugFetcher(`Unmounting ${mountPath}`); | ||
childProcess.exec(unmountCommand, err => { | ||
if (err) | ||
console.error(`Error unmounting dmg: ${err}`); | ||
}); | ||
} | ||
return new Promise(mountAndCopy).catch(err => { console.error(err); }).finally(unmount); | ||
} | ||
function httpRequest(url, method, response) { | ||
/** @type {Object} */ | ||
let options = URL.parse(url); | ||
options.method = method; | ||
const proxyURL = getProxyForUrl(url); | ||
if (proxyURL) { | ||
if (url.startsWith('http:')) { | ||
const proxy = URL.parse(proxyURL); | ||
options = { | ||
path: options.href, | ||
host: proxy.hostname, | ||
port: proxy.port, | ||
}; | ||
} else { | ||
/** @type {Object} */ | ||
const parsedProxyURL = URL.parse(proxyURL); | ||
parsedProxyURL.secureProxy = parsedProxyURL.protocol === 'https:'; | ||
options.agent = new ProxyAgent(parsedProxyURL); | ||
options.rejectUnauthorized = false; | ||
/** @type {Object} */ | ||
let options = URL.parse(url); | ||
options.method = method; | ||
const proxyURL = getProxyForUrl(url); | ||
if (proxyURL) { | ||
if (url.startsWith('http:')) { | ||
const proxy = URL.parse(proxyURL); | ||
options = { | ||
path: options.href, | ||
host: proxy.hostname, | ||
port: proxy.port, | ||
}; | ||
} | ||
else { | ||
/** @type {Object} */ | ||
const parsedProxyURL = URL.parse(proxyURL); | ||
parsedProxyURL.secureProxy = parsedProxyURL.protocol === 'https:'; | ||
options.agent = new ProxyAgent(parsedProxyURL); | ||
options.rejectUnauthorized = false; | ||
} | ||
} | ||
} | ||
const requestCallback = res => { | ||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) | ||
httpRequest(res.headers.location, method, response); | ||
else | ||
response(res); | ||
}; | ||
const request = options.protocol === 'https:' ? | ||
require('https').request(options, requestCallback) : | ||
require('http').request(options, requestCallback); | ||
request.end(); | ||
return request; | ||
const requestCallback = res => { | ||
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) | ||
httpRequest(res.headers.location, method, response); | ||
else | ||
response(res); | ||
}; | ||
const request = options.protocol === 'https:' ? | ||
require('https').request(options, requestCallback) : | ||
require('http').request(options, requestCallback); | ||
request.end(); | ||
return request; | ||
} | ||
/** | ||
* @typedef {Object} BrowserFetcher.Options | ||
* @property {string=} platform | ||
* @property {string=} product | ||
* @property {string=} path | ||
* @property {string=} host | ||
*/ | ||
/** | ||
@@ -327,2 +441,3 @@ * @typedef {Object} BrowserFetcher.RevisionInfo | ||
* @property {string} revision | ||
* @property {string} product | ||
*/ |
@@ -16,205 +16,192 @@ /** | ||
*/ | ||
const {assert} = require('./helper'); | ||
const {Events} = require('./Events'); | ||
const { assert } = require('./helper'); | ||
const { Events } = require('./Events'); | ||
const debugProtocol = require('debug')('puppeteer:protocol'); | ||
const EventEmitter = require('events'); | ||
class Connection extends EventEmitter { | ||
/** | ||
* @param {string} url | ||
* @param {!Puppeteer.ConnectionTransport} transport | ||
* @param {number=} delay | ||
*/ | ||
constructor(url, transport, delay = 0) { | ||
super(); | ||
this._url = url; | ||
this._lastId = 0; | ||
/** @type {!Map<number, {resolve: function, reject: function, error: !Error, method: string}>}*/ | ||
this._callbacks = new Map(); | ||
this._delay = delay; | ||
this._transport = transport; | ||
this._transport.onmessage = this._onMessage.bind(this); | ||
this._transport.onclose = this._onClose.bind(this); | ||
/** @type {!Map<string, !CDPSession>}*/ | ||
this._sessions = new Map(); | ||
this._closed = false; | ||
} | ||
/** | ||
* @param {!CDPSession} session | ||
* @return {!Connection} | ||
*/ | ||
static fromSession(session) { | ||
return session._connection; | ||
} | ||
/** | ||
* @param {string} sessionId | ||
* @return {?CDPSession} | ||
*/ | ||
session(sessionId) { | ||
return this._sessions.get(sessionId) || null; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
url() { | ||
return this._url; | ||
} | ||
/** | ||
* @param {string} method | ||
* @param {!Object=} params | ||
* @return {!Promise<?Object>} | ||
*/ | ||
send(method, params = {}) { | ||
const id = this._rawSend({method, params}); | ||
return new Promise((resolve, reject) => { | ||
this._callbacks.set(id, {resolve, reject, error: new Error(), method}); | ||
}); | ||
} | ||
/** | ||
* @param {*} message | ||
* @return {number} | ||
*/ | ||
_rawSend(message) { | ||
const id = ++this._lastId; | ||
message = JSON.stringify(Object.assign({}, message, {id})); | ||
debugProtocol('SEND ► ' + message); | ||
this._transport.send(message); | ||
return id; | ||
} | ||
/** | ||
* @param {string} message | ||
*/ | ||
async _onMessage(message) { | ||
if (this._delay) | ||
await new Promise(f => setTimeout(f, this._delay)); | ||
debugProtocol('◀ RECV ' + message); | ||
const object = JSON.parse(message); | ||
if (object.method === 'Target.attachedToTarget') { | ||
const sessionId = object.params.sessionId; | ||
const session = new CDPSession(this, object.params.targetInfo.type, sessionId); | ||
this._sessions.set(sessionId, session); | ||
} else if (object.method === 'Target.detachedFromTarget') { | ||
const session = this._sessions.get(object.params.sessionId); | ||
if (session) { | ||
session._onClosed(); | ||
this._sessions.delete(object.params.sessionId); | ||
} | ||
/** | ||
* @param {string} url | ||
* @param {!Puppeteer.ConnectionTransport} transport | ||
* @param {number=} delay | ||
*/ | ||
constructor(url, transport, delay = 0) { | ||
super(); | ||
this._url = url; | ||
this._lastId = 0; | ||
/** @type {!Map<number, {resolve: function, reject: function, error: !Error, method: string}>}*/ | ||
this._callbacks = new Map(); | ||
this._delay = delay; | ||
this._transport = transport; | ||
this._transport.onmessage = this._onMessage.bind(this); | ||
this._transport.onclose = this._onClose.bind(this); | ||
/** @type {!Map<string, !CDPSession>}*/ | ||
this._sessions = new Map(); | ||
this._closed = false; | ||
} | ||
if (object.sessionId) { | ||
const session = this._sessions.get(object.sessionId); | ||
if (session) | ||
session._onMessage(object); | ||
} else if (object.id) { | ||
const callback = this._callbacks.get(object.id); | ||
// Callbacks could be all rejected if someone has called `.dispose()`. | ||
if (callback) { | ||
this._callbacks.delete(object.id); | ||
if (object.error) | ||
callback.reject(createProtocolError(callback.error, callback.method, object)); | ||
else | ||
callback.resolve(object.result); | ||
} | ||
} else { | ||
this.emit(object.method, object.params); | ||
/** | ||
* @param {!CDPSession} session | ||
* @return {!Connection} | ||
*/ | ||
static fromSession(session) { | ||
return session._connection; | ||
} | ||
} | ||
_onClose() { | ||
if (this._closed) | ||
return; | ||
this._closed = true; | ||
this._transport.onmessage = null; | ||
this._transport.onclose = null; | ||
for (const callback of this._callbacks.values()) | ||
callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`)); | ||
this._callbacks.clear(); | ||
for (const session of this._sessions.values()) | ||
session._onClosed(); | ||
this._sessions.clear(); | ||
this.emit(Events.Connection.Disconnected); | ||
} | ||
dispose() { | ||
this._onClose(); | ||
this._transport.close(); | ||
} | ||
/** | ||
* @param {Protocol.Target.TargetInfo} targetInfo | ||
* @return {!Promise<!CDPSession>} | ||
*/ | ||
async createSession(targetInfo) { | ||
const {sessionId} = await this.send('Target.attachToTarget', {targetId: targetInfo.targetId, flatten: true}); | ||
return this._sessions.get(sessionId); | ||
} | ||
/** | ||
* @param {string} sessionId | ||
* @return {?CDPSession} | ||
*/ | ||
session(sessionId) { | ||
return this._sessions.get(sessionId) || null; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
url() { | ||
return this._url; | ||
} | ||
/** | ||
* @param {string} method | ||
* @param {!Object=} params | ||
* @return {!Promise<?Object>} | ||
*/ | ||
send(method, params = {}) { | ||
const id = this._rawSend({ method, params }); | ||
return new Promise((resolve, reject) => { | ||
this._callbacks.set(id, { resolve, reject, error: new Error(), method }); | ||
}); | ||
} | ||
/** | ||
* @param {*} message | ||
* @return {number} | ||
*/ | ||
_rawSend(message) { | ||
const id = ++this._lastId; | ||
message = JSON.stringify(Object.assign({}, message, { id })); | ||
debugProtocol('SEND ► ' + message); | ||
this._transport.send(message); | ||
return id; | ||
} | ||
/** | ||
* @param {string} message | ||
*/ | ||
async _onMessage(message) { | ||
if (this._delay) | ||
await new Promise(f => setTimeout(f, this._delay)); | ||
debugProtocol('◀ RECV ' + message); | ||
const object = JSON.parse(message); | ||
if (object.method === 'Target.attachedToTarget') { | ||
const sessionId = object.params.sessionId; | ||
const session = new CDPSession(this, object.params.targetInfo.type, sessionId); | ||
this._sessions.set(sessionId, session); | ||
} | ||
else if (object.method === 'Target.detachedFromTarget') { | ||
const session = this._sessions.get(object.params.sessionId); | ||
if (session) { | ||
session._onClosed(); | ||
this._sessions.delete(object.params.sessionId); | ||
} | ||
} | ||
if (object.sessionId) { | ||
const session = this._sessions.get(object.sessionId); | ||
if (session) | ||
session._onMessage(object); | ||
} | ||
else if (object.id) { | ||
const callback = this._callbacks.get(object.id); | ||
// Callbacks could be all rejected if someone has called `.dispose()`. | ||
if (callback) { | ||
this._callbacks.delete(object.id); | ||
if (object.error) | ||
callback.reject(createProtocolError(callback.error, callback.method, object)); | ||
else | ||
callback.resolve(object.result); | ||
} | ||
} | ||
else { | ||
this.emit(object.method, object.params); | ||
} | ||
} | ||
_onClose() { | ||
if (this._closed) | ||
return; | ||
this._closed = true; | ||
this._transport.onmessage = null; | ||
this._transport.onclose = null; | ||
for (const callback of this._callbacks.values()) | ||
callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`)); | ||
this._callbacks.clear(); | ||
for (const session of this._sessions.values()) | ||
session._onClosed(); | ||
this._sessions.clear(); | ||
this.emit(Events.Connection.Disconnected); | ||
} | ||
dispose() { | ||
this._onClose(); | ||
this._transport.close(); | ||
} | ||
/** | ||
* @param {Protocol.Target.TargetInfo} targetInfo | ||
* @return {!Promise<!CDPSession>} | ||
*/ | ||
async createSession(targetInfo) { | ||
const { sessionId } = await this.send('Target.attachToTarget', { targetId: targetInfo.targetId, flatten: true }); | ||
return this._sessions.get(sessionId); | ||
} | ||
} | ||
class CDPSession extends EventEmitter { | ||
/** | ||
* @param {!Connection} connection | ||
* @param {string} targetType | ||
* @param {string} sessionId | ||
*/ | ||
constructor(connection, targetType, sessionId) { | ||
super(); | ||
/** @type {!Map<number, {resolve: function, reject: function, error: !Error, method: string}>}*/ | ||
this._callbacks = new Map(); | ||
this._connection = connection; | ||
this._targetType = targetType; | ||
this._sessionId = sessionId; | ||
} | ||
/** | ||
* @param {string} method | ||
* @param {!Object=} params | ||
* @return {!Promise<?Object>} | ||
*/ | ||
send(method, params = {}) { | ||
if (!this._connection) | ||
return Promise.reject(new Error(`Protocol error (${method}): Session closed. Most likely the ${this._targetType} has been closed.`)); | ||
const id = this._connection._rawSend({sessionId: this._sessionId, method, params}); | ||
return new Promise((resolve, reject) => { | ||
this._callbacks.set(id, {resolve, reject, error: new Error(), method}); | ||
}); | ||
} | ||
/** | ||
* @param {{id?: number, method: string, params: Object, error: {message: string, data: any}, result?: *}} object | ||
*/ | ||
_onMessage(object) { | ||
if (object.id && this._callbacks.has(object.id)) { | ||
const callback = this._callbacks.get(object.id); | ||
this._callbacks.delete(object.id); | ||
if (object.error) | ||
callback.reject(createProtocolError(callback.error, callback.method, object)); | ||
else | ||
callback.resolve(object.result); | ||
} else { | ||
assert(!object.id); | ||
this.emit(object.method, object.params); | ||
/** | ||
* @param {!Connection} connection | ||
* @param {string} targetType | ||
* @param {string} sessionId | ||
*/ | ||
constructor(connection, targetType, sessionId) { | ||
super(); | ||
/** @type {!Map<number, {resolve: function, reject: function, error: !Error, method: string}>}*/ | ||
this._callbacks = new Map(); | ||
this._connection = connection; | ||
this._targetType = targetType; | ||
this._sessionId = sessionId; | ||
} | ||
} | ||
async detach() { | ||
if (!this._connection) | ||
throw new Error(`Session already detached. Most likely the ${this._targetType} has been closed.`); | ||
await this._connection.send('Target.detachFromTarget', {sessionId: this._sessionId}); | ||
} | ||
_onClosed() { | ||
for (const callback of this._callbacks.values()) | ||
callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`)); | ||
this._callbacks.clear(); | ||
this._connection = null; | ||
this.emit(Events.CDPSession.Disconnected); | ||
} | ||
/** | ||
* @param {string} method | ||
* @param {!Object=} params | ||
* @return {!Promise<?Object>} | ||
*/ | ||
send(method, params = {}) { | ||
if (!this._connection) | ||
return Promise.reject(new Error(`Protocol error (${method}): Session closed. Most likely the ${this._targetType} has been closed.`)); | ||
const id = this._connection._rawSend({ sessionId: this._sessionId, method, params }); | ||
return new Promise((resolve, reject) => { | ||
this._callbacks.set(id, { resolve, reject, error: new Error(), method }); | ||
}); | ||
} | ||
/** | ||
* @param {{id?: number, method: string, params: Object, error: {message: string, data: any}, result?: *}} object | ||
*/ | ||
_onMessage(object) { | ||
if (object.id && this._callbacks.has(object.id)) { | ||
const callback = this._callbacks.get(object.id); | ||
this._callbacks.delete(object.id); | ||
if (object.error) | ||
callback.reject(createProtocolError(callback.error, callback.method, object)); | ||
else | ||
callback.resolve(object.result); | ||
} | ||
else { | ||
assert(!object.id); | ||
this.emit(object.method, object.params); | ||
} | ||
} | ||
async detach() { | ||
if (!this._connection) | ||
throw new Error(`Session already detached. Most likely the ${this._targetType} has been closed.`); | ||
await this._connection.send('Target.detachFromTarget', { sessionId: this._sessionId }); | ||
} | ||
_onClosed() { | ||
for (const callback of this._callbacks.values()) | ||
callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`)); | ||
this._callbacks.clear(); | ||
this._connection = null; | ||
this.emit(Events.CDPSession.Disconnected); | ||
} | ||
} | ||
/** | ||
@@ -227,8 +214,7 @@ * @param {!Error} error | ||
function createProtocolError(error, method, object) { | ||
let message = `Protocol error (${method}): ${object.error.message}`; | ||
if ('data' in object.error) | ||
message += ` ${object.error.data}`; | ||
return rewriteError(error, message); | ||
let message = `Protocol error (${method}): ${object.error.message}`; | ||
if ('data' in object.error) | ||
message += ` ${object.error.data}`; | ||
return rewriteError(error, message); | ||
} | ||
/** | ||
@@ -240,6 +226,5 @@ * @param {!Error} error | ||
function rewriteError(error, message) { | ||
error.message = message; | ||
return error; | ||
error.message = message; | ||
return error; | ||
} | ||
module.exports = {Connection, CDPSession}; | ||
module.exports = { Connection, CDPSession }; |
@@ -16,7 +16,4 @@ /** | ||
*/ | ||
const {helper, debugError, assert} = require('./helper'); | ||
const {EVALUATION_SCRIPT_URL} = require('./ExecutionContext'); | ||
const { helper, debugError, assert } = require('./helper'); | ||
const { EVALUATION_SCRIPT_URL } = require('./ExecutionContext'); | ||
/** | ||
@@ -28,240 +25,219 @@ * @typedef {Object} CoverageEntry | ||
*/ | ||
class Coverage { | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
*/ | ||
constructor(client) { | ||
this._jsCoverage = new JSCoverage(client); | ||
this._cssCoverage = new CSSCoverage(client); | ||
} | ||
/** | ||
* @param {!{resetOnNavigation?: boolean, reportAnonymousScripts?: boolean}} options | ||
*/ | ||
async startJSCoverage(options) { | ||
return await this._jsCoverage.start(options); | ||
} | ||
/** | ||
* @return {!Promise<!Array<!CoverageEntry>>} | ||
*/ | ||
async stopJSCoverage() { | ||
return await this._jsCoverage.stop(); | ||
} | ||
/** | ||
* @param {{resetOnNavigation?: boolean}=} options | ||
*/ | ||
async startCSSCoverage(options) { | ||
return await this._cssCoverage.start(options); | ||
} | ||
/** | ||
* @return {!Promise<!Array<!CoverageEntry>>} | ||
*/ | ||
async stopCSSCoverage() { | ||
return await this._cssCoverage.stop(); | ||
} | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
*/ | ||
constructor(client) { | ||
this._jsCoverage = new JSCoverage(client); | ||
this._cssCoverage = new CSSCoverage(client); | ||
} | ||
/** | ||
* @param {!{resetOnNavigation?: boolean, reportAnonymousScripts?: boolean}} options | ||
*/ | ||
async startJSCoverage(options) { | ||
return await this._jsCoverage.start(options); | ||
} | ||
/** | ||
* @return {!Promise<!Array<!CoverageEntry>>} | ||
*/ | ||
async stopJSCoverage() { | ||
return await this._jsCoverage.stop(); | ||
} | ||
/** | ||
* @param {{resetOnNavigation?: boolean}=} options | ||
*/ | ||
async startCSSCoverage(options) { | ||
return await this._cssCoverage.start(options); | ||
} | ||
/** | ||
* @return {!Promise<!Array<!CoverageEntry>>} | ||
*/ | ||
async stopCSSCoverage() { | ||
return await this._cssCoverage.stop(); | ||
} | ||
} | ||
module.exports = {Coverage}; | ||
module.exports = { Coverage }; | ||
class JSCoverage { | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
*/ | ||
constructor(client) { | ||
this._client = client; | ||
this._enabled = false; | ||
this._scriptURLs = new Map(); | ||
this._scriptSources = new Map(); | ||
this._eventListeners = []; | ||
this._resetOnNavigation = false; | ||
} | ||
/** | ||
* @param {!{resetOnNavigation?: boolean, reportAnonymousScripts?: boolean}} options | ||
*/ | ||
async start(options = {}) { | ||
assert(!this._enabled, 'JSCoverage is already enabled'); | ||
const { | ||
resetOnNavigation = true, | ||
reportAnonymousScripts = false | ||
} = options; | ||
this._resetOnNavigation = resetOnNavigation; | ||
this._reportAnonymousScripts = reportAnonymousScripts; | ||
this._enabled = true; | ||
this._scriptURLs.clear(); | ||
this._scriptSources.clear(); | ||
this._eventListeners = [ | ||
helper.addEventListener(this._client, 'Debugger.scriptParsed', this._onScriptParsed.bind(this)), | ||
helper.addEventListener(this._client, 'Runtime.executionContextsCleared', this._onExecutionContextsCleared.bind(this)), | ||
]; | ||
await Promise.all([ | ||
this._client.send('Profiler.enable'), | ||
this._client.send('Profiler.startPreciseCoverage', {callCount: false, detailed: true}), | ||
this._client.send('Debugger.enable'), | ||
this._client.send('Debugger.setSkipAllPauses', {skip: true}) | ||
]); | ||
} | ||
_onExecutionContextsCleared() { | ||
if (!this._resetOnNavigation) | ||
return; | ||
this._scriptURLs.clear(); | ||
this._scriptSources.clear(); | ||
} | ||
/** | ||
* @param {!Protocol.Debugger.scriptParsedPayload} event | ||
*/ | ||
async _onScriptParsed(event) { | ||
// Ignore puppeteer-injected scripts | ||
if (event.url === EVALUATION_SCRIPT_URL) | ||
return; | ||
// Ignore other anonymous scripts unless the reportAnonymousScripts option is true. | ||
if (!event.url && !this._reportAnonymousScripts) | ||
return; | ||
try { | ||
const response = await this._client.send('Debugger.getScriptSource', {scriptId: event.scriptId}); | ||
this._scriptURLs.set(event.scriptId, event.url); | ||
this._scriptSources.set(event.scriptId, response.scriptSource); | ||
} catch (e) { | ||
// This might happen if the page has already navigated away. | ||
debugError(e); | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
*/ | ||
constructor(client) { | ||
this._client = client; | ||
this._enabled = false; | ||
this._scriptURLs = new Map(); | ||
this._scriptSources = new Map(); | ||
this._eventListeners = []; | ||
this._resetOnNavigation = false; | ||
} | ||
} | ||
/** | ||
* @return {!Promise<!Array<!CoverageEntry>>} | ||
*/ | ||
async stop() { | ||
assert(this._enabled, 'JSCoverage is not enabled'); | ||
this._enabled = false; | ||
const [profileResponse] = await Promise.all([ | ||
this._client.send('Profiler.takePreciseCoverage'), | ||
this._client.send('Profiler.stopPreciseCoverage'), | ||
this._client.send('Profiler.disable'), | ||
this._client.send('Debugger.disable'), | ||
]); | ||
helper.removeEventListeners(this._eventListeners); | ||
const coverage = []; | ||
for (const entry of profileResponse.result) { | ||
let url = this._scriptURLs.get(entry.scriptId); | ||
if (!url && this._reportAnonymousScripts) | ||
url = 'debugger://VM' + entry.scriptId; | ||
const text = this._scriptSources.get(entry.scriptId); | ||
if (text === undefined || url === undefined) | ||
continue; | ||
const flattenRanges = []; | ||
for (const func of entry.functions) | ||
flattenRanges.push(...func.ranges); | ||
const ranges = convertToDisjointRanges(flattenRanges); | ||
coverage.push({url, ranges, text}); | ||
/** | ||
* @param {!{resetOnNavigation?: boolean, reportAnonymousScripts?: boolean}} options | ||
*/ | ||
async start(options = {}) { | ||
assert(!this._enabled, 'JSCoverage is already enabled'); | ||
const { resetOnNavigation = true, reportAnonymousScripts = false } = options; | ||
this._resetOnNavigation = resetOnNavigation; | ||
this._reportAnonymousScripts = reportAnonymousScripts; | ||
this._enabled = true; | ||
this._scriptURLs.clear(); | ||
this._scriptSources.clear(); | ||
this._eventListeners = [ | ||
helper.addEventListener(this._client, 'Debugger.scriptParsed', this._onScriptParsed.bind(this)), | ||
helper.addEventListener(this._client, 'Runtime.executionContextsCleared', this._onExecutionContextsCleared.bind(this)), | ||
]; | ||
await Promise.all([ | ||
this._client.send('Profiler.enable'), | ||
this._client.send('Profiler.startPreciseCoverage', { callCount: false, detailed: true }), | ||
this._client.send('Debugger.enable'), | ||
this._client.send('Debugger.setSkipAllPauses', { skip: true }) | ||
]); | ||
} | ||
return coverage; | ||
} | ||
_onExecutionContextsCleared() { | ||
if (!this._resetOnNavigation) | ||
return; | ||
this._scriptURLs.clear(); | ||
this._scriptSources.clear(); | ||
} | ||
/** | ||
* @param {!Protocol.Debugger.scriptParsedPayload} event | ||
*/ | ||
async _onScriptParsed(event) { | ||
// Ignore puppeteer-injected scripts | ||
if (event.url === EVALUATION_SCRIPT_URL) | ||
return; | ||
// Ignore other anonymous scripts unless the reportAnonymousScripts option is true. | ||
if (!event.url && !this._reportAnonymousScripts) | ||
return; | ||
try { | ||
const response = await this._client.send('Debugger.getScriptSource', { scriptId: event.scriptId }); | ||
this._scriptURLs.set(event.scriptId, event.url); | ||
this._scriptSources.set(event.scriptId, response.scriptSource); | ||
} | ||
catch (e) { | ||
// This might happen if the page has already navigated away. | ||
debugError(e); | ||
} | ||
} | ||
/** | ||
* @return {!Promise<!Array<!CoverageEntry>>} | ||
*/ | ||
async stop() { | ||
assert(this._enabled, 'JSCoverage is not enabled'); | ||
this._enabled = false; | ||
const result = await Promise.all([ | ||
this._client.send('Profiler.takePreciseCoverage'), | ||
this._client.send('Profiler.stopPreciseCoverage'), | ||
this._client.send('Profiler.disable'), | ||
this._client.send('Debugger.disable'), | ||
]); | ||
helper.removeEventListeners(this._eventListeners); | ||
const coverage = []; | ||
const profileResponse = /** @type Protocol.Profiler.takePreciseCoverageReturnValue */ (result[0]); | ||
for (const entry of profileResponse.result) { | ||
let url = this._scriptURLs.get(entry.scriptId); | ||
if (!url && this._reportAnonymousScripts) | ||
url = 'debugger://VM' + entry.scriptId; | ||
const text = this._scriptSources.get(entry.scriptId); | ||
if (text === undefined || url === undefined) | ||
continue; | ||
const flattenRanges = []; | ||
for (const func of entry.functions) | ||
flattenRanges.push(...func.ranges); | ||
const ranges = convertToDisjointRanges(flattenRanges); | ||
coverage.push({ url, ranges, text }); | ||
} | ||
return coverage; | ||
} | ||
} | ||
class CSSCoverage { | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
*/ | ||
constructor(client) { | ||
this._client = client; | ||
this._enabled = false; | ||
this._stylesheetURLs = new Map(); | ||
this._stylesheetSources = new Map(); | ||
this._eventListeners = []; | ||
this._resetOnNavigation = false; | ||
} | ||
/** | ||
* @param {{resetOnNavigation?: boolean}=} options | ||
*/ | ||
async start(options = {}) { | ||
assert(!this._enabled, 'CSSCoverage is already enabled'); | ||
const {resetOnNavigation = true} = options; | ||
this._resetOnNavigation = resetOnNavigation; | ||
this._enabled = true; | ||
this._stylesheetURLs.clear(); | ||
this._stylesheetSources.clear(); | ||
this._eventListeners = [ | ||
helper.addEventListener(this._client, 'CSS.styleSheetAdded', this._onStyleSheet.bind(this)), | ||
helper.addEventListener(this._client, 'Runtime.executionContextsCleared', this._onExecutionContextsCleared.bind(this)), | ||
]; | ||
await Promise.all([ | ||
this._client.send('DOM.enable'), | ||
this._client.send('CSS.enable'), | ||
this._client.send('CSS.startRuleUsageTracking'), | ||
]); | ||
} | ||
_onExecutionContextsCleared() { | ||
if (!this._resetOnNavigation) | ||
return; | ||
this._stylesheetURLs.clear(); | ||
this._stylesheetSources.clear(); | ||
} | ||
/** | ||
* @param {!Protocol.CSS.styleSheetAddedPayload} event | ||
*/ | ||
async _onStyleSheet(event) { | ||
const header = event.header; | ||
// Ignore anonymous scripts | ||
if (!header.sourceURL) | ||
return; | ||
try { | ||
const response = await this._client.send('CSS.getStyleSheetText', {styleSheetId: header.styleSheetId}); | ||
this._stylesheetURLs.set(header.styleSheetId, header.sourceURL); | ||
this._stylesheetSources.set(header.styleSheetId, response.text); | ||
} catch (e) { | ||
// This might happen if the page has already navigated away. | ||
debugError(e); | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
*/ | ||
constructor(client) { | ||
this._client = client; | ||
this._enabled = false; | ||
this._stylesheetURLs = new Map(); | ||
this._stylesheetSources = new Map(); | ||
this._eventListeners = []; | ||
this._resetOnNavigation = false; | ||
} | ||
} | ||
/** | ||
* @return {!Promise<!Array<!CoverageEntry>>} | ||
*/ | ||
async stop() { | ||
assert(this._enabled, 'CSSCoverage is not enabled'); | ||
this._enabled = false; | ||
const ruleTrackingResponse = await this._client.send('CSS.stopRuleUsageTracking'); | ||
await Promise.all([ | ||
this._client.send('CSS.disable'), | ||
this._client.send('DOM.disable'), | ||
]); | ||
helper.removeEventListeners(this._eventListeners); | ||
// aggregate by styleSheetId | ||
const styleSheetIdToCoverage = new Map(); | ||
for (const entry of ruleTrackingResponse.ruleUsage) { | ||
let ranges = styleSheetIdToCoverage.get(entry.styleSheetId); | ||
if (!ranges) { | ||
ranges = []; | ||
styleSheetIdToCoverage.set(entry.styleSheetId, ranges); | ||
} | ||
ranges.push({ | ||
startOffset: entry.startOffset, | ||
endOffset: entry.endOffset, | ||
count: entry.used ? 1 : 0, | ||
}); | ||
/** | ||
* @param {{resetOnNavigation?: boolean}=} options | ||
*/ | ||
async start(options = {}) { | ||
assert(!this._enabled, 'CSSCoverage is already enabled'); | ||
const { resetOnNavigation = true } = options; | ||
this._resetOnNavigation = resetOnNavigation; | ||
this._enabled = true; | ||
this._stylesheetURLs.clear(); | ||
this._stylesheetSources.clear(); | ||
this._eventListeners = [ | ||
helper.addEventListener(this._client, 'CSS.styleSheetAdded', this._onStyleSheet.bind(this)), | ||
helper.addEventListener(this._client, 'Runtime.executionContextsCleared', this._onExecutionContextsCleared.bind(this)), | ||
]; | ||
await Promise.all([ | ||
this._client.send('DOM.enable'), | ||
this._client.send('CSS.enable'), | ||
this._client.send('CSS.startRuleUsageTracking'), | ||
]); | ||
} | ||
const coverage = []; | ||
for (const styleSheetId of this._stylesheetURLs.keys()) { | ||
const url = this._stylesheetURLs.get(styleSheetId); | ||
const text = this._stylesheetSources.get(styleSheetId); | ||
const ranges = convertToDisjointRanges(styleSheetIdToCoverage.get(styleSheetId) || []); | ||
coverage.push({url, ranges, text}); | ||
_onExecutionContextsCleared() { | ||
if (!this._resetOnNavigation) | ||
return; | ||
this._stylesheetURLs.clear(); | ||
this._stylesheetSources.clear(); | ||
} | ||
return coverage; | ||
} | ||
/** | ||
* @param {!Protocol.CSS.styleSheetAddedPayload} event | ||
*/ | ||
async _onStyleSheet(event) { | ||
const header = event.header; | ||
// Ignore anonymous scripts | ||
if (!header.sourceURL) | ||
return; | ||
try { | ||
const response = await this._client.send('CSS.getStyleSheetText', { styleSheetId: header.styleSheetId }); | ||
this._stylesheetURLs.set(header.styleSheetId, header.sourceURL); | ||
this._stylesheetSources.set(header.styleSheetId, response.text); | ||
} | ||
catch (e) { | ||
// This might happen if the page has already navigated away. | ||
debugError(e); | ||
} | ||
} | ||
/** | ||
* @return {!Promise<!Array<!CoverageEntry>>} | ||
*/ | ||
async stop() { | ||
assert(this._enabled, 'CSSCoverage is not enabled'); | ||
this._enabled = false; | ||
const ruleTrackingResponse = await this._client.send('CSS.stopRuleUsageTracking'); | ||
await Promise.all([ | ||
this._client.send('CSS.disable'), | ||
this._client.send('DOM.disable'), | ||
]); | ||
helper.removeEventListeners(this._eventListeners); | ||
// aggregate by styleSheetId | ||
const styleSheetIdToCoverage = new Map(); | ||
for (const entry of ruleTrackingResponse.ruleUsage) { | ||
let ranges = styleSheetIdToCoverage.get(entry.styleSheetId); | ||
if (!ranges) { | ||
ranges = []; | ||
styleSheetIdToCoverage.set(entry.styleSheetId, ranges); | ||
} | ||
ranges.push({ | ||
startOffset: entry.startOffset, | ||
endOffset: entry.endOffset, | ||
count: entry.used ? 1 : 0, | ||
}); | ||
} | ||
const coverage = []; | ||
for (const styleSheetId of this._stylesheetURLs.keys()) { | ||
const url = this._stylesheetURLs.get(styleSheetId); | ||
const text = this._stylesheetSources.get(styleSheetId); | ||
const ranges = convertToDisjointRanges(styleSheetIdToCoverage.get(styleSheetId) || []); | ||
coverage.push({ url, ranges, text }); | ||
} | ||
return coverage; | ||
} | ||
} | ||
/** | ||
@@ -272,45 +248,43 @@ * @param {!Array<!{startOffset:number, endOffset:number, count:number}>} nestedRanges | ||
function convertToDisjointRanges(nestedRanges) { | ||
const points = []; | ||
for (const range of nestedRanges) { | ||
points.push({ offset: range.startOffset, type: 0, range }); | ||
points.push({ offset: range.endOffset, type: 1, range }); | ||
} | ||
// Sort points to form a valid parenthesis sequence. | ||
points.sort((a, b) => { | ||
// Sort with increasing offsets. | ||
if (a.offset !== b.offset) | ||
return a.offset - b.offset; | ||
// All "end" points should go before "start" points. | ||
if (a.type !== b.type) | ||
return b.type - a.type; | ||
const aLength = a.range.endOffset - a.range.startOffset; | ||
const bLength = b.range.endOffset - b.range.startOffset; | ||
// For two "start" points, the one with longer range goes first. | ||
if (a.type === 0) | ||
return bLength - aLength; | ||
// For two "end" points, the one with shorter range goes first. | ||
return aLength - bLength; | ||
}); | ||
const hitCountStack = []; | ||
const results = []; | ||
let lastOffset = 0; | ||
// Run scanning line to intersect all ranges. | ||
for (const point of points) { | ||
if (hitCountStack.length && lastOffset < point.offset && hitCountStack[hitCountStack.length - 1] > 0) { | ||
const lastResult = results.length ? results[results.length - 1] : null; | ||
if (lastResult && lastResult.end === lastOffset) | ||
lastResult.end = point.offset; | ||
else | ||
results.push({start: lastOffset, end: point.offset}); | ||
const points = []; | ||
for (const range of nestedRanges) { | ||
points.push({ offset: range.startOffset, type: 0, range }); | ||
points.push({ offset: range.endOffset, type: 1, range }); | ||
} | ||
lastOffset = point.offset; | ||
if (point.type === 0) | ||
hitCountStack.push(point.range.count); | ||
else | ||
hitCountStack.pop(); | ||
} | ||
// Filter out empty ranges. | ||
return results.filter(range => range.end - range.start > 1); | ||
// Sort points to form a valid parenthesis sequence. | ||
points.sort((a, b) => { | ||
// Sort with increasing offsets. | ||
if (a.offset !== b.offset) | ||
return a.offset - b.offset; | ||
// All "end" points should go before "start" points. | ||
if (a.type !== b.type) | ||
return b.type - a.type; | ||
const aLength = a.range.endOffset - a.range.startOffset; | ||
const bLength = b.range.endOffset - b.range.startOffset; | ||
// For two "start" points, the one with longer range goes first. | ||
if (a.type === 0) | ||
return bLength - aLength; | ||
// For two "end" points, the one with shorter range goes first. | ||
return aLength - bLength; | ||
}); | ||
const hitCountStack = []; | ||
const results = []; | ||
let lastOffset = 0; | ||
// Run scanning line to intersect all ranges. | ||
for (const point of points) { | ||
if (hitCountStack.length && lastOffset < point.offset && hitCountStack[hitCountStack.length - 1] > 0) { | ||
const lastResult = results.length ? results[results.length - 1] : null; | ||
if (lastResult && lastResult.end === lastOffset) | ||
lastResult.end = point.offset; | ||
else | ||
results.push({ start: lastOffset, end: point.offset }); | ||
} | ||
lastOffset = point.offset; | ||
if (point.type === 0) | ||
hitCountStack.push(point.range.count); | ||
else | ||
hitCountStack.pop(); | ||
} | ||
// Filter out empty ranges. | ||
return results.filter(range => range.end - range.start > 1); | ||
} | ||
@@ -0,1 +1,2 @@ | ||
"use strict"; | ||
/** | ||
@@ -16,858 +17,859 @@ * Copyright 2017 Google Inc. All rights reserved. | ||
*/ | ||
module.exports = [ | ||
{ | ||
'name': 'Blackberry PlayBook', | ||
'userAgent': 'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+', | ||
'viewport': { | ||
'width': 600, | ||
'height': 1024, | ||
'deviceScaleFactor': 1, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
const devices = [ | ||
{ | ||
'name': 'Blackberry PlayBook', | ||
'userAgent': 'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+', | ||
'viewport': { | ||
'width': 600, | ||
'height': 1024, | ||
'deviceScaleFactor': 1, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Blackberry PlayBook landscape', | ||
'userAgent': 'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+', | ||
'viewport': { | ||
'width': 1024, | ||
'height': 600, | ||
'deviceScaleFactor': 1, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'BlackBerry Z30', | ||
'userAgent': 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+', | ||
'viewport': { | ||
'width': 360, | ||
'height': 640, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'BlackBerry Z30 landscape', | ||
'userAgent': 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+', | ||
'viewport': { | ||
'width': 640, | ||
'height': 360, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Galaxy Note 3', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', | ||
'viewport': { | ||
'width': 360, | ||
'height': 640, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Galaxy Note 3 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', | ||
'viewport': { | ||
'width': 640, | ||
'height': 360, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Galaxy Note II', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', | ||
'viewport': { | ||
'width': 360, | ||
'height': 640, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Galaxy Note II landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', | ||
'viewport': { | ||
'width': 640, | ||
'height': 360, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Galaxy S III', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', | ||
'viewport': { | ||
'width': 360, | ||
'height': 640, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Galaxy S III landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', | ||
'viewport': { | ||
'width': 640, | ||
'height': 360, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Galaxy S5', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 360, | ||
'height': 640, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Galaxy S5 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 640, | ||
'height': 360, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPad', | ||
'userAgent': 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', | ||
'viewport': { | ||
'width': 768, | ||
'height': 1024, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPad landscape', | ||
'userAgent': 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', | ||
'viewport': { | ||
'width': 1024, | ||
'height': 768, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPad Mini', | ||
'userAgent': 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', | ||
'viewport': { | ||
'width': 768, | ||
'height': 1024, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPad Mini landscape', | ||
'userAgent': 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', | ||
'viewport': { | ||
'width': 1024, | ||
'height': 768, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPad Pro', | ||
'userAgent': 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', | ||
'viewport': { | ||
'width': 1024, | ||
'height': 1366, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPad Pro landscape', | ||
'userAgent': 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', | ||
'viewport': { | ||
'width': 1366, | ||
'height': 1024, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 4', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257 Safari/9537.53', | ||
'viewport': { | ||
'width': 320, | ||
'height': 480, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 4 landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257 Safari/9537.53', | ||
'viewport': { | ||
'width': 480, | ||
'height': 320, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 5', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', | ||
'viewport': { | ||
'width': 320, | ||
'height': 568, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 5 landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', | ||
'viewport': { | ||
'width': 568, | ||
'height': 320, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 6', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 375, | ||
'height': 667, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 6 landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 667, | ||
'height': 375, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 6 Plus', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 414, | ||
'height': 736, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 6 Plus landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 736, | ||
'height': 414, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 7', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 375, | ||
'height': 667, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 7 landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 667, | ||
'height': 375, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 7 Plus', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 414, | ||
'height': 736, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 7 Plus landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 736, | ||
'height': 414, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 8', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 375, | ||
'height': 667, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 8 landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 667, | ||
'height': 375, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 8 Plus', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 414, | ||
'height': 736, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 8 Plus landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 736, | ||
'height': 414, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone SE', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', | ||
'viewport': { | ||
'width': 320, | ||
'height': 568, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone SE landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', | ||
'viewport': { | ||
'width': 568, | ||
'height': 320, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone X', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 375, | ||
'height': 812, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone X landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 812, | ||
'height': 375, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone XR', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1', | ||
'viewport': { | ||
'width': 414, | ||
'height': 896, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone XR landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1', | ||
'viewport': { | ||
'width': 896, | ||
'height': 414, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'JioPhone 2', | ||
'userAgent': 'Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5', | ||
'viewport': { | ||
'width': 240, | ||
'height': 320, | ||
'deviceScaleFactor': 1, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'JioPhone 2 landscape', | ||
'userAgent': 'Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5', | ||
'viewport': { | ||
'width': 320, | ||
'height': 240, | ||
'deviceScaleFactor': 1, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Kindle Fire HDX', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true', | ||
'viewport': { | ||
'width': 800, | ||
'height': 1280, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Kindle Fire HDX landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true', | ||
'viewport': { | ||
'width': 1280, | ||
'height': 800, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'LG Optimus L70', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 384, | ||
'height': 640, | ||
'deviceScaleFactor': 1.25, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'LG Optimus L70 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 640, | ||
'height': 384, | ||
'deviceScaleFactor': 1.25, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Microsoft Lumia 550', | ||
'userAgent': 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', | ||
'viewport': { | ||
'width': 640, | ||
'height': 360, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Microsoft Lumia 950', | ||
'userAgent': 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', | ||
'viewport': { | ||
'width': 360, | ||
'height': 640, | ||
'deviceScaleFactor': 4, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Microsoft Lumia 950 landscape', | ||
'userAgent': 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', | ||
'viewport': { | ||
'width': 640, | ||
'height': 360, | ||
'deviceScaleFactor': 4, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 10', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', | ||
'viewport': { | ||
'width': 800, | ||
'height': 1280, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 10 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', | ||
'viewport': { | ||
'width': 1280, | ||
'height': 800, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 4', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 384, | ||
'height': 640, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 4 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 640, | ||
'height': 384, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 5', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 360, | ||
'height': 640, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 5 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 640, | ||
'height': 360, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 5X', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 412, | ||
'height': 732, | ||
'deviceScaleFactor': 2.625, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 5X landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 732, | ||
'height': 412, | ||
'deviceScaleFactor': 2.625, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 6', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 412, | ||
'height': 732, | ||
'deviceScaleFactor': 3.5, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 6 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 732, | ||
'height': 412, | ||
'deviceScaleFactor': 3.5, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 6P', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 412, | ||
'height': 732, | ||
'deviceScaleFactor': 3.5, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 6P landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 732, | ||
'height': 412, | ||
'deviceScaleFactor': 3.5, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 7', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', | ||
'viewport': { | ||
'width': 600, | ||
'height': 960, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 7 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', | ||
'viewport': { | ||
'width': 960, | ||
'height': 600, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nokia Lumia 520', | ||
'userAgent': 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)', | ||
'viewport': { | ||
'width': 320, | ||
'height': 533, | ||
'deviceScaleFactor': 1.5, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nokia Lumia 520 landscape', | ||
'userAgent': 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)', | ||
'viewport': { | ||
'width': 533, | ||
'height': 320, | ||
'deviceScaleFactor': 1.5, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nokia N9', | ||
'userAgent': 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13', | ||
'viewport': { | ||
'width': 480, | ||
'height': 854, | ||
'deviceScaleFactor': 1, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nokia N9 landscape', | ||
'userAgent': 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13', | ||
'viewport': { | ||
'width': 854, | ||
'height': 480, | ||
'deviceScaleFactor': 1, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Pixel 2', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 411, | ||
'height': 731, | ||
'deviceScaleFactor': 2.625, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Pixel 2 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 731, | ||
'height': 411, | ||
'deviceScaleFactor': 2.625, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Pixel 2 XL', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 411, | ||
'height': 823, | ||
'deviceScaleFactor': 3.5, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Pixel 2 XL landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 823, | ||
'height': 411, | ||
'deviceScaleFactor': 3.5, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
} | ||
}, | ||
{ | ||
'name': 'Blackberry PlayBook landscape', | ||
'userAgent': 'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+', | ||
'viewport': { | ||
'width': 1024, | ||
'height': 600, | ||
'deviceScaleFactor': 1, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'BlackBerry Z30', | ||
'userAgent': 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+', | ||
'viewport': { | ||
'width': 360, | ||
'height': 640, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'BlackBerry Z30 landscape', | ||
'userAgent': 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+', | ||
'viewport': { | ||
'width': 640, | ||
'height': 360, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Galaxy Note 3', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', | ||
'viewport': { | ||
'width': 360, | ||
'height': 640, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Galaxy Note 3 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', | ||
'viewport': { | ||
'width': 640, | ||
'height': 360, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Galaxy Note II', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', | ||
'viewport': { | ||
'width': 360, | ||
'height': 640, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Galaxy Note II landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', | ||
'viewport': { | ||
'width': 640, | ||
'height': 360, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Galaxy S III', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', | ||
'viewport': { | ||
'width': 360, | ||
'height': 640, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Galaxy S III landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30', | ||
'viewport': { | ||
'width': 640, | ||
'height': 360, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Galaxy S5', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 360, | ||
'height': 640, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Galaxy S5 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 5.0; SM-G900P Build/LRX21T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 640, | ||
'height': 360, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPad', | ||
'userAgent': 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', | ||
'viewport': { | ||
'width': 768, | ||
'height': 1024, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPad landscape', | ||
'userAgent': 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', | ||
'viewport': { | ||
'width': 1024, | ||
'height': 768, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPad Mini', | ||
'userAgent': 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', | ||
'viewport': { | ||
'width': 768, | ||
'height': 1024, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPad Mini landscape', | ||
'userAgent': 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', | ||
'viewport': { | ||
'width': 1024, | ||
'height': 768, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPad Pro', | ||
'userAgent': 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', | ||
'viewport': { | ||
'width': 1024, | ||
'height': 1366, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPad Pro landscape', | ||
'userAgent': 'Mozilla/5.0 (iPad; CPU OS 11_0 like Mac OS X) AppleWebKit/604.1.34 (KHTML, like Gecko) Version/11.0 Mobile/15A5341f Safari/604.1', | ||
'viewport': { | ||
'width': 1366, | ||
'height': 1024, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 4', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257 Safari/9537.53', | ||
'viewport': { | ||
'width': 320, | ||
'height': 480, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 4 landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_2 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D257 Safari/9537.53', | ||
'viewport': { | ||
'width': 480, | ||
'height': 320, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 5', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', | ||
'viewport': { | ||
'width': 320, | ||
'height': 568, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 5 landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', | ||
'viewport': { | ||
'width': 568, | ||
'height': 320, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 6', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 375, | ||
'height': 667, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 6 landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 667, | ||
'height': 375, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 6 Plus', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 414, | ||
'height': 736, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 6 Plus landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 736, | ||
'height': 414, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 7', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 375, | ||
'height': 667, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 7 landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 667, | ||
'height': 375, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 7 Plus', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 414, | ||
'height': 736, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 7 Plus landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 736, | ||
'height': 414, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 8', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 375, | ||
'height': 667, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 8 landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 667, | ||
'height': 375, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 8 Plus', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 414, | ||
'height': 736, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone 8 Plus landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 736, | ||
'height': 414, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone SE', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', | ||
'viewport': { | ||
'width': 320, | ||
'height': 568, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone SE landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1', | ||
'viewport': { | ||
'width': 568, | ||
'height': 320, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone X', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 375, | ||
'height': 812, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone X landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', | ||
'viewport': { | ||
'width': 812, | ||
'height': 375, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone XR', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1', | ||
'viewport': { | ||
'width': 414, | ||
'height': 896, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'iPhone XR landscape', | ||
'userAgent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1', | ||
'viewport': { | ||
'width': 896, | ||
'height': 414, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'JioPhone 2', | ||
'userAgent': 'Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5', | ||
'viewport': { | ||
'width': 240, | ||
'height': 320, | ||
'deviceScaleFactor': 1, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'JioPhone 2 landscape', | ||
'userAgent': 'Mozilla/5.0 (Mobile; LYF/F300B/LYF-F300B-001-01-15-130718-i;Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5', | ||
'viewport': { | ||
'width': 320, | ||
'height': 240, | ||
'deviceScaleFactor': 1, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Kindle Fire HDX', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true', | ||
'viewport': { | ||
'width': 800, | ||
'height': 1280, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Kindle Fire HDX landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true', | ||
'viewport': { | ||
'width': 1280, | ||
'height': 800, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'LG Optimus L70', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 384, | ||
'height': 640, | ||
'deviceScaleFactor': 1.25, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'LG Optimus L70 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 640, | ||
'height': 384, | ||
'deviceScaleFactor': 1.25, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Microsoft Lumia 550', | ||
'userAgent': 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 550) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', | ||
'viewport': { | ||
'width': 640, | ||
'height': 360, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Microsoft Lumia 950', | ||
'userAgent': 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', | ||
'viewport': { | ||
'width': 360, | ||
'height': 640, | ||
'deviceScaleFactor': 4, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Microsoft Lumia 950 landscape', | ||
'userAgent': 'Mozilla/5.0 (Windows Phone 10.0; Android 4.2.1; Microsoft; Lumia 950) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/14.14263', | ||
'viewport': { | ||
'width': 640, | ||
'height': 360, | ||
'deviceScaleFactor': 4, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 10', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', | ||
'viewport': { | ||
'width': 800, | ||
'height': 1280, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 10 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 10 Build/MOB31T) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', | ||
'viewport': { | ||
'width': 1280, | ||
'height': 800, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 4', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 384, | ||
'height': 640, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 4 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 640, | ||
'height': 384, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 5', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 360, | ||
'height': 640, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 5 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 640, | ||
'height': 360, | ||
'deviceScaleFactor': 3, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 5X', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 412, | ||
'height': 732, | ||
'deviceScaleFactor': 2.625, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 5X landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 5X Build/OPR4.170623.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 732, | ||
'height': 412, | ||
'deviceScaleFactor': 2.625, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 6', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 412, | ||
'height': 732, | ||
'deviceScaleFactor': 3.5, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 6 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 7.1.1; Nexus 6 Build/N6F26U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 732, | ||
'height': 412, | ||
'deviceScaleFactor': 3.5, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 6P', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 412, | ||
'height': 732, | ||
'deviceScaleFactor': 3.5, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 6P landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 8.0.0; Nexus 6P Build/OPP3.170518.006) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 732, | ||
'height': 412, | ||
'deviceScaleFactor': 3.5, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 7', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', | ||
'viewport': { | ||
'width': 600, | ||
'height': 960, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nexus 7 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Safari/537.36', | ||
'viewport': { | ||
'width': 960, | ||
'height': 600, | ||
'deviceScaleFactor': 2, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nokia Lumia 520', | ||
'userAgent': 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)', | ||
'viewport': { | ||
'width': 320, | ||
'height': 533, | ||
'deviceScaleFactor': 1.5, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nokia Lumia 520 landscape', | ||
'userAgent': 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)', | ||
'viewport': { | ||
'width': 533, | ||
'height': 320, | ||
'deviceScaleFactor': 1.5, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Nokia N9', | ||
'userAgent': 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13', | ||
'viewport': { | ||
'width': 480, | ||
'height': 854, | ||
'deviceScaleFactor': 1, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Nokia N9 landscape', | ||
'userAgent': 'Mozilla/5.0 (MeeGo; NokiaN9) AppleWebKit/534.13 (KHTML, like Gecko) NokiaBrowser/8.5.0 Mobile Safari/534.13', | ||
'viewport': { | ||
'width': 854, | ||
'height': 480, | ||
'deviceScaleFactor': 1, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Pixel 2', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 411, | ||
'height': 731, | ||
'deviceScaleFactor': 2.625, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Pixel 2 landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 8.0; Pixel 2 Build/OPD3.170816.012) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 731, | ||
'height': 411, | ||
'deviceScaleFactor': 2.625, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
}, | ||
{ | ||
'name': 'Pixel 2 XL', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 411, | ||
'height': 823, | ||
'deviceScaleFactor': 3.5, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': false | ||
} | ||
}, | ||
{ | ||
'name': 'Pixel 2 XL landscape', | ||
'userAgent': 'Mozilla/5.0 (Linux; Android 8.0.0; Pixel 2 XL Build/OPD1.170816.004) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3765.0 Mobile Safari/537.36', | ||
'viewport': { | ||
'width': 823, | ||
'height': 411, | ||
'deviceScaleFactor': 3.5, | ||
'isMobile': true, | ||
'hasTouch': true, | ||
'isLandscape': true | ||
} | ||
} | ||
]; | ||
for (const device of module.exports) | ||
module.exports[device.name] = device; | ||
const devicesMap = {}; | ||
for (const device of devices) | ||
devicesMap[device.name] = device; | ||
module.exports = devicesMap; |
@@ -16,69 +16,60 @@ /** | ||
*/ | ||
const {assert} = require('./helper'); | ||
const { assert } = require('./helper'); | ||
class Dialog { | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {string} type | ||
* @param {string} message | ||
* @param {(string|undefined)} defaultValue | ||
*/ | ||
constructor(client, type, message, defaultValue = '') { | ||
this._client = client; | ||
this._type = type; | ||
this._message = message; | ||
this._handled = false; | ||
this._defaultValue = defaultValue; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
type() { | ||
return this._type; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
message() { | ||
return this._message; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
defaultValue() { | ||
return this._defaultValue; | ||
} | ||
/** | ||
* @param {string=} promptText | ||
*/ | ||
async accept(promptText) { | ||
assert(!this._handled, 'Cannot accept dialog which is already handled!'); | ||
this._handled = true; | ||
await this._client.send('Page.handleJavaScriptDialog', { | ||
accept: true, | ||
promptText: promptText | ||
}); | ||
} | ||
async dismiss() { | ||
assert(!this._handled, 'Cannot dismiss dialog which is already handled!'); | ||
this._handled = true; | ||
await this._client.send('Page.handleJavaScriptDialog', { | ||
accept: false | ||
}); | ||
} | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {string} type | ||
* @param {string} message | ||
* @param {(string|undefined)} defaultValue | ||
*/ | ||
constructor(client, type, message, defaultValue = '') { | ||
this._client = client; | ||
this._type = type; | ||
this._message = message; | ||
this._handled = false; | ||
this._defaultValue = defaultValue; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
type() { | ||
return this._type; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
message() { | ||
return this._message; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
defaultValue() { | ||
return this._defaultValue; | ||
} | ||
/** | ||
* @param {string=} promptText | ||
*/ | ||
async accept(promptText) { | ||
assert(!this._handled, 'Cannot accept dialog which is already handled!'); | ||
this._handled = true; | ||
await this._client.send('Page.handleJavaScriptDialog', { | ||
accept: true, | ||
promptText: promptText | ||
}); | ||
} | ||
async dismiss() { | ||
assert(!this._handled, 'Cannot dismiss dialog which is already handled!'); | ||
this._handled = true; | ||
await this._client.send('Page.handleJavaScriptDialog', { | ||
accept: false | ||
}); | ||
} | ||
} | ||
Dialog.Type = { | ||
Alert: 'alert', | ||
BeforeUnload: 'beforeunload', | ||
Confirm: 'confirm', | ||
Prompt: 'prompt' | ||
Alert: 'alert', | ||
BeforeUnload: 'beforeunload', | ||
Confirm: 'confirm', | ||
Prompt: 'prompt' | ||
}; | ||
module.exports = {Dialog}; | ||
module.exports = { Dialog }; |
1207
lib/DOMWorld.js
@@ -16,9 +16,7 @@ /** | ||
*/ | ||
const fs = require('fs'); | ||
const {helper, assert} = require('./helper'); | ||
const {LifecycleWatcher} = require('./LifecycleWatcher'); | ||
const {TimeoutError} = require('./Errors'); | ||
const { helper, assert } = require('./helper'); | ||
const { LifecycleWatcher } = require('./LifecycleWatcher'); | ||
const { TimeoutError } = require('./Errors'); | ||
const readFileAsync = helper.promisify(fs.readFile); | ||
/** | ||
@@ -28,589 +26,519 @@ * @unrestricted | ||
class DOMWorld { | ||
/** | ||
* @param {!Puppeteer.FrameManager} frameManager | ||
* @param {!Puppeteer.Frame} frame | ||
* @param {!Puppeteer.TimeoutSettings} timeoutSettings | ||
*/ | ||
constructor(frameManager, frame, timeoutSettings) { | ||
this._frameManager = frameManager; | ||
this._frame = frame; | ||
this._timeoutSettings = timeoutSettings; | ||
/** @type {?Promise<!Puppeteer.ElementHandle>} */ | ||
this._documentPromise = null; | ||
/** @type {!Promise<!Puppeteer.ExecutionContext>} */ | ||
this._contextPromise; | ||
this._contextResolveCallback = null; | ||
this._setContext(null); | ||
/** @type {!Set<!WaitTask>} */ | ||
this._waitTasks = new Set(); | ||
this._detached = false; | ||
} | ||
/** | ||
* @return {!Puppeteer.Frame} | ||
*/ | ||
frame() { | ||
return this._frame; | ||
} | ||
/** | ||
* @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; | ||
}); | ||
/** | ||
* @param {!Puppeteer.FrameManager} frameManager | ||
* @param {!Puppeteer.Frame} frame | ||
* @param {!Puppeteer.TimeoutSettings} timeoutSettings | ||
*/ | ||
constructor(frameManager, frame, timeoutSettings) { | ||
this._frameManager = frameManager; | ||
this._frame = frame; | ||
this._timeoutSettings = timeoutSettings; | ||
/** @type {?Promise<!Puppeteer.ElementHandle>} */ | ||
this._documentPromise = null; | ||
/** @type {!Promise<!Puppeteer.ExecutionContext>} */ | ||
this._contextPromise; | ||
this._contextResolveCallback = null; | ||
this._setContext(null); | ||
/** @type {!Set<!WaitTask>} */ | ||
this._waitTasks = new Set(); | ||
this._detached = false; | ||
} | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
_hasContext() { | ||
return !this._contextResolveCallback; | ||
} | ||
_detach() { | ||
this._detached = true; | ||
for (const waitTask of this._waitTasks) | ||
waitTask.terminate(new Error('waitForFunction failed: frame got detached.')); | ||
} | ||
/** | ||
* @return {!Promise<!Puppeteer.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 {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
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<?Puppeteer.ElementHandle>} | ||
*/ | ||
async $(selector) { | ||
const document = await this._document(); | ||
const value = await document.$(selector); | ||
return value; | ||
} | ||
/** | ||
* @return {!Promise<!Puppeteer.ElementHandle>} | ||
*/ | ||
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<!Array<!Puppeteer.ElementHandle>>} | ||
*/ | ||
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<!Array<!Puppeteer.ElementHandle>>} | ||
*/ | ||
async $$(selector) { | ||
const document = await this._document(); | ||
const value = await document.$$(selector); | ||
return value; | ||
} | ||
/** | ||
* @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 | ||
* @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<!Puppeteer.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`); | ||
} | ||
/** | ||
* @return {!Puppeteer.Frame} | ||
*/ | ||
frame() { | ||
return this._frame; | ||
} | ||
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(); | ||
/** | ||
* @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; | ||
}); | ||
} | ||
} | ||
if (content !== null) { | ||
const context = await this.executionContext(); | ||
return (await context.evaluateHandle(addScriptContent, content, type)).asElement(); | ||
/** | ||
* @return {boolean} | ||
*/ | ||
_hasContext() { | ||
return !this._contextResolveCallback; | ||
} | ||
throw new Error('Provide an object with a `url`, `path` or `content` property'); | ||
_detach() { | ||
this._detached = true; | ||
for (const waitTask of this._waitTasks) | ||
waitTask.terminate(new Error('waitForFunction failed: frame got detached.')); | ||
} | ||
/** | ||
* @param {string} url | ||
* @param {string} type | ||
* @return {!Promise<!HTMLElement>} | ||
* @return {!Promise<!Puppeteer.ExecutionContext>} | ||
*/ | ||
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; | ||
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} content | ||
* @param {string} type | ||
* @return {!HTMLElement} | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
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; | ||
async evaluateHandle(pageFunction, ...args) { | ||
const context = await this.executionContext(); | ||
return context.evaluateHandle(pageFunction, ...args); | ||
} | ||
} | ||
/** | ||
* @param {!{url?: string, path?: string, content?: string}} options | ||
* @return {!Promise<!Puppeteer.ElementHandle>} | ||
*/ | ||
async addStyleTag(options) { | ||
const { | ||
url = null, | ||
path = null, | ||
content = null | ||
} = options; | ||
if (url !== null) { | ||
try { | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<*>} | ||
*/ | ||
async evaluate(pageFunction, ...args) { | ||
const context = await this.executionContext(); | ||
return (await context.evaluateHandle(addStyleUrl, url)).asElement(); | ||
} catch (error) { | ||
throw new Error(`Loading style from ${url} failed`); | ||
} | ||
return context.evaluate(pageFunction, ...args); | ||
} | ||
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(); | ||
/** | ||
* @param {string} selector | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
async $(selector) { | ||
const document = await this._document(); | ||
const value = await document.$(selector); | ||
return value; | ||
} | ||
if (content !== null) { | ||
const context = await this.executionContext(); | ||
return (await context.evaluateHandle(addStyleContent, content)).asElement(); | ||
/** | ||
* @return {!Promise<!Puppeteer.ElementHandle>} | ||
*/ | ||
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; | ||
} | ||
throw new Error('Provide an object with a `url`, `path` or `content` property'); | ||
/** | ||
* @param {string} url | ||
* @return {!Promise<!HTMLElement>} | ||
* @param {string} expression | ||
* @return {!Promise<!Array<!Puppeteer.ElementHandle>>} | ||
*/ | ||
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; | ||
async $x(expression) { | ||
const document = await this._document(); | ||
const value = await document.$x(expression); | ||
return value; | ||
} | ||
/** | ||
* @param {string} content | ||
* @return {!Promise<!HTMLElement>} | ||
* @param {string} selector | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<(!Object|undefined)>} | ||
*/ | ||
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; | ||
async $eval(selector, pageFunction, ...args) { | ||
const document = await this._document(); | ||
return document.$eval(selector, pageFunction, ...args); | ||
} | ||
} | ||
/** | ||
* @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<string>} values | ||
* @return {!Promise<!Array<string>>} | ||
*/ | ||
async select(selector, ...values) { | ||
const handle = await this.$(selector); | ||
assert(handle, 'No node found for selector: ' + selector); | ||
const result = await handle.select(...values); | ||
await handle.dispose(); | ||
return result; | ||
} | ||
/** | ||
* @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(); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
waitForSelector(selector, options) { | ||
return this._waitForSelectorOrXPath(selector, false, options); | ||
} | ||
/** | ||
* @param {string} xpath | ||
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
waitForXPath(xpath, options) { | ||
return this._waitForSelectorOrXPath(xpath, true, options); | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!{polling?: string|number, timeout?: number}=} options | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
waitForFunction(pageFunction, options = {}, ...args) { | ||
const { | ||
polling = 'raf', | ||
timeout = this._timeoutSettings.timeout(), | ||
} = options; | ||
return new WaitTask(this, pageFunction, 'function', polling, timeout, ...args).promise; | ||
} | ||
/** | ||
* @return {!Promise<string>} | ||
*/ | ||
async title() { | ||
return this.evaluate(() => document.title); | ||
} | ||
/** | ||
* @param {string} selectorOrXPath | ||
* @param {boolean} isXPath | ||
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options | ||
* @return {!Promise<?Puppeteer.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; | ||
/** | ||
* @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; | ||
} | ||
return handle.asElement(); | ||
/** | ||
* @param {string} selector | ||
* @return {!Promise<!Array<!Puppeteer.ElementHandle>>} | ||
*/ | ||
async $$(selector) { | ||
const document = await this._document(); | ||
const value = await document.$$(selector); | ||
return value; | ||
} | ||
/** | ||
* @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 | ||
* @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<!Puppeteer.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 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<!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 {!{url?: string, path?: string, content?: string}} options | ||
* @return {!Promise<!Puppeteer.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 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<!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; | ||
} | ||
} | ||
/** | ||
* @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<string>} values | ||
* @return {!Promise<!Array<string>>} | ||
*/ | ||
async select(selector, ...values) { | ||
const handle = await this.$(selector); | ||
assert(handle, 'No node found for selector: ' + selector); | ||
const result = await handle.select(...values); | ||
await handle.dispose(); | ||
return result; | ||
} | ||
/** | ||
* @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(); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
waitForSelector(selector, options) { | ||
return this._waitForSelectorOrXPath(selector, false, options); | ||
} | ||
/** | ||
* @param {string} xpath | ||
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
waitForXPath(xpath, options) { | ||
return this._waitForSelectorOrXPath(xpath, true, options); | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!{polling?: string|number, timeout?: number}=} options | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
waitForFunction(pageFunction, options = {}, ...args) { | ||
const { polling = 'raf', timeout = this._timeoutSettings.timeout(), } = options; | ||
return new WaitTask(this, pageFunction, 'function', polling, timeout, ...args).promise; | ||
} | ||
/** | ||
* @return {!Promise<string>} | ||
*/ | ||
async title() { | ||
return this.evaluate(() => document.title); | ||
} | ||
/** | ||
* @param {string} selectorOrXPath | ||
* @param {boolean} isXPath | ||
* @param {boolean} waitForVisible | ||
* @param {boolean} waitForHidden | ||
* @return {?Node|boolean} | ||
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
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); | ||
} | ||
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); | ||
} | ||
} | ||
} | ||
} | ||
} | ||
class WaitTask { | ||
/** | ||
* @param {!DOMWorld} domWorld | ||
* @param {Function|string} predicateBody | ||
* @param {string|number} polling | ||
* @param {number} timeout | ||
* @param {!Array<*>} args | ||
*/ | ||
constructor(domWorld, 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._domWorld = domWorld; | ||
this._polling = polling; | ||
this._timeout = timeout; | ||
this._predicateBody = helper.isString(predicateBody) ? 'return (' + predicateBody + ')' : 'return (' + predicateBody + ')(...args)'; | ||
this._args = args; | ||
this._runCount = 0; | ||
domWorld._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); | ||
/** | ||
* @param {!DOMWorld} domWorld | ||
* @param {Function|string} predicateBody | ||
* @param {string|number} polling | ||
* @param {number} timeout | ||
* @param {!Array<*>} args | ||
*/ | ||
constructor(domWorld, 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._domWorld = domWorld; | ||
this._polling = polling; | ||
this._timeout = timeout; | ||
this._predicateBody = helper.isString(predicateBody) ? 'return (' + predicateBody + ')' : 'return (' + predicateBody + ')(...args)'; | ||
this._args = args; | ||
this._runCount = 0; | ||
domWorld._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(); | ||
} | ||
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._domWorld.executionContext()).evaluateHandle(waitForPredicatePageFunction, this._predicateBody, this._polling, this._timeout, ...this._args); | ||
} catch (e) { | ||
error = e; | ||
/** | ||
* @param {!Error} error | ||
*/ | ||
terminate(error) { | ||
this._terminated = true; | ||
this._reject(error); | ||
this._cleanup(); | ||
} | ||
if (this._terminated || runCount !== this._runCount) { | ||
if (success) | ||
await success.dispose(); | ||
return; | ||
async rerun() { | ||
const runCount = ++this._runCount; | ||
/** @type {?Puppeteer.JSHandle} */ | ||
let success = null; | ||
let error = null; | ||
try { | ||
success = await (await this._domWorld.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._domWorld.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(); | ||
} | ||
// 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._domWorld.evaluate(s => !s, success).catch(e => true)) { | ||
await success.dispose(); | ||
return; | ||
_cleanup() { | ||
clearTimeout(this._timeoutTimer); | ||
this._domWorld._waitTasks.delete(this); | ||
this._runningTask = null; | ||
} | ||
// 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._domWorld._waitTasks.delete(this); | ||
this._runningTask = null; | ||
} | ||
} | ||
/** | ||
@@ -623,88 +551,81 @@ * @param {string} predicateBody | ||
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); | ||
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; | ||
} | ||
} | ||
/** | ||
* @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); | ||
/** | ||
* @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); | ||
} | ||
} | ||
} | ||
module.exports = {DOMWorld}; | ||
module.exports = { DOMWorld }; |
@@ -16,40 +16,35 @@ /** | ||
*/ | ||
class EmulationManager { | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
*/ | ||
constructor(client) { | ||
this._client = client; | ||
this._emulatingMobile = false; | ||
this._hasTouch = false; | ||
} | ||
/** | ||
* @param {!Puppeteer.Viewport} viewport | ||
* @return {Promise<boolean>} | ||
*/ | ||
async emulateViewport(viewport) { | ||
const mobile = viewport.isMobile || false; | ||
const width = viewport.width; | ||
const height = viewport.height; | ||
const deviceScaleFactor = viewport.deviceScaleFactor || 1; | ||
/** @type {Protocol.Emulation.ScreenOrientation} */ | ||
const screenOrientation = viewport.isLandscape ? { angle: 90, type: 'landscapePrimary' } : { angle: 0, type: 'portraitPrimary' }; | ||
const hasTouch = viewport.hasTouch || false; | ||
await Promise.all([ | ||
this._client.send('Emulation.setDeviceMetricsOverride', { mobile, width, height, deviceScaleFactor, screenOrientation }), | ||
this._client.send('Emulation.setTouchEmulationEnabled', { | ||
enabled: hasTouch | ||
}) | ||
]); | ||
const reloadNeeded = this._emulatingMobile !== mobile || this._hasTouch !== hasTouch; | ||
this._emulatingMobile = mobile; | ||
this._hasTouch = hasTouch; | ||
return reloadNeeded; | ||
} | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
*/ | ||
constructor(client) { | ||
this._client = client; | ||
this._emulatingMobile = false; | ||
this._hasTouch = false; | ||
} | ||
/** | ||
* @param {!Puppeteer.Viewport} viewport | ||
* @return {Promise<boolean>} | ||
*/ | ||
async emulateViewport(viewport) { | ||
const mobile = viewport.isMobile || false; | ||
const width = viewport.width; | ||
const height = viewport.height; | ||
const deviceScaleFactor = viewport.deviceScaleFactor || 1; | ||
/** @type {Protocol.Emulation.ScreenOrientation} */ | ||
const screenOrientation = viewport.isLandscape ? { angle: 90, type: 'landscapePrimary' } : { angle: 0, type: 'portraitPrimary' }; | ||
const hasTouch = viewport.hasTouch || false; | ||
await Promise.all([ | ||
this._client.send('Emulation.setDeviceMetricsOverride', { mobile, width, height, deviceScaleFactor, screenOrientation }), | ||
this._client.send('Emulation.setTouchEmulationEnabled', { | ||
enabled: hasTouch | ||
}) | ||
]); | ||
const reloadNeeded = this._emulatingMobile !== mobile || this._hasTouch !== hasTouch; | ||
this._emulatingMobile = mobile; | ||
this._hasTouch = hasTouch; | ||
return reloadNeeded; | ||
} | ||
} | ||
module.exports = {EmulationManager}; | ||
module.exports = { EmulationManager }; |
@@ -16,15 +16,13 @@ /** | ||
*/ | ||
class CustomError extends Error { | ||
constructor(message) { | ||
super(message); | ||
this.name = this.constructor.name; | ||
Error.captureStackTrace(this, this.constructor); | ||
} | ||
constructor(message) { | ||
super(message); | ||
this.name = this.constructor.name; | ||
Error.captureStackTrace(this, this.constructor); | ||
} | ||
} | ||
class TimeoutError extends CustomError {} | ||
class TimeoutError extends CustomError { | ||
} | ||
module.exports = { | ||
TimeoutError, | ||
TimeoutError, | ||
}; |
@@ -16,66 +16,58 @@ /** | ||
*/ | ||
const Events = { | ||
Page: { | ||
Close: 'close', | ||
Console: 'console', | ||
Dialog: 'dialog', | ||
DOMContentLoaded: 'domcontentloaded', | ||
Error: 'error', | ||
// Can't use just 'error' due to node.js special treatment of error events. | ||
// @see https://nodejs.org/api/events.html#events_error_events | ||
PageError: 'pageerror', | ||
Request: 'request', | ||
Response: 'response', | ||
RequestFailed: 'requestfailed', | ||
RequestFinished: 'requestfinished', | ||
FrameAttached: 'frameattached', | ||
FrameDetached: 'framedetached', | ||
FrameNavigated: 'framenavigated', | ||
Load: 'load', | ||
Metrics: 'metrics', | ||
Popup: 'popup', | ||
WorkerCreated: 'workercreated', | ||
WorkerDestroyed: 'workerdestroyed', | ||
}, | ||
Browser: { | ||
TargetCreated: 'targetcreated', | ||
TargetDestroyed: 'targetdestroyed', | ||
TargetChanged: 'targetchanged', | ||
Disconnected: 'disconnected' | ||
}, | ||
BrowserContext: { | ||
TargetCreated: 'targetcreated', | ||
TargetDestroyed: 'targetdestroyed', | ||
TargetChanged: 'targetchanged', | ||
}, | ||
NetworkManager: { | ||
Request: Symbol('Events.NetworkManager.Request'), | ||
Response: Symbol('Events.NetworkManager.Response'), | ||
RequestFailed: Symbol('Events.NetworkManager.RequestFailed'), | ||
RequestFinished: Symbol('Events.NetworkManager.RequestFinished'), | ||
}, | ||
FrameManager: { | ||
FrameAttached: Symbol('Events.FrameManager.FrameAttached'), | ||
FrameNavigated: Symbol('Events.FrameManager.FrameNavigated'), | ||
FrameDetached: Symbol('Events.FrameManager.FrameDetached'), | ||
LifecycleEvent: Symbol('Events.FrameManager.LifecycleEvent'), | ||
FrameNavigatedWithinDocument: Symbol('Events.FrameManager.FrameNavigatedWithinDocument'), | ||
ExecutionContextCreated: Symbol('Events.FrameManager.ExecutionContextCreated'), | ||
ExecutionContextDestroyed: Symbol('Events.FrameManager.ExecutionContextDestroyed'), | ||
}, | ||
Connection: { | ||
Disconnected: Symbol('Events.Connection.Disconnected'), | ||
}, | ||
CDPSession: { | ||
Disconnected: Symbol('Events.CDPSession.Disconnected'), | ||
}, | ||
Page: { | ||
Close: 'close', | ||
Console: 'console', | ||
Dialog: 'dialog', | ||
DOMContentLoaded: 'domcontentloaded', | ||
Error: 'error', | ||
// Can't use just 'error' due to node.js special treatment of error events. | ||
// @see https://nodejs.org/api/events.html#events_error_events | ||
PageError: 'pageerror', | ||
Request: 'request', | ||
Response: 'response', | ||
RequestFailed: 'requestfailed', | ||
RequestFinished: 'requestfinished', | ||
FrameAttached: 'frameattached', | ||
FrameDetached: 'framedetached', | ||
FrameNavigated: 'framenavigated', | ||
Load: 'load', | ||
Metrics: 'metrics', | ||
Popup: 'popup', | ||
WorkerCreated: 'workercreated', | ||
WorkerDestroyed: 'workerdestroyed', | ||
}, | ||
Browser: { | ||
TargetCreated: 'targetcreated', | ||
TargetDestroyed: 'targetdestroyed', | ||
TargetChanged: 'targetchanged', | ||
Disconnected: 'disconnected' | ||
}, | ||
BrowserContext: { | ||
TargetCreated: 'targetcreated', | ||
TargetDestroyed: 'targetdestroyed', | ||
TargetChanged: 'targetchanged', | ||
}, | ||
NetworkManager: { | ||
Request: Symbol('Events.NetworkManager.Request'), | ||
Response: Symbol('Events.NetworkManager.Response'), | ||
RequestFailed: Symbol('Events.NetworkManager.RequestFailed'), | ||
RequestFinished: Symbol('Events.NetworkManager.RequestFinished'), | ||
}, | ||
FrameManager: { | ||
FrameAttached: Symbol('Events.FrameManager.FrameAttached'), | ||
FrameNavigated: Symbol('Events.FrameManager.FrameNavigated'), | ||
FrameDetached: Symbol('Events.FrameManager.FrameDetached'), | ||
LifecycleEvent: Symbol('Events.FrameManager.LifecycleEvent'), | ||
FrameNavigatedWithinDocument: Symbol('Events.FrameManager.FrameNavigatedWithinDocument'), | ||
ExecutionContextCreated: Symbol('Events.FrameManager.ExecutionContextCreated'), | ||
ExecutionContextDestroyed: Symbol('Events.FrameManager.ExecutionContextDestroyed'), | ||
}, | ||
Connection: { | ||
Disconnected: Symbol('Events.Connection.Disconnected'), | ||
}, | ||
CDPSession: { | ||
Disconnected: Symbol('Events.CDPSession.Disconnected'), | ||
}, | ||
}; | ||
module.exports = { Events }; |
@@ -16,197 +16,183 @@ /** | ||
*/ | ||
const {helper, assert} = require('./helper'); | ||
const {createJSHandle, JSHandle} = require('./JSHandle'); | ||
const { helper, assert } = require('./helper'); | ||
const { createJSHandle, JSHandle } = require('./JSHandle'); | ||
const EVALUATION_SCRIPT_URL = '__puppeteer_evaluation_script__'; | ||
const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m; | ||
class ExecutionContext { | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Protocol.Runtime.ExecutionContextDescription} contextPayload | ||
* @param {?Puppeteer.DOMWorld} world | ||
*/ | ||
constructor(client, contextPayload, world) { | ||
this._client = client; | ||
this._world = world; | ||
this._contextId = contextPayload.id; | ||
} | ||
/** | ||
* @return {?Puppeteer.Frame} | ||
*/ | ||
frame() { | ||
return this._world ? this._world.frame() : null; | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {...*} args | ||
* @return {!Promise<*>} | ||
*/ | ||
async evaluate(pageFunction, ...args) { | ||
return await this._evaluateInternal(true /* returnByValue */, pageFunction, ...args); | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {...*} args | ||
* @return {!Promise<!JSHandle>} | ||
*/ | ||
async evaluateHandle(pageFunction, ...args) { | ||
return this._evaluateInternal(false /* returnByValue */, pageFunction, ...args); | ||
} | ||
/** | ||
* @param {boolean} returnByValue | ||
* @param {Function|string} pageFunction | ||
* @param {...*} args | ||
* @return {!Promise<*>} | ||
*/ | ||
async _evaluateInternal(returnByValue, pageFunction, ...args) { | ||
const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`; | ||
if (helper.isString(pageFunction)) { | ||
const contextId = this._contextId; | ||
const expression = /** @type {string} */ (pageFunction); | ||
const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression) ? expression : expression + '\n' + suffix; | ||
const {exceptionDetails, result: remoteObject} = await this._client.send('Runtime.evaluate', { | ||
expression: expressionWithSourceUrl, | ||
contextId, | ||
returnByValue, | ||
awaitPromise: true, | ||
userGesture: true | ||
}).catch(rewriteError); | ||
if (exceptionDetails) | ||
throw new Error('Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails)); | ||
return returnByValue ? helper.valueFromRemoteObject(remoteObject) : createJSHandle(this, remoteObject); | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Protocol.Runtime.ExecutionContextDescription} contextPayload | ||
* @param {?Puppeteer.DOMWorld} world | ||
*/ | ||
constructor(client, contextPayload, world) { | ||
this._client = client; | ||
this._world = world; | ||
this._contextId = contextPayload.id; | ||
} | ||
if (typeof pageFunction !== 'function') | ||
throw new Error(`Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.`); | ||
let functionText = pageFunction.toString(); | ||
try { | ||
new Function('(' + functionText + ')'); | ||
} catch (e1) { | ||
// This means we might have a function shorthand. Try another | ||
// time prefixing 'function '. | ||
if (functionText.startsWith('async ')) | ||
functionText = 'async function ' + functionText.substring('async '.length); | ||
else | ||
functionText = 'function ' + functionText; | ||
try { | ||
new Function('(' + functionText + ')'); | ||
} catch (e2) { | ||
// We tried hard to serialize, but there's a weird beast here. | ||
throw new Error('Passed function is not well-serializable!'); | ||
} | ||
/** | ||
* @return {?Puppeteer.Frame} | ||
*/ | ||
frame() { | ||
return this._world ? this._world.frame() : null; | ||
} | ||
let callFunctionOnPromise; | ||
try { | ||
callFunctionOnPromise = this._client.send('Runtime.callFunctionOn', { | ||
functionDeclaration: functionText + '\n' + suffix + '\n', | ||
executionContextId: this._contextId, | ||
arguments: args.map(convertArgument.bind(this)), | ||
returnByValue, | ||
awaitPromise: true, | ||
userGesture: true | ||
}); | ||
} catch (err) { | ||
if (err instanceof TypeError && err.message.startsWith('Converting circular structure to JSON')) | ||
err.message += ' Are you passing a nested JSHandle?'; | ||
throw err; | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {...*} args | ||
* @return {!Promise<*>} | ||
*/ | ||
async evaluate(pageFunction, ...args) { | ||
return await this._evaluateInternal(true /* returnByValue */, pageFunction, ...args); | ||
} | ||
const { exceptionDetails, result: remoteObject } = await callFunctionOnPromise.catch(rewriteError); | ||
if (exceptionDetails) | ||
throw new Error('Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails)); | ||
return returnByValue ? helper.valueFromRemoteObject(remoteObject) : createJSHandle(this, remoteObject); | ||
/** | ||
* @param {*} arg | ||
* @return {*} | ||
* @this {ExecutionContext} | ||
* @param {Function|string} pageFunction | ||
* @param {...*} args | ||
* @return {!Promise<!JSHandle>} | ||
*/ | ||
function convertArgument(arg) { | ||
if (typeof arg === 'bigint') // eslint-disable-line valid-typeof | ||
return { unserializableValue: `${arg.toString()}n` }; | ||
if (Object.is(arg, -0)) | ||
return { unserializableValue: '-0' }; | ||
if (Object.is(arg, Infinity)) | ||
return { unserializableValue: 'Infinity' }; | ||
if (Object.is(arg, -Infinity)) | ||
return { unserializableValue: '-Infinity' }; | ||
if (Object.is(arg, NaN)) | ||
return { unserializableValue: 'NaN' }; | ||
const objectHandle = arg && (arg instanceof JSHandle) ? arg : null; | ||
if (objectHandle) { | ||
if (objectHandle._context !== this) | ||
throw new Error('JSHandles can be evaluated only in the context they were created!'); | ||
if (objectHandle._disposed) | ||
throw new Error('JSHandle is disposed!'); | ||
if (objectHandle._remoteObject.unserializableValue) | ||
return { unserializableValue: objectHandle._remoteObject.unserializableValue }; | ||
if (!objectHandle._remoteObject.objectId) | ||
return { value: objectHandle._remoteObject.value }; | ||
return { objectId: objectHandle._remoteObject.objectId }; | ||
} | ||
return { value: arg }; | ||
async evaluateHandle(pageFunction, ...args) { | ||
return this._evaluateInternal(false /* returnByValue */, pageFunction, ...args); | ||
} | ||
/** | ||
* @param {!Error} error | ||
* @return {!Protocol.Runtime.evaluateReturnValue} | ||
* @param {boolean} returnByValue | ||
* @param {Function|string} pageFunction | ||
* @param {...*} args | ||
* @return {!Promise<*>} | ||
*/ | ||
function rewriteError(error) { | ||
if (error.message.includes('Object reference chain is too long')) | ||
return {result: {type: 'undefined'}}; | ||
if (error.message.includes('Object couldn\'t be returned by value')) | ||
return {result: {type: 'undefined'}}; | ||
if (error.message.endsWith('Cannot find context with specified id') || error.message.endsWith('Inspected target navigated or closed')) | ||
throw new Error('Execution context was destroyed, most likely because of a navigation.'); | ||
throw error; | ||
async _evaluateInternal(returnByValue, pageFunction, ...args) { | ||
const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`; | ||
if (helper.isString(pageFunction)) { | ||
const contextId = this._contextId; | ||
const expression = /** @type {string} */ (pageFunction); | ||
const expressionWithSourceUrl = SOURCE_URL_REGEX.test(expression) ? expression : expression + '\n' + suffix; | ||
const { exceptionDetails, result: remoteObject } = await this._client.send('Runtime.evaluate', { | ||
expression: expressionWithSourceUrl, | ||
contextId, | ||
returnByValue, | ||
awaitPromise: true, | ||
userGesture: true | ||
}).catch(rewriteError); | ||
if (exceptionDetails) | ||
throw new Error('Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails)); | ||
return returnByValue ? helper.valueFromRemoteObject(remoteObject) : createJSHandle(this, remoteObject); | ||
} | ||
if (typeof pageFunction !== 'function') | ||
throw new Error(`Expected to get |string| or |function| as the first argument, but got "${pageFunction}" instead.`); | ||
let functionText = pageFunction.toString(); | ||
try { | ||
new Function('(' + functionText + ')'); | ||
} | ||
catch (e1) { | ||
// This means we might have a function shorthand. Try another | ||
// time prefixing 'function '. | ||
if (functionText.startsWith('async ')) | ||
functionText = 'async function ' + functionText.substring('async '.length); | ||
else | ||
functionText = 'function ' + functionText; | ||
try { | ||
new Function('(' + functionText + ')'); | ||
} | ||
catch (e2) { | ||
// We tried hard to serialize, but there's a weird beast here. | ||
throw new Error('Passed function is not well-serializable!'); | ||
} | ||
} | ||
let callFunctionOnPromise; | ||
try { | ||
callFunctionOnPromise = this._client.send('Runtime.callFunctionOn', { | ||
functionDeclaration: functionText + '\n' + suffix + '\n', | ||
executionContextId: this._contextId, | ||
arguments: args.map(convertArgument.bind(this)), | ||
returnByValue, | ||
awaitPromise: true, | ||
userGesture: true | ||
}); | ||
} | ||
catch (err) { | ||
if (err instanceof TypeError && err.message.startsWith('Converting circular structure to JSON')) | ||
err.message += ' Are you passing a nested JSHandle?'; | ||
throw err; | ||
} | ||
const { exceptionDetails, result: remoteObject } = await callFunctionOnPromise.catch(rewriteError); | ||
if (exceptionDetails) | ||
throw new Error('Evaluation failed: ' + helper.getExceptionMessage(exceptionDetails)); | ||
return returnByValue ? helper.valueFromRemoteObject(remoteObject) : createJSHandle(this, remoteObject); | ||
/** | ||
* @param {*} arg | ||
* @return {*} | ||
* @this {ExecutionContext} | ||
*/ | ||
function convertArgument(arg) { | ||
if (typeof arg === 'bigint') // eslint-disable-line valid-typeof | ||
return { unserializableValue: `${arg.toString()}n` }; | ||
if (Object.is(arg, -0)) | ||
return { unserializableValue: '-0' }; | ||
if (Object.is(arg, Infinity)) | ||
return { unserializableValue: 'Infinity' }; | ||
if (Object.is(arg, -Infinity)) | ||
return { unserializableValue: '-Infinity' }; | ||
if (Object.is(arg, NaN)) | ||
return { unserializableValue: 'NaN' }; | ||
const objectHandle = arg && (arg instanceof JSHandle) ? arg : null; | ||
if (objectHandle) { | ||
if (objectHandle._context !== this) | ||
throw new Error('JSHandles can be evaluated only in the context they were created!'); | ||
if (objectHandle._disposed) | ||
throw new Error('JSHandle is disposed!'); | ||
if (objectHandle._remoteObject.unserializableValue) | ||
return { unserializableValue: objectHandle._remoteObject.unserializableValue }; | ||
if (!objectHandle._remoteObject.objectId) | ||
return { value: objectHandle._remoteObject.value }; | ||
return { objectId: objectHandle._remoteObject.objectId }; | ||
} | ||
return { value: arg }; | ||
} | ||
/** | ||
* @param {!Error} error | ||
* @return {!Protocol.Runtime.evaluateReturnValue} | ||
*/ | ||
function rewriteError(error) { | ||
if (error.message.includes('Object reference chain is too long')) | ||
return { result: { type: 'undefined' } }; | ||
if (error.message.includes('Object couldn\'t be returned by value')) | ||
return { result: { type: 'undefined' } }; | ||
if (error.message.endsWith('Cannot find context with specified id') || error.message.endsWith('Inspected target navigated or closed')) | ||
throw new Error('Execution context was destroyed, most likely because of a navigation.'); | ||
throw error; | ||
} | ||
} | ||
} | ||
/** | ||
* @param {!JSHandle} prototypeHandle | ||
* @return {!Promise<!JSHandle>} | ||
*/ | ||
async queryObjects(prototypeHandle) { | ||
assert(!prototypeHandle._disposed, 'Prototype JSHandle is disposed!'); | ||
assert(prototypeHandle._remoteObject.objectId, 'Prototype JSHandle must not be referencing primitive value'); | ||
const response = await this._client.send('Runtime.queryObjects', { | ||
prototypeObjectId: prototypeHandle._remoteObject.objectId | ||
}); | ||
return createJSHandle(this, response.objects); | ||
} | ||
/** | ||
* @param {Protocol.DOM.BackendNodeId} backendNodeId | ||
* @return {Promise<Puppeteer.ElementHandle>} | ||
*/ | ||
async _adoptBackendNodeId(backendNodeId) { | ||
const {object} = await this._client.send('DOM.resolveNode', { | ||
backendNodeId: backendNodeId, | ||
executionContextId: this._contextId, | ||
}); | ||
return /** @type {Puppeteer.ElementHandle}*/(createJSHandle(this, object)); | ||
} | ||
/** | ||
* @param {Puppeteer.ElementHandle} elementHandle | ||
* @return {Promise<Puppeteer.ElementHandle>} | ||
*/ | ||
async _adoptElementHandle(elementHandle) { | ||
assert(elementHandle.executionContext() !== this, 'Cannot adopt handle that already belongs to this execution context'); | ||
assert(this._world, 'Cannot adopt handle without DOMWorld'); | ||
const nodeInfo = await this._client.send('DOM.describeNode', { | ||
objectId: elementHandle._remoteObject.objectId, | ||
}); | ||
return this._adoptBackendNodeId(nodeInfo.node.backendNodeId); | ||
} | ||
/** | ||
* @param {!JSHandle} prototypeHandle | ||
* @return {!Promise<!JSHandle>} | ||
*/ | ||
async queryObjects(prototypeHandle) { | ||
assert(!prototypeHandle._disposed, 'Prototype JSHandle is disposed!'); | ||
assert(prototypeHandle._remoteObject.objectId, 'Prototype JSHandle must not be referencing primitive value'); | ||
const response = await this._client.send('Runtime.queryObjects', { | ||
prototypeObjectId: prototypeHandle._remoteObject.objectId | ||
}); | ||
return createJSHandle(this, response.objects); | ||
} | ||
/** | ||
* @param {Protocol.DOM.BackendNodeId} backendNodeId | ||
* @return {Promise<Puppeteer.ElementHandle>} | ||
*/ | ||
async _adoptBackendNodeId(backendNodeId) { | ||
const { object } = await this._client.send('DOM.resolveNode', { | ||
backendNodeId: backendNodeId, | ||
executionContextId: this._contextId, | ||
}); | ||
return /** @type {Puppeteer.ElementHandle}*/ (createJSHandle(this, object)); | ||
} | ||
/** | ||
* @param {Puppeteer.ElementHandle} elementHandle | ||
* @return {Promise<Puppeteer.ElementHandle>} | ||
*/ | ||
async _adoptElementHandle(elementHandle) { | ||
assert(elementHandle.executionContext() !== this, 'Cannot adopt handle that already belongs to this execution context'); | ||
assert(this._world, 'Cannot adopt handle without DOMWorld'); | ||
const nodeInfo = await this._client.send('DOM.describeNode', { | ||
objectId: elementHandle._remoteObject.objectId, | ||
}); | ||
return this._adoptBackendNodeId(nodeInfo.node.backendNodeId); | ||
} | ||
} | ||
module.exports = {ExecutionContext, EVALUATION_SCRIPT_URL}; | ||
module.exports = { ExecutionContext, EVALUATION_SCRIPT_URL }; |
@@ -16,354 +16,318 @@ /** | ||
*/ | ||
const EventEmitter = require('events'); | ||
const {helper, assert, debugError} = require('./helper'); | ||
const {Events} = require('./Events'); | ||
const {ExecutionContext, EVALUATION_SCRIPT_URL} = require('./ExecutionContext'); | ||
const {LifecycleWatcher} = require('./LifecycleWatcher'); | ||
const {DOMWorld} = require('./DOMWorld'); | ||
const {NetworkManager} = require('./NetworkManager'); | ||
const { helper, assert, debugError } = require('./helper'); | ||
const { Events } = require('./Events'); | ||
const { ExecutionContext, EVALUATION_SCRIPT_URL } = require('./ExecutionContext'); | ||
const { LifecycleWatcher } = require('./LifecycleWatcher'); | ||
const { DOMWorld } = require('./DOMWorld'); | ||
const { NetworkManager } = require('./NetworkManager'); | ||
const UTILITY_WORLD_NAME = '__puppeteer_utility_world__'; | ||
class FrameManager extends EventEmitter { | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Puppeteer.Page} page | ||
* @param {boolean} ignoreHTTPSErrors | ||
* @param {!Puppeteer.TimeoutSettings} timeoutSettings | ||
*/ | ||
constructor(client, page, ignoreHTTPSErrors, timeoutSettings) { | ||
super(); | ||
this._client = client; | ||
this._page = page; | ||
this._networkManager = new NetworkManager(client, ignoreHTTPSErrors, this); | ||
this._timeoutSettings = timeoutSettings; | ||
/** @type {!Map<string, !Frame>} */ | ||
this._frames = new Map(); | ||
/** @type {!Map<number, !ExecutionContext>} */ | ||
this._contextIdToContext = new Map(); | ||
/** @type {!Set<string>} */ | ||
this._isolatedWorlds = new Set(); | ||
this._client.on('Page.frameAttached', event => this._onFrameAttached(event.frameId, event.parentFrameId)); | ||
this._client.on('Page.frameNavigated', event => this._onFrameNavigated(event.frame)); | ||
this._client.on('Page.navigatedWithinDocument', event => this._onFrameNavigatedWithinDocument(event.frameId, event.url)); | ||
this._client.on('Page.frameDetached', event => this._onFrameDetached(event.frameId)); | ||
this._client.on('Page.frameStoppedLoading', event => this._onFrameStoppedLoading(event.frameId)); | ||
this._client.on('Runtime.executionContextCreated', event => this._onExecutionContextCreated(event.context)); | ||
this._client.on('Runtime.executionContextDestroyed', event => this._onExecutionContextDestroyed(event.executionContextId)); | ||
this._client.on('Runtime.executionContextsCleared', event => this._onExecutionContextsCleared()); | ||
this._client.on('Page.lifecycleEvent', event => this._onLifecycleEvent(event)); | ||
} | ||
async initialize() { | ||
const [,{frameTree}] = await Promise.all([ | ||
this._client.send('Page.enable'), | ||
this._client.send('Page.getFrameTree'), | ||
]); | ||
this._handleFrameTree(frameTree); | ||
await Promise.all([ | ||
this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }), | ||
this._client.send('Runtime.enable', {}).then(() => this._ensureIsolatedWorld(UTILITY_WORLD_NAME)), | ||
this._networkManager.initialize(), | ||
]); | ||
} | ||
/** | ||
* @return {!NetworkManager} | ||
*/ | ||
networkManager() { | ||
return this._networkManager; | ||
} | ||
/** | ||
* @param {!Puppeteer.Frame} frame | ||
* @param {string} url | ||
* @param {!{referer?: string, timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async navigateFrame(frame, url, options = {}) { | ||
assertNoLegacyNavigationOptions(options); | ||
const { | ||
referer = this._networkManager.extraHTTPHeaders()['referer'], | ||
waitUntil = ['load'], | ||
timeout = this._timeoutSettings.navigationTimeout(), | ||
} = options; | ||
const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout); | ||
let ensureNewDocumentNavigation = false; | ||
let error = await Promise.race([ | ||
navigate(this._client, url, referer, frame._id), | ||
watcher.timeoutOrTerminationPromise(), | ||
]); | ||
if (!error) { | ||
error = await Promise.race([ | ||
watcher.timeoutOrTerminationPromise(), | ||
ensureNewDocumentNavigation ? watcher.newDocumentNavigationPromise() : watcher.sameDocumentNavigationPromise(), | ||
]); | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Puppeteer.Page} page | ||
* @param {boolean} ignoreHTTPSErrors | ||
* @param {!Puppeteer.TimeoutSettings} timeoutSettings | ||
*/ | ||
constructor(client, page, ignoreHTTPSErrors, timeoutSettings) { | ||
super(); | ||
this._client = client; | ||
this._page = page; | ||
this._networkManager = new NetworkManager(client, ignoreHTTPSErrors, this); | ||
this._timeoutSettings = timeoutSettings; | ||
/** @type {!Map<string, !Frame>} */ | ||
this._frames = new Map(); | ||
/** @type {!Map<number, !ExecutionContext>} */ | ||
this._contextIdToContext = new Map(); | ||
/** @type {!Set<string>} */ | ||
this._isolatedWorlds = new Set(); | ||
this._client.on('Page.frameAttached', event => this._onFrameAttached(event.frameId, event.parentFrameId)); | ||
this._client.on('Page.frameNavigated', event => this._onFrameNavigated(event.frame)); | ||
this._client.on('Page.navigatedWithinDocument', event => this._onFrameNavigatedWithinDocument(event.frameId, event.url)); | ||
this._client.on('Page.frameDetached', event => this._onFrameDetached(event.frameId)); | ||
this._client.on('Page.frameStoppedLoading', event => this._onFrameStoppedLoading(event.frameId)); | ||
this._client.on('Runtime.executionContextCreated', event => this._onExecutionContextCreated(event.context)); | ||
this._client.on('Runtime.executionContextDestroyed', event => this._onExecutionContextDestroyed(event.executionContextId)); | ||
this._client.on('Runtime.executionContextsCleared', event => this._onExecutionContextsCleared()); | ||
this._client.on('Page.lifecycleEvent', event => this._onLifecycleEvent(event)); | ||
} | ||
watcher.dispose(); | ||
if (error) | ||
throw error; | ||
return watcher.navigationResponse(); | ||
async initialize() { | ||
const result = await Promise.all([ | ||
this._client.send('Page.enable'), | ||
this._client.send('Page.getFrameTree'), | ||
]); | ||
const { frameTree } = /** @type Protocol.Page.getFrameTreeReturnValue*/ (result[1]); | ||
this._handleFrameTree(frameTree); | ||
await Promise.all([ | ||
this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }), | ||
this._client.send('Runtime.enable', {}).then(() => this._ensureIsolatedWorld(UTILITY_WORLD_NAME)), | ||
this._networkManager.initialize(), | ||
]); | ||
} | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @return {!NetworkManager} | ||
*/ | ||
networkManager() { | ||
return this._networkManager; | ||
} | ||
/** | ||
* @param {!Puppeteer.Frame} frame | ||
* @param {string} url | ||
* @param {string} referrer | ||
* @param {!{referer?: string, timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async navigateFrame(frame, url, options = {}) { | ||
assertNoLegacyNavigationOptions(options); | ||
const { referer = this._networkManager.extraHTTPHeaders()['referer'], waitUntil = ['load'], timeout = this._timeoutSettings.navigationTimeout(), } = options; | ||
const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout); | ||
let ensureNewDocumentNavigation = false; | ||
let error = await Promise.race([ | ||
navigate(this._client, url, referer, frame._id), | ||
watcher.timeoutOrTerminationPromise(), | ||
]); | ||
if (!error) { | ||
error = await Promise.race([ | ||
watcher.timeoutOrTerminationPromise(), | ||
ensureNewDocumentNavigation ? watcher.newDocumentNavigationPromise() : watcher.sameDocumentNavigationPromise(), | ||
]); | ||
} | ||
watcher.dispose(); | ||
if (error) | ||
throw error; | ||
return watcher.navigationResponse(); | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {string} url | ||
* @param {string} referrer | ||
* @param {string} frameId | ||
* @return {!Promise<?Error>} | ||
*/ | ||
async function navigate(client, url, referrer, frameId) { | ||
try { | ||
const response = await client.send('Page.navigate', { url, referrer, frameId }); | ||
ensureNewDocumentNavigation = !!response.loaderId; | ||
return response.errorText ? new Error(`${response.errorText} at ${url}`) : null; | ||
} | ||
catch (error) { | ||
return error; | ||
} | ||
} | ||
} | ||
/** | ||
* @param {!Puppeteer.Frame} frame | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async waitForFrameNavigation(frame, options = {}) { | ||
assertNoLegacyNavigationOptions(options); | ||
const { waitUntil = ['load'], timeout = this._timeoutSettings.navigationTimeout(), } = options; | ||
const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout); | ||
const error = await Promise.race([ | ||
watcher.timeoutOrTerminationPromise(), | ||
watcher.sameDocumentNavigationPromise(), | ||
watcher.newDocumentNavigationPromise() | ||
]); | ||
watcher.dispose(); | ||
if (error) | ||
throw error; | ||
return watcher.navigationResponse(); | ||
} | ||
/** | ||
* @param {!Protocol.Page.lifecycleEventPayload} event | ||
*/ | ||
_onLifecycleEvent(event) { | ||
const frame = this._frames.get(event.frameId); | ||
if (!frame) | ||
return; | ||
frame._onLifecycleEvent(event.loaderId, event.name); | ||
this.emit(Events.FrameManager.LifecycleEvent, frame); | ||
} | ||
/** | ||
* @param {string} frameId | ||
* @return {!Promise<?Error>} | ||
*/ | ||
async function navigate(client, url, referrer, frameId) { | ||
try { | ||
const response = await client.send('Page.navigate', {url, referrer, frameId}); | ||
ensureNewDocumentNavigation = !!response.loaderId; | ||
return response.errorText ? new Error(`${response.errorText} at ${url}`) : null; | ||
} catch (error) { | ||
return error; | ||
} | ||
_onFrameStoppedLoading(frameId) { | ||
const frame = this._frames.get(frameId); | ||
if (!frame) | ||
return; | ||
frame._onLoadingStopped(); | ||
this.emit(Events.FrameManager.LifecycleEvent, frame); | ||
} | ||
} | ||
/** | ||
* @param {!Puppeteer.Frame} frame | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async waitForFrameNavigation(frame, options = {}) { | ||
assertNoLegacyNavigationOptions(options); | ||
const { | ||
waitUntil = ['load'], | ||
timeout = this._timeoutSettings.navigationTimeout(), | ||
} = options; | ||
const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout); | ||
const error = await Promise.race([ | ||
watcher.timeoutOrTerminationPromise(), | ||
watcher.sameDocumentNavigationPromise(), | ||
watcher.newDocumentNavigationPromise() | ||
]); | ||
watcher.dispose(); | ||
if (error) | ||
throw error; | ||
return watcher.navigationResponse(); | ||
} | ||
/** | ||
* @param {!Protocol.Page.lifecycleEventPayload} event | ||
*/ | ||
_onLifecycleEvent(event) { | ||
const frame = this._frames.get(event.frameId); | ||
if (!frame) | ||
return; | ||
frame._onLifecycleEvent(event.loaderId, event.name); | ||
this.emit(Events.FrameManager.LifecycleEvent, frame); | ||
} | ||
/** | ||
* @param {string} frameId | ||
*/ | ||
_onFrameStoppedLoading(frameId) { | ||
const frame = this._frames.get(frameId); | ||
if (!frame) | ||
return; | ||
frame._onLoadingStopped(); | ||
this.emit(Events.FrameManager.LifecycleEvent, frame); | ||
} | ||
/** | ||
* @param {!Protocol.Page.FrameTree} frameTree | ||
*/ | ||
_handleFrameTree(frameTree) { | ||
if (frameTree.frame.parentId) | ||
this._onFrameAttached(frameTree.frame.id, frameTree.frame.parentId); | ||
this._onFrameNavigated(frameTree.frame); | ||
if (!frameTree.childFrames) | ||
return; | ||
for (const child of frameTree.childFrames) | ||
this._handleFrameTree(child); | ||
} | ||
/** | ||
* @return {!Puppeteer.Page} | ||
*/ | ||
page() { | ||
return this._page; | ||
} | ||
/** | ||
* @return {!Frame} | ||
*/ | ||
mainFrame() { | ||
return this._mainFrame; | ||
} | ||
/** | ||
* @return {!Array<!Frame>} | ||
*/ | ||
frames() { | ||
return Array.from(this._frames.values()); | ||
} | ||
/** | ||
* @param {!string} frameId | ||
* @return {?Frame} | ||
*/ | ||
frame(frameId) { | ||
return this._frames.get(frameId) || null; | ||
} | ||
/** | ||
* @param {string} frameId | ||
* @param {?string} parentFrameId | ||
*/ | ||
_onFrameAttached(frameId, parentFrameId) { | ||
if (this._frames.has(frameId)) | ||
return; | ||
assert(parentFrameId); | ||
const parentFrame = this._frames.get(parentFrameId); | ||
const frame = new Frame(this, this._client, parentFrame, frameId); | ||
this._frames.set(frame._id, frame); | ||
this.emit(Events.FrameManager.FrameAttached, frame); | ||
} | ||
/** | ||
* @param {!Protocol.Page.Frame} framePayload | ||
*/ | ||
_onFrameNavigated(framePayload) { | ||
const isMainFrame = !framePayload.parentId; | ||
let frame = isMainFrame ? this._mainFrame : this._frames.get(framePayload.id); | ||
assert(isMainFrame || frame, 'We either navigate top level or have old version of the navigated frame'); | ||
// Detach all child frames first. | ||
if (frame) { | ||
for (const child of frame.childFrames()) | ||
this._removeFramesRecursively(child); | ||
/** | ||
* @param {!Protocol.Page.FrameTree} frameTree | ||
*/ | ||
_handleFrameTree(frameTree) { | ||
if (frameTree.frame.parentId) | ||
this._onFrameAttached(frameTree.frame.id, frameTree.frame.parentId); | ||
this._onFrameNavigated(frameTree.frame); | ||
if (!frameTree.childFrames) | ||
return; | ||
for (const child of frameTree.childFrames) | ||
this._handleFrameTree(child); | ||
} | ||
// Update or create main frame. | ||
if (isMainFrame) { | ||
if (frame) { | ||
// Update frame id to retain frame identity on cross-process navigation. | ||
this._frames.delete(frame._id); | ||
frame._id = framePayload.id; | ||
} else { | ||
// Initial main frame navigation. | ||
frame = new Frame(this, this._client, null, framePayload.id); | ||
} | ||
this._frames.set(framePayload.id, frame); | ||
this._mainFrame = frame; | ||
/** | ||
* @return {!Puppeteer.Page} | ||
*/ | ||
page() { | ||
return this._page; | ||
} | ||
// Update frame payload. | ||
frame._navigated(framePayload); | ||
this.emit(Events.FrameManager.FrameNavigated, frame); | ||
} | ||
/** | ||
* @param {string} name | ||
*/ | ||
async _ensureIsolatedWorld(name) { | ||
if (this._isolatedWorlds.has(name)) | ||
return; | ||
this._isolatedWorlds.add(name); | ||
await this._client.send('Page.addScriptToEvaluateOnNewDocument', { | ||
source: `//# sourceURL=${EVALUATION_SCRIPT_URL}`, | ||
worldName: name, | ||
}), | ||
await Promise.all(this.frames().map(frame => this._client.send('Page.createIsolatedWorld', { | ||
frameId: frame._id, | ||
grantUniveralAccess: true, | ||
worldName: name, | ||
}).catch(debugError))); // frames might be removed before we send this | ||
} | ||
/** | ||
* @param {string} frameId | ||
* @param {string} url | ||
*/ | ||
_onFrameNavigatedWithinDocument(frameId, url) { | ||
const frame = this._frames.get(frameId); | ||
if (!frame) | ||
return; | ||
frame._navigatedWithinDocument(url); | ||
this.emit(Events.FrameManager.FrameNavigatedWithinDocument, frame); | ||
this.emit(Events.FrameManager.FrameNavigated, frame); | ||
} | ||
/** | ||
* @param {string} frameId | ||
*/ | ||
_onFrameDetached(frameId) { | ||
const frame = this._frames.get(frameId); | ||
if (frame) | ||
this._removeFramesRecursively(frame); | ||
} | ||
_onExecutionContextCreated(contextPayload) { | ||
const frameId = contextPayload.auxData ? contextPayload.auxData.frameId : null; | ||
const frame = this._frames.get(frameId) || null; | ||
let world = null; | ||
if (frame) { | ||
if (contextPayload.auxData && !!contextPayload.auxData['isDefault']) { | ||
world = frame._mainWorld; | ||
} else if (contextPayload.name === UTILITY_WORLD_NAME && !frame._secondaryWorld._hasContext()) { | ||
// In case of multiple sessions to the same target, there's a race between | ||
// connections so we might end up creating multiple isolated worlds. | ||
// We can use either. | ||
world = frame._secondaryWorld; | ||
} | ||
/** | ||
* @return {!Frame} | ||
*/ | ||
mainFrame() { | ||
return this._mainFrame; | ||
} | ||
if (contextPayload.auxData && contextPayload.auxData['type'] === 'isolated') | ||
this._isolatedWorlds.add(contextPayload.name); | ||
/** @type {!ExecutionContext} */ | ||
const context = new ExecutionContext(this._client, contextPayload, world); | ||
if (world) | ||
world._setContext(context); | ||
this._contextIdToContext.set(contextPayload.id, context); | ||
} | ||
/** | ||
* @param {number} executionContextId | ||
*/ | ||
_onExecutionContextDestroyed(executionContextId) { | ||
const context = this._contextIdToContext.get(executionContextId); | ||
if (!context) | ||
return; | ||
this._contextIdToContext.delete(executionContextId); | ||
if (context._world) | ||
context._world._setContext(null); | ||
} | ||
_onExecutionContextsCleared() { | ||
for (const context of this._contextIdToContext.values()) { | ||
if (context._world) | ||
context._world._setContext(null); | ||
/** | ||
* @return {!Array<!Frame>} | ||
*/ | ||
frames() { | ||
return Array.from(this._frames.values()); | ||
} | ||
this._contextIdToContext.clear(); | ||
} | ||
/** | ||
* @param {number} contextId | ||
* @return {!ExecutionContext} | ||
*/ | ||
executionContextById(contextId) { | ||
const context = this._contextIdToContext.get(contextId); | ||
assert(context, 'INTERNAL ERROR: missing context with id = ' + contextId); | ||
return context; | ||
} | ||
/** | ||
* @param {!Frame} frame | ||
*/ | ||
_removeFramesRecursively(frame) { | ||
for (const child of frame.childFrames()) | ||
this._removeFramesRecursively(child); | ||
frame._detach(); | ||
this._frames.delete(frame._id); | ||
this.emit(Events.FrameManager.FrameDetached, frame); | ||
} | ||
/** | ||
* @param {!string} frameId | ||
* @return {?Frame} | ||
*/ | ||
frame(frameId) { | ||
return this._frames.get(frameId) || null; | ||
} | ||
/** | ||
* @param {string} frameId | ||
* @param {?string} parentFrameId | ||
*/ | ||
_onFrameAttached(frameId, parentFrameId) { | ||
if (this._frames.has(frameId)) | ||
return; | ||
assert(parentFrameId); | ||
const parentFrame = this._frames.get(parentFrameId); | ||
const frame = new Frame(this, this._client, parentFrame, frameId); | ||
this._frames.set(frame._id, frame); | ||
this.emit(Events.FrameManager.FrameAttached, frame); | ||
} | ||
/** | ||
* @param {!Protocol.Page.Frame} framePayload | ||
*/ | ||
_onFrameNavigated(framePayload) { | ||
const isMainFrame = !framePayload.parentId; | ||
let frame = isMainFrame ? this._mainFrame : this._frames.get(framePayload.id); | ||
assert(isMainFrame || frame, 'We either navigate top level or have old version of the navigated frame'); | ||
// Detach all child frames first. | ||
if (frame) { | ||
for (const child of frame.childFrames()) | ||
this._removeFramesRecursively(child); | ||
} | ||
// Update or create main frame. | ||
if (isMainFrame) { | ||
if (frame) { | ||
// Update frame id to retain frame identity on cross-process navigation. | ||
this._frames.delete(frame._id); | ||
frame._id = framePayload.id; | ||
} | ||
else { | ||
// Initial main frame navigation. | ||
frame = new Frame(this, this._client, null, framePayload.id); | ||
} | ||
this._frames.set(framePayload.id, frame); | ||
this._mainFrame = frame; | ||
} | ||
// Update frame payload. | ||
frame._navigated(framePayload); | ||
this.emit(Events.FrameManager.FrameNavigated, frame); | ||
} | ||
/** | ||
* @param {string} name | ||
*/ | ||
async _ensureIsolatedWorld(name) { | ||
if (this._isolatedWorlds.has(name)) | ||
return; | ||
this._isolatedWorlds.add(name); | ||
await this._client.send('Page.addScriptToEvaluateOnNewDocument', { | ||
source: `//# sourceURL=${EVALUATION_SCRIPT_URL}`, | ||
worldName: name, | ||
}), | ||
await Promise.all(this.frames().map(frame => this._client.send('Page.createIsolatedWorld', { | ||
frameId: frame._id, | ||
grantUniveralAccess: true, | ||
worldName: name, | ||
}).catch(debugError))); // frames might be removed before we send this | ||
} | ||
/** | ||
* @param {string} frameId | ||
* @param {string} url | ||
*/ | ||
_onFrameNavigatedWithinDocument(frameId, url) { | ||
const frame = this._frames.get(frameId); | ||
if (!frame) | ||
return; | ||
frame._navigatedWithinDocument(url); | ||
this.emit(Events.FrameManager.FrameNavigatedWithinDocument, frame); | ||
this.emit(Events.FrameManager.FrameNavigated, frame); | ||
} | ||
/** | ||
* @param {string} frameId | ||
*/ | ||
_onFrameDetached(frameId) { | ||
const frame = this._frames.get(frameId); | ||
if (frame) | ||
this._removeFramesRecursively(frame); | ||
} | ||
_onExecutionContextCreated(contextPayload) { | ||
const frameId = contextPayload.auxData ? contextPayload.auxData.frameId : null; | ||
const frame = this._frames.get(frameId) || null; | ||
let world = null; | ||
if (frame) { | ||
if (contextPayload.auxData && !!contextPayload.auxData['isDefault']) { | ||
world = frame._mainWorld; | ||
} | ||
else if (contextPayload.name === UTILITY_WORLD_NAME && !frame._secondaryWorld._hasContext()) { | ||
// In case of multiple sessions to the same target, there's a race between | ||
// connections so we might end up creating multiple isolated worlds. | ||
// We can use either. | ||
world = frame._secondaryWorld; | ||
} | ||
} | ||
if (contextPayload.auxData && contextPayload.auxData['type'] === 'isolated') | ||
this._isolatedWorlds.add(contextPayload.name); | ||
/** @type {!ExecutionContext} */ | ||
const context = new ExecutionContext(this._client, contextPayload, world); | ||
if (world) | ||
world._setContext(context); | ||
this._contextIdToContext.set(contextPayload.id, context); | ||
} | ||
/** | ||
* @param {number} executionContextId | ||
*/ | ||
_onExecutionContextDestroyed(executionContextId) { | ||
const context = this._contextIdToContext.get(executionContextId); | ||
if (!context) | ||
return; | ||
this._contextIdToContext.delete(executionContextId); | ||
if (context._world) | ||
context._world._setContext(null); | ||
} | ||
_onExecutionContextsCleared() { | ||
for (const context of this._contextIdToContext.values()) { | ||
if (context._world) | ||
context._world._setContext(null); | ||
} | ||
this._contextIdToContext.clear(); | ||
} | ||
/** | ||
* @param {number} contextId | ||
* @return {!ExecutionContext} | ||
*/ | ||
executionContextById(contextId) { | ||
const context = this._contextIdToContext.get(contextId); | ||
assert(context, 'INTERNAL ERROR: missing context with id = ' + contextId); | ||
return context; | ||
} | ||
/** | ||
* @param {!Frame} frame | ||
*/ | ||
_removeFramesRecursively(frame) { | ||
for (const child of frame.childFrames()) | ||
this._removeFramesRecursively(child); | ||
frame._detach(); | ||
this._frames.delete(frame._id); | ||
this.emit(Events.FrameManager.FrameDetached, frame); | ||
} | ||
} | ||
/** | ||
@@ -373,347 +337,307 @@ * @unrestricted | ||
class Frame { | ||
/** | ||
* @param {!FrameManager} frameManager | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {?Frame} parentFrame | ||
* @param {string} frameId | ||
*/ | ||
constructor(frameManager, client, parentFrame, frameId) { | ||
this._frameManager = frameManager; | ||
this._client = client; | ||
this._parentFrame = parentFrame; | ||
this._url = ''; | ||
this._id = frameId; | ||
this._detached = false; | ||
this._loaderId = ''; | ||
/** @type {!Set<string>} */ | ||
this._lifecycleEvents = new Set(); | ||
/** @type {!DOMWorld} */ | ||
this._mainWorld = new DOMWorld(frameManager, this, frameManager._timeoutSettings); | ||
/** @type {!DOMWorld} */ | ||
this._secondaryWorld = new DOMWorld(frameManager, this, frameManager._timeoutSettings); | ||
/** @type {!Set<!Frame>} */ | ||
this._childFrames = new Set(); | ||
if (this._parentFrame) | ||
this._parentFrame._childFrames.add(this); | ||
} | ||
/** | ||
* @param {string} url | ||
* @param {!{referer?: string, timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async goto(url, options) { | ||
return await this._frameManager.navigateFrame(this, url, options); | ||
} | ||
/** | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async waitForNavigation(options) { | ||
return await this._frameManager.waitForFrameNavigation(this, options); | ||
} | ||
/** | ||
* @return {!Promise<!ExecutionContext>} | ||
*/ | ||
executionContext() { | ||
return this._mainWorld.executionContext(); | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
async evaluateHandle(pageFunction, ...args) { | ||
return this._mainWorld.evaluateHandle(pageFunction, ...args); | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<*>} | ||
*/ | ||
async evaluate(pageFunction, ...args) { | ||
return this._mainWorld.evaluate(pageFunction, ...args); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
async $(selector) { | ||
return this._mainWorld.$(selector); | ||
} | ||
/** | ||
* @param {string} expression | ||
* @return {!Promise<!Array<!Puppeteer.ElementHandle>>} | ||
*/ | ||
async $x(expression) { | ||
return this._mainWorld.$x(expression); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<(!Object|undefined)>} | ||
*/ | ||
async $eval(selector, pageFunction, ...args) { | ||
return this._mainWorld.$eval(selector, pageFunction, ...args); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<(!Object|undefined)>} | ||
*/ | ||
async $$eval(selector, pageFunction, ...args) { | ||
return this._mainWorld.$$eval(selector, pageFunction, ...args); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @return {!Promise<!Array<!Puppeteer.ElementHandle>>} | ||
*/ | ||
async $$(selector) { | ||
return this._mainWorld.$$(selector); | ||
} | ||
/** | ||
* @return {!Promise<String>} | ||
*/ | ||
async content() { | ||
return this._secondaryWorld.content(); | ||
} | ||
/** | ||
* @param {string} html | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
*/ | ||
async setContent(html, options = {}) { | ||
return this._secondaryWorld.setContent(html, options); | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
name() { | ||
return this._name || ''; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
url() { | ||
return this._url; | ||
} | ||
/** | ||
* @return {?Frame} | ||
*/ | ||
parentFrame() { | ||
return this._parentFrame; | ||
} | ||
/** | ||
* @return {!Array.<!Frame>} | ||
*/ | ||
childFrames() { | ||
return Array.from(this._childFrames); | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
isDetached() { | ||
return this._detached; | ||
} | ||
/** | ||
* @param {!{url?: string, path?: string, content?: string, type?: string}} options | ||
* @return {!Promise<!Puppeteer.ElementHandle>} | ||
*/ | ||
async addScriptTag(options) { | ||
return this._mainWorld.addScriptTag(options); | ||
} | ||
/** | ||
* @param {!{url?: string, path?: string, content?: string}} options | ||
* @return {!Promise<!Puppeteer.ElementHandle>} | ||
*/ | ||
async addStyleTag(options) { | ||
return this._mainWorld.addStyleTag(options); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {!{delay?: number, button?: "left"|"right"|"middle", clickCount?: number}=} options | ||
*/ | ||
async click(selector, options) { | ||
return this._secondaryWorld.click(selector, options); | ||
} | ||
/** | ||
* @param {string} selector | ||
*/ | ||
async focus(selector) { | ||
return this._secondaryWorld.focus(selector); | ||
} | ||
/** | ||
* @param {string} selector | ||
*/ | ||
async hover(selector) { | ||
return this._secondaryWorld.hover(selector); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {!Array<string>} values | ||
* @return {!Promise<!Array<string>>} | ||
*/ | ||
select(selector, ...values){ | ||
return this._secondaryWorld.select(selector, ...values); | ||
} | ||
/** | ||
* @param {string} selector | ||
*/ | ||
async tap(selector) { | ||
return this._secondaryWorld.tap(selector); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {string} text | ||
* @param {{delay: (number|undefined)}=} options | ||
*/ | ||
async type(selector, text, options) { | ||
return this._mainWorld.type(selector, text, options); | ||
} | ||
/** | ||
* @param {(string|number|Function)} selectorOrFunctionOrTimeout | ||
* @param {!Object=} options | ||
* @param {!Array<*>} args | ||
* @return {!Promise<?Puppeteer.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); | ||
/** | ||
* @param {!FrameManager} frameManager | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {?Frame} parentFrame | ||
* @param {string} frameId | ||
*/ | ||
constructor(frameManager, client, parentFrame, frameId) { | ||
this._frameManager = frameManager; | ||
this._client = client; | ||
this._parentFrame = parentFrame; | ||
this._url = ''; | ||
this._id = frameId; | ||
this._detached = false; | ||
this._loaderId = ''; | ||
/** @type {!Set<string>} */ | ||
this._lifecycleEvents = new Set(); | ||
/** @type {!DOMWorld} */ | ||
this._mainWorld = new DOMWorld(frameManager, this, frameManager._timeoutSettings); | ||
/** @type {!DOMWorld} */ | ||
this._secondaryWorld = new DOMWorld(frameManager, this, frameManager._timeoutSettings); | ||
/** @type {!Set<!Frame>} */ | ||
this._childFrames = new Set(); | ||
if (this._parentFrame) | ||
this._parentFrame._childFrames.add(this); | ||
} | ||
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 {string} selector | ||
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
async waitForSelector(selector, options) { | ||
const handle = await this._secondaryWorld.waitForSelector(selector, options); | ||
if (!handle) | ||
return null; | ||
const mainExecutionContext = await this._mainWorld.executionContext(); | ||
const result = await mainExecutionContext._adoptElementHandle(handle); | ||
await handle.dispose(); | ||
return result; | ||
} | ||
/** | ||
* @param {string} xpath | ||
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
async waitForXPath(xpath, options) { | ||
const handle = await this._secondaryWorld.waitForXPath(xpath, options); | ||
if (!handle) | ||
return null; | ||
const mainExecutionContext = await this._mainWorld.executionContext(); | ||
const result = await mainExecutionContext._adoptElementHandle(handle); | ||
await handle.dispose(); | ||
return result; | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!{polling?: string|number, timeout?: number}=} options | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
waitForFunction(pageFunction, options = {}, ...args) { | ||
return this._mainWorld.waitForFunction(pageFunction, options, ...args); | ||
} | ||
/** | ||
* @return {!Promise<string>} | ||
*/ | ||
async title() { | ||
return this._secondaryWorld.title(); | ||
} | ||
/** | ||
* @param {!Protocol.Page.Frame} framePayload | ||
*/ | ||
_navigated(framePayload) { | ||
this._name = framePayload.name; | ||
// TODO(lushnikov): remove this once requestInterception has loaderId exposed. | ||
this._navigationURL = framePayload.url; | ||
this._url = framePayload.url; | ||
} | ||
/** | ||
* @param {string} url | ||
*/ | ||
_navigatedWithinDocument(url) { | ||
this._url = url; | ||
} | ||
/** | ||
* @param {string} loaderId | ||
* @param {string} name | ||
*/ | ||
_onLifecycleEvent(loaderId, name) { | ||
if (name === 'init') { | ||
this._loaderId = loaderId; | ||
this._lifecycleEvents.clear(); | ||
/** | ||
* @param {string} url | ||
* @param {!{referer?: string, timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async goto(url, options) { | ||
return await this._frameManager.navigateFrame(this, url, options); | ||
} | ||
this._lifecycleEvents.add(name); | ||
} | ||
_onLoadingStopped() { | ||
this._lifecycleEvents.add('DOMContentLoaded'); | ||
this._lifecycleEvents.add('load'); | ||
} | ||
_detach() { | ||
this._detached = true; | ||
this._mainWorld._detach(); | ||
this._secondaryWorld._detach(); | ||
if (this._parentFrame) | ||
this._parentFrame._childFrames.delete(this); | ||
this._parentFrame = null; | ||
} | ||
/** | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async waitForNavigation(options) { | ||
return await this._frameManager.waitForFrameNavigation(this, options); | ||
} | ||
/** | ||
* @return {!Promise<!ExecutionContext>} | ||
*/ | ||
executionContext() { | ||
return this._mainWorld.executionContext(); | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
async evaluateHandle(pageFunction, ...args) { | ||
return this._mainWorld.evaluateHandle(pageFunction, ...args); | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<*>} | ||
*/ | ||
async evaluate(pageFunction, ...args) { | ||
return this._mainWorld.evaluate(pageFunction, ...args); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
async $(selector) { | ||
return this._mainWorld.$(selector); | ||
} | ||
/** | ||
* @param {string} expression | ||
* @return {!Promise<!Array<!Puppeteer.ElementHandle>>} | ||
*/ | ||
async $x(expression) { | ||
return this._mainWorld.$x(expression); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<(!Object|undefined)>} | ||
*/ | ||
async $eval(selector, pageFunction, ...args) { | ||
return this._mainWorld.$eval(selector, pageFunction, ...args); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<(!Object|undefined)>} | ||
*/ | ||
async $$eval(selector, pageFunction, ...args) { | ||
return this._mainWorld.$$eval(selector, pageFunction, ...args); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @return {!Promise<!Array<!Puppeteer.ElementHandle>>} | ||
*/ | ||
async $$(selector) { | ||
return this._mainWorld.$$(selector); | ||
} | ||
/** | ||
* @return {!Promise<String>} | ||
*/ | ||
async content() { | ||
return this._secondaryWorld.content(); | ||
} | ||
/** | ||
* @param {string} html | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
*/ | ||
async setContent(html, options = {}) { | ||
return this._secondaryWorld.setContent(html, options); | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
name() { | ||
return this._name || ''; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
url() { | ||
return this._url; | ||
} | ||
/** | ||
* @return {?Frame} | ||
*/ | ||
parentFrame() { | ||
return this._parentFrame; | ||
} | ||
/** | ||
* @return {!Array.<!Frame>} | ||
*/ | ||
childFrames() { | ||
return Array.from(this._childFrames); | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
isDetached() { | ||
return this._detached; | ||
} | ||
/** | ||
* @param {!{url?: string, path?: string, content?: string, type?: string}} options | ||
* @return {!Promise<!Puppeteer.ElementHandle>} | ||
*/ | ||
async addScriptTag(options) { | ||
return this._mainWorld.addScriptTag(options); | ||
} | ||
/** | ||
* @param {!{url?: string, path?: string, content?: string}} options | ||
* @return {!Promise<!Puppeteer.ElementHandle>} | ||
*/ | ||
async addStyleTag(options) { | ||
return this._mainWorld.addStyleTag(options); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {!{delay?: number, button?: "left"|"right"|"middle", clickCount?: number}=} options | ||
*/ | ||
async click(selector, options) { | ||
return this._secondaryWorld.click(selector, options); | ||
} | ||
/** | ||
* @param {string} selector | ||
*/ | ||
async focus(selector) { | ||
return this._secondaryWorld.focus(selector); | ||
} | ||
/** | ||
* @param {string} selector | ||
*/ | ||
async hover(selector) { | ||
return this._secondaryWorld.hover(selector); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {!Array<string>} values | ||
* @return {!Promise<!Array<string>>} | ||
*/ | ||
select(selector, ...values) { | ||
return this._secondaryWorld.select(selector, ...values); | ||
} | ||
/** | ||
* @param {string} selector | ||
*/ | ||
async tap(selector) { | ||
return this._secondaryWorld.tap(selector); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {string} text | ||
* @param {{delay: (number|undefined)}=} options | ||
*/ | ||
async type(selector, text, options) { | ||
return this._mainWorld.type(selector, text, options); | ||
} | ||
/** | ||
* @param {(string|number|Function)} selectorOrFunctionOrTimeout | ||
* @param {!Object=} options | ||
* @param {!Array<*>} args | ||
* @return {!Promise<?Puppeteer.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 {string} selector | ||
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
async waitForSelector(selector, options) { | ||
const handle = await this._secondaryWorld.waitForSelector(selector, options); | ||
if (!handle) | ||
return null; | ||
const mainExecutionContext = await this._mainWorld.executionContext(); | ||
const result = await mainExecutionContext._adoptElementHandle(handle); | ||
await handle.dispose(); | ||
return result; | ||
} | ||
/** | ||
* @param {string} xpath | ||
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
async waitForXPath(xpath, options) { | ||
const handle = await this._secondaryWorld.waitForXPath(xpath, options); | ||
if (!handle) | ||
return null; | ||
const mainExecutionContext = await this._mainWorld.executionContext(); | ||
const result = await mainExecutionContext._adoptElementHandle(handle); | ||
await handle.dispose(); | ||
return result; | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!{polling?: string|number, timeout?: number}=} options | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
waitForFunction(pageFunction, options = {}, ...args) { | ||
return this._mainWorld.waitForFunction(pageFunction, options, ...args); | ||
} | ||
/** | ||
* @return {!Promise<string>} | ||
*/ | ||
async title() { | ||
return this._secondaryWorld.title(); | ||
} | ||
/** | ||
* @param {!Protocol.Page.Frame} framePayload | ||
*/ | ||
_navigated(framePayload) { | ||
this._name = framePayload.name; | ||
// TODO(lushnikov): remove this once requestInterception has loaderId exposed. | ||
this._navigationURL = framePayload.url; | ||
this._url = framePayload.url; | ||
} | ||
/** | ||
* @param {string} url | ||
*/ | ||
_navigatedWithinDocument(url) { | ||
this._url = url; | ||
} | ||
/** | ||
* @param {string} loaderId | ||
* @param {string} name | ||
*/ | ||
_onLifecycleEvent(loaderId, name) { | ||
if (name === 'init') { | ||
this._loaderId = loaderId; | ||
this._lifecycleEvents.clear(); | ||
} | ||
this._lifecycleEvents.add(name); | ||
} | ||
_onLoadingStopped() { | ||
this._lifecycleEvents.add('DOMContentLoaded'); | ||
this._lifecycleEvents.add('load'); | ||
} | ||
_detach() { | ||
this._detached = true; | ||
this._mainWorld._detach(); | ||
this._secondaryWorld._detach(); | ||
if (this._parentFrame) | ||
this._parentFrame._childFrames.delete(this); | ||
this._parentFrame = null; | ||
} | ||
} | ||
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.'); | ||
assert(options.waitUntil !== 'networkidle', 'ERROR: "networkidle" option is no longer supported. Use "networkidle2" instead'); | ||
assert(options['networkIdleTimeout'] === undefined, 'ERROR: networkIdleTimeout option is no longer supported.'); | ||
assert(options['networkIdleInflight'] === undefined, 'ERROR: networkIdleInflight option is no longer supported.'); | ||
assert(options.waitUntil !== 'networkidle', 'ERROR: "networkidle" option is no longer supported. Use "networkidle2" instead'); | ||
} | ||
module.exports = {FrameManager, Frame}; | ||
module.exports = { FrameManager, Frame }; |
@@ -16,263 +16,249 @@ /** | ||
*/ | ||
const {TimeoutError} = require('./Errors'); | ||
const { TimeoutError } = require('./Errors'); | ||
const debugError = require('debug')(`puppeteer:error`); | ||
const fs = require('fs'); | ||
class Helper { | ||
/** | ||
* @param {Function|string} fun | ||
* @param {!Array<*>} args | ||
* @return {string} | ||
*/ | ||
static evaluationString(fun, ...args) { | ||
if (Helper.isString(fun)) { | ||
assert(args.length === 0, 'Cannot evaluate a string with arguments'); | ||
return /** @type {string} */ (fun); | ||
/** | ||
* @param {Function|string} fun | ||
* @param {!Array<*>} args | ||
* @return {string} | ||
*/ | ||
static evaluationString(fun, ...args) { | ||
if (Helper.isString(fun)) { | ||
assert(args.length === 0, 'Cannot evaluate a string with arguments'); | ||
return /** @type {string} */ (fun); | ||
} | ||
return `(${fun})(${args.map(serializeArgument).join(',')})`; | ||
/** | ||
* @param {*} arg | ||
* @return {string} | ||
*/ | ||
function serializeArgument(arg) { | ||
if (Object.is(arg, undefined)) | ||
return 'undefined'; | ||
return JSON.stringify(arg); | ||
} | ||
} | ||
return `(${fun})(${args.map(serializeArgument).join(',')})`; | ||
/** | ||
* @param {*} arg | ||
* @param {!Protocol.Runtime.ExceptionDetails} exceptionDetails | ||
* @return {string} | ||
*/ | ||
function serializeArgument(arg) { | ||
if (Object.is(arg, undefined)) | ||
return 'undefined'; | ||
return JSON.stringify(arg); | ||
static getExceptionMessage(exceptionDetails) { | ||
if (exceptionDetails.exception) | ||
return exceptionDetails.exception.description || exceptionDetails.exception.value; | ||
let message = exceptionDetails.text; | ||
if (exceptionDetails.stackTrace) { | ||
for (const callframe of exceptionDetails.stackTrace.callFrames) { | ||
const location = callframe.url + ':' + callframe.lineNumber + ':' + callframe.columnNumber; | ||
const functionName = callframe.functionName || '<anonymous>'; | ||
message += `\n at ${functionName} (${location})`; | ||
} | ||
} | ||
return message; | ||
} | ||
} | ||
/** | ||
* @param {!Protocol.Runtime.ExceptionDetails} exceptionDetails | ||
* @return {string} | ||
*/ | ||
static getExceptionMessage(exceptionDetails) { | ||
if (exceptionDetails.exception) | ||
return exceptionDetails.exception.description || exceptionDetails.exception.value; | ||
let message = exceptionDetails.text; | ||
if (exceptionDetails.stackTrace) { | ||
for (const callframe of exceptionDetails.stackTrace.callFrames) { | ||
const location = callframe.url + ':' + callframe.lineNumber + ':' + callframe.columnNumber; | ||
const functionName = callframe.functionName || '<anonymous>'; | ||
message += `\n at ${functionName} (${location})`; | ||
} | ||
/** | ||
* @param {!Protocol.Runtime.RemoteObject} remoteObject | ||
* @return {*} | ||
*/ | ||
static valueFromRemoteObject(remoteObject) { | ||
assert(!remoteObject.objectId, 'Cannot extract value when objectId is given'); | ||
if (remoteObject.unserializableValue) { | ||
if (remoteObject.type === 'bigint' && typeof BigInt !== 'undefined') | ||
return BigInt(remoteObject.unserializableValue.replace('n', '')); | ||
switch (remoteObject.unserializableValue) { | ||
case '-0': | ||
return -0; | ||
case 'NaN': | ||
return NaN; | ||
case 'Infinity': | ||
return Infinity; | ||
case '-Infinity': | ||
return -Infinity; | ||
default: | ||
throw new Error('Unsupported unserializable value: ' + remoteObject.unserializableValue); | ||
} | ||
} | ||
return remoteObject.value; | ||
} | ||
return message; | ||
} | ||
/** | ||
* @param {!Protocol.Runtime.RemoteObject} remoteObject | ||
* @return {*} | ||
*/ | ||
static valueFromRemoteObject(remoteObject) { | ||
assert(!remoteObject.objectId, 'Cannot extract value when objectId is given'); | ||
if (remoteObject.unserializableValue) { | ||
if (remoteObject.type === 'bigint' && typeof BigInt !== 'undefined') | ||
return BigInt(remoteObject.unserializableValue.replace('n', '')); | ||
switch (remoteObject.unserializableValue) { | ||
case '-0': | ||
return -0; | ||
case 'NaN': | ||
return NaN; | ||
case 'Infinity': | ||
return Infinity; | ||
case '-Infinity': | ||
return -Infinity; | ||
default: | ||
throw new Error('Unsupported unserializable value: ' + remoteObject.unserializableValue); | ||
} | ||
} | ||
return remoteObject.value; | ||
} | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Protocol.Runtime.RemoteObject} remoteObject | ||
*/ | ||
static async releaseObject(client, remoteObject) { | ||
if (!remoteObject.objectId) | ||
return; | ||
await client.send('Runtime.releaseObject', {objectId: remoteObject.objectId}).catch(error => { | ||
// Exceptions might happen in case of a page been navigated or closed. | ||
// Swallow these since they are harmless and we don't leak anything in this case. | ||
debugError(error); | ||
}); | ||
} | ||
/** | ||
* @param {!Object} classType | ||
*/ | ||
static installAsyncStackHooks(classType) { | ||
for (const methodName of Reflect.ownKeys(classType.prototype)) { | ||
const method = Reflect.get(classType.prototype, methodName); | ||
if (methodName === 'constructor' || typeof methodName !== 'string' || methodName.startsWith('_') || typeof method !== 'function' || method.constructor.name !== 'AsyncFunction') | ||
continue; | ||
Reflect.set(classType.prototype, methodName, function(...args) { | ||
const syncStack = {}; | ||
Error.captureStackTrace(syncStack); | ||
return method.call(this, ...args).catch(e => { | ||
const stack = syncStack.stack.substring(syncStack.stack.indexOf('\n') + 1); | ||
const clientStack = stack.substring(stack.indexOf('\n')); | ||
if (e instanceof Error && e.stack && !e.stack.includes(clientStack)) | ||
e.stack += '\n -- ASYNC --\n' + stack; | ||
throw e; | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Protocol.Runtime.RemoteObject} remoteObject | ||
*/ | ||
static async releaseObject(client, remoteObject) { | ||
if (!remoteObject.objectId) | ||
return; | ||
await client.send('Runtime.releaseObject', { objectId: remoteObject.objectId }).catch(error => { | ||
// Exceptions might happen in case of a page been navigated or closed. | ||
// Swallow these since they are harmless and we don't leak anything in this case. | ||
debugError(error); | ||
}); | ||
}); | ||
} | ||
} | ||
/** | ||
* @param {!NodeJS.EventEmitter} emitter | ||
* @param {(string|symbol)} eventName | ||
* @param {function(?):void} handler | ||
* @return {{emitter: !NodeJS.EventEmitter, eventName: (string|symbol), handler: function(?)}} | ||
*/ | ||
static addEventListener(emitter, eventName, handler) { | ||
emitter.on(eventName, handler); | ||
return { emitter, eventName, handler }; | ||
} | ||
/** | ||
* @param {!Array<{emitter: !NodeJS.EventEmitter, eventName: (string|symbol), handler: function(?):void}>} listeners | ||
*/ | ||
static removeEventListeners(listeners) { | ||
for (const listener of listeners) | ||
listener.emitter.removeListener(listener.eventName, listener.handler); | ||
listeners.length = 0; | ||
} | ||
/** | ||
* @param {!Object} obj | ||
* @return {boolean} | ||
*/ | ||
static isString(obj) { | ||
return typeof obj === 'string' || obj instanceof String; | ||
} | ||
/** | ||
* @param {!Object} obj | ||
* @return {boolean} | ||
*/ | ||
static isNumber(obj) { | ||
return typeof obj === 'number' || obj instanceof Number; | ||
} | ||
/** | ||
* @param {function} nodeFunction | ||
* @return {function} | ||
*/ | ||
static promisify(nodeFunction) { | ||
function promisified(...args) { | ||
return new Promise((resolve, reject) => { | ||
function callback(err, ...result) { | ||
if (err) | ||
return reject(err); | ||
if (result.length === 1) | ||
return resolve(result[0]); | ||
return resolve(result); | ||
/** | ||
* @param {!Object} classType | ||
*/ | ||
static installAsyncStackHooks(classType) { | ||
for (const methodName of Reflect.ownKeys(classType.prototype)) { | ||
const method = Reflect.get(classType.prototype, methodName); | ||
if (methodName === 'constructor' || typeof methodName !== 'string' || methodName.startsWith('_') || typeof method !== 'function' || method.constructor.name !== 'AsyncFunction') | ||
continue; | ||
Reflect.set(classType.prototype, methodName, function (...args) { | ||
const syncStack = {}; | ||
Error.captureStackTrace(syncStack); | ||
return method.call(this, ...args).catch(e => { | ||
const stack = syncStack.stack.substring(syncStack.stack.indexOf('\n') + 1); | ||
const clientStack = stack.substring(stack.indexOf('\n')); | ||
if (e instanceof Error && e.stack && !e.stack.includes(clientStack)) | ||
e.stack += '\n -- ASYNC --\n' + stack; | ||
throw e; | ||
}); | ||
}); | ||
} | ||
nodeFunction.call(null, ...args, callback); | ||
}); | ||
} | ||
return promisified; | ||
} | ||
/** | ||
* @param {!NodeJS.EventEmitter} emitter | ||
* @param {(string|symbol)} eventName | ||
* @param {function} predicate | ||
* @param {number} timeout | ||
* @param {!Promise<!Error>} abortPromise | ||
* @return {!Promise} | ||
*/ | ||
static async waitForEvent(emitter, eventName, predicate, timeout, abortPromise) { | ||
let eventTimeout, resolveCallback, rejectCallback; | ||
const promise = new Promise((resolve, reject) => { | ||
resolveCallback = resolve; | ||
rejectCallback = reject; | ||
}); | ||
const listener = Helper.addEventListener(emitter, eventName, event => { | ||
if (!predicate(event)) | ||
return; | ||
resolveCallback(event); | ||
}); | ||
if (timeout) { | ||
eventTimeout = setTimeout(() => { | ||
rejectCallback(new TimeoutError('Timeout exceeded while waiting for event')); | ||
}, timeout); | ||
/** | ||
* @param {!NodeJS.EventEmitter} emitter | ||
* @param {(string|symbol)} eventName | ||
* @param {function(?):void} handler | ||
* @return {{emitter: !NodeJS.EventEmitter, eventName: (string|symbol), handler: function(?)}} | ||
*/ | ||
static addEventListener(emitter, eventName, handler) { | ||
emitter.on(eventName, handler); | ||
return { emitter, eventName, handler }; | ||
} | ||
function cleanup() { | ||
Helper.removeEventListeners([listener]); | ||
clearTimeout(eventTimeout); | ||
/** | ||
* @param {!Array<{emitter: !NodeJS.EventEmitter, eventName: (string|symbol), handler: function(?):void}>} listeners | ||
*/ | ||
static removeEventListeners(listeners) { | ||
for (const listener of listeners) | ||
listener.emitter.removeListener(listener.eventName, listener.handler); | ||
listeners.length = 0; | ||
} | ||
const result = await Promise.race([promise, abortPromise]).then(r => { | ||
cleanup(); | ||
return r; | ||
}, e => { | ||
cleanup(); | ||
throw e; | ||
}); | ||
if (result instanceof Error) | ||
throw result; | ||
return result; | ||
} | ||
/** | ||
* @template T | ||
* @param {!Promise<T>} promise | ||
* @param {string} taskName | ||
* @param {number} timeout | ||
* @return {!Promise<T>} | ||
*/ | ||
static async waitWithTimeout(promise, taskName, timeout) { | ||
let reject; | ||
const timeoutError = new TimeoutError(`waiting for ${taskName} failed: timeout ${timeout}ms exceeded`); | ||
const timeoutPromise = new Promise((resolve, x) => reject = x); | ||
let timeoutTimer = null; | ||
if (timeout) | ||
timeoutTimer = setTimeout(() => reject(timeoutError), timeout); | ||
try { | ||
return await Promise.race([promise, timeoutPromise]); | ||
} finally { | ||
if (timeoutTimer) | ||
clearTimeout(timeoutTimer); | ||
/** | ||
* @param {!Object} obj | ||
* @return {boolean} | ||
*/ | ||
static isString(obj) { | ||
return typeof obj === 'string' || obj instanceof String; | ||
} | ||
} | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {string} handle | ||
* @param {?string} path | ||
* @return {!Promise<!Buffer>} | ||
*/ | ||
static async readProtocolStream(client, handle, path) { | ||
let eof = false; | ||
let file; | ||
if (path) | ||
file = await openAsync(path, 'w'); | ||
const bufs = []; | ||
while (!eof) { | ||
const response = await client.send('IO.read', {handle}); | ||
eof = response.eof; | ||
const buf = Buffer.from(response.data, response.base64Encoded ? 'base64' : undefined); | ||
bufs.push(buf); | ||
if (path) | ||
await writeAsync(file, buf); | ||
/** | ||
* @param {!Object} obj | ||
* @return {boolean} | ||
*/ | ||
static isNumber(obj) { | ||
return typeof obj === 'number' || obj instanceof Number; | ||
} | ||
if (path) | ||
await closeAsync(file); | ||
await client.send('IO.close', {handle}); | ||
let resultBuffer = null; | ||
try { | ||
resultBuffer = Buffer.concat(bufs); | ||
} finally { | ||
return resultBuffer; | ||
/** | ||
* @param {function} nodeFunction | ||
* @return {function} | ||
*/ | ||
static promisify(nodeFunction) { | ||
function promisified(...args) { | ||
return new Promise((resolve, reject) => { | ||
function callback(err, ...result) { | ||
if (err) | ||
return reject(err); | ||
if (result.length === 1) | ||
return resolve(result[0]); | ||
return resolve(result); | ||
} | ||
nodeFunction.call(null, ...args, callback); | ||
}); | ||
} | ||
return promisified; | ||
} | ||
} | ||
/** | ||
* @param {!NodeJS.EventEmitter} emitter | ||
* @param {(string|symbol)} eventName | ||
* @param {function} predicate | ||
* @param {number} timeout | ||
* @param {!Promise<!Error>} abortPromise | ||
* @return {!Promise} | ||
*/ | ||
static async waitForEvent(emitter, eventName, predicate, timeout, abortPromise) { | ||
let eventTimeout, resolveCallback, rejectCallback; | ||
const promise = new Promise((resolve, reject) => { | ||
resolveCallback = resolve; | ||
rejectCallback = reject; | ||
}); | ||
const listener = Helper.addEventListener(emitter, eventName, event => { | ||
if (!predicate(event)) | ||
return; | ||
resolveCallback(event); | ||
}); | ||
if (timeout) { | ||
eventTimeout = setTimeout(() => { | ||
rejectCallback(new TimeoutError('Timeout exceeded while waiting for event')); | ||
}, timeout); | ||
} | ||
function cleanup() { | ||
Helper.removeEventListeners([listener]); | ||
clearTimeout(eventTimeout); | ||
} | ||
const result = await Promise.race([promise, abortPromise]).then(r => { | ||
cleanup(); | ||
return r; | ||
}, e => { | ||
cleanup(); | ||
throw e; | ||
}); | ||
if (result instanceof Error) | ||
throw result; | ||
return result; | ||
} | ||
/** | ||
* @template T | ||
* @param {!Promise<T>} promise | ||
* @param {string} taskName | ||
* @param {number} timeout | ||
* @return {!Promise<T>} | ||
*/ | ||
static async waitWithTimeout(promise, taskName, timeout) { | ||
let reject; | ||
const timeoutError = new TimeoutError(`waiting for ${taskName} failed: timeout ${timeout}ms exceeded`); | ||
const timeoutPromise = new Promise((resolve, x) => reject = x); | ||
let timeoutTimer = null; | ||
if (timeout) | ||
timeoutTimer = setTimeout(() => reject(timeoutError), timeout); | ||
try { | ||
return await Promise.race([promise, timeoutPromise]); | ||
} | ||
finally { | ||
if (timeoutTimer) | ||
clearTimeout(timeoutTimer); | ||
} | ||
} | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {string} handle | ||
* @param {?string} path | ||
* @return {!Promise<!Buffer>} | ||
*/ | ||
static async readProtocolStream(client, handle, path) { | ||
let eof = false; | ||
let file; | ||
if (path) | ||
file = await openAsync(path, 'w'); | ||
const bufs = []; | ||
while (!eof) { | ||
const response = await client.send('IO.read', { handle }); | ||
eof = response.eof; | ||
const buf = Buffer.from(response.data, response.base64Encoded ? 'base64' : undefined); | ||
bufs.push(buf); | ||
if (path) | ||
await writeAsync(file, buf); | ||
} | ||
if (path) | ||
await closeAsync(file); | ||
await client.send('IO.close', { handle }); | ||
let resultBuffer = null; | ||
try { | ||
resultBuffer = Buffer.concat(bufs); | ||
} | ||
finally { | ||
return resultBuffer; | ||
} | ||
} | ||
} | ||
const openAsync = Helper.promisify(fs.open); | ||
const writeAsync = Helper.promisify(fs.write); | ||
const closeAsync = Helper.promisify(fs.close); | ||
/** | ||
@@ -283,10 +269,9 @@ * @param {*} value | ||
function assert(value, message) { | ||
if (!value) | ||
throw new Error(message); | ||
if (!value) | ||
throw new Error(message); | ||
} | ||
module.exports = { | ||
helper: Helper, | ||
assert, | ||
debugError | ||
helper: Helper, | ||
assert, | ||
debugError | ||
}; |
527
lib/Input.js
@@ -16,6 +16,4 @@ /** | ||
*/ | ||
const {assert} = require('./helper'); | ||
const { assert } = require('./helper'); | ||
const keyDefinitions = require('./USKeyboardLayout'); | ||
/** | ||
@@ -29,286 +27,259 @@ * @typedef {Object} KeyDescription | ||
*/ | ||
class Keyboard { | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
*/ | ||
constructor(client) { | ||
this._client = client; | ||
this._modifiers = 0; | ||
this._pressedKeys = new Set(); | ||
} | ||
/** | ||
* @param {string} key | ||
* @param {{text?: string}=} options | ||
*/ | ||
async down(key, options = { text: undefined }) { | ||
const description = this._keyDescriptionForString(key); | ||
const autoRepeat = this._pressedKeys.has(description.code); | ||
this._pressedKeys.add(description.code); | ||
this._modifiers |= this._modifierBit(description.key); | ||
const text = options.text === undefined ? description.text : options.text; | ||
await this._client.send('Input.dispatchKeyEvent', { | ||
type: text ? 'keyDown' : 'rawKeyDown', | ||
modifiers: this._modifiers, | ||
windowsVirtualKeyCode: description.keyCode, | ||
code: description.code, | ||
key: description.key, | ||
text: text, | ||
unmodifiedText: text, | ||
autoRepeat, | ||
location: description.location, | ||
isKeypad: description.location === 3 | ||
}); | ||
} | ||
/** | ||
* @param {string} key | ||
* @return {number} | ||
*/ | ||
_modifierBit(key) { | ||
if (key === 'Alt') | ||
return 1; | ||
if (key === 'Control') | ||
return 2; | ||
if (key === 'Meta') | ||
return 4; | ||
if (key === 'Shift') | ||
return 8; | ||
return 0; | ||
} | ||
/** | ||
* @param {string} keyString | ||
* @return {KeyDescription} | ||
*/ | ||
_keyDescriptionForString(keyString) { | ||
const shift = this._modifiers & 8; | ||
const description = { | ||
key: '', | ||
keyCode: 0, | ||
code: '', | ||
text: '', | ||
location: 0 | ||
}; | ||
const definition = keyDefinitions[keyString]; | ||
assert(definition, `Unknown key: "${keyString}"`); | ||
if (definition.key) | ||
description.key = definition.key; | ||
if (shift && definition.shiftKey) | ||
description.key = definition.shiftKey; | ||
if (definition.keyCode) | ||
description.keyCode = definition.keyCode; | ||
if (shift && definition.shiftKeyCode) | ||
description.keyCode = definition.shiftKeyCode; | ||
if (definition.code) | ||
description.code = definition.code; | ||
if (definition.location) | ||
description.location = definition.location; | ||
if (description.key.length === 1) | ||
description.text = description.key; | ||
if (definition.text) | ||
description.text = definition.text; | ||
if (shift && definition.shiftText) | ||
description.text = definition.shiftText; | ||
// if any modifiers besides shift are pressed, no text should be sent | ||
if (this._modifiers & ~8) | ||
description.text = ''; | ||
return description; | ||
} | ||
/** | ||
* @param {string} key | ||
*/ | ||
async up(key) { | ||
const description = this._keyDescriptionForString(key); | ||
this._modifiers &= ~this._modifierBit(description.key); | ||
this._pressedKeys.delete(description.code); | ||
await this._client.send('Input.dispatchKeyEvent', { | ||
type: 'keyUp', | ||
modifiers: this._modifiers, | ||
key: description.key, | ||
windowsVirtualKeyCode: description.keyCode, | ||
code: description.code, | ||
location: description.location | ||
}); | ||
} | ||
/** | ||
* @param {string} char | ||
*/ | ||
async sendCharacter(char) { | ||
await this._client.send('Input.insertText', {text: char}); | ||
} | ||
/** | ||
* @param {string} text | ||
* @param {{delay: (number|undefined)}=} options | ||
*/ | ||
async type(text, options) { | ||
const delay = (options && options.delay) || null; | ||
for (const char of text) { | ||
if (keyDefinitions[char]) { | ||
await this.press(char, {delay}); | ||
} else { | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
*/ | ||
constructor(client) { | ||
this._client = client; | ||
this._modifiers = 0; | ||
this._pressedKeys = new Set(); | ||
} | ||
/** | ||
* @param {string} key | ||
* @param {{text?: string}=} options | ||
*/ | ||
async down(key, options = { text: undefined }) { | ||
const description = this._keyDescriptionForString(key); | ||
const autoRepeat = this._pressedKeys.has(description.code); | ||
this._pressedKeys.add(description.code); | ||
this._modifiers |= this._modifierBit(description.key); | ||
const text = options.text === undefined ? description.text : options.text; | ||
await this._client.send('Input.dispatchKeyEvent', { | ||
type: text ? 'keyDown' : 'rawKeyDown', | ||
modifiers: this._modifiers, | ||
windowsVirtualKeyCode: description.keyCode, | ||
code: description.code, | ||
key: description.key, | ||
text: text, | ||
unmodifiedText: text, | ||
autoRepeat, | ||
location: description.location, | ||
isKeypad: description.location === 3 | ||
}); | ||
} | ||
/** | ||
* @param {string} key | ||
* @return {number} | ||
*/ | ||
_modifierBit(key) { | ||
if (key === 'Alt') | ||
return 1; | ||
if (key === 'Control') | ||
return 2; | ||
if (key === 'Meta') | ||
return 4; | ||
if (key === 'Shift') | ||
return 8; | ||
return 0; | ||
} | ||
/** | ||
* @param {string} keyString | ||
* @return {KeyDescription} | ||
*/ | ||
_keyDescriptionForString(keyString) { | ||
const shift = this._modifiers & 8; | ||
const description = { | ||
key: '', | ||
keyCode: 0, | ||
code: '', | ||
text: '', | ||
location: 0 | ||
}; | ||
const definition = keyDefinitions[keyString]; | ||
assert(definition, `Unknown key: "${keyString}"`); | ||
if (definition.key) | ||
description.key = definition.key; | ||
if (shift && definition.shiftKey) | ||
description.key = definition.shiftKey; | ||
if (definition.keyCode) | ||
description.keyCode = definition.keyCode; | ||
if (shift && definition.shiftKeyCode) | ||
description.keyCode = definition.shiftKeyCode; | ||
if (definition.code) | ||
description.code = definition.code; | ||
if (definition.location) | ||
description.location = definition.location; | ||
if (description.key.length === 1) | ||
description.text = description.key; | ||
if (definition.text) | ||
description.text = definition.text; | ||
if (shift && definition.shiftText) | ||
description.text = definition.shiftText; | ||
// if any modifiers besides shift are pressed, no text should be sent | ||
if (this._modifiers & ~8) | ||
description.text = ''; | ||
return description; | ||
} | ||
/** | ||
* @param {string} key | ||
*/ | ||
async up(key) { | ||
const description = this._keyDescriptionForString(key); | ||
this._modifiers &= ~this._modifierBit(description.key); | ||
this._pressedKeys.delete(description.code); | ||
await this._client.send('Input.dispatchKeyEvent', { | ||
type: 'keyUp', | ||
modifiers: this._modifiers, | ||
key: description.key, | ||
windowsVirtualKeyCode: description.keyCode, | ||
code: description.code, | ||
location: description.location | ||
}); | ||
} | ||
/** | ||
* @param {string} char | ||
*/ | ||
async sendCharacter(char) { | ||
await this._client.send('Input.insertText', { text: char }); | ||
} | ||
/** | ||
* @param {string} text | ||
* @param {{delay: (number|undefined)}=} options | ||
*/ | ||
async type(text, options) { | ||
const delay = (options && options.delay) || null; | ||
for (const char of text) { | ||
if (keyDefinitions[char]) { | ||
await this.press(char, { delay }); | ||
} | ||
else { | ||
if (delay) | ||
await new Promise(f => setTimeout(f, delay)); | ||
await this.sendCharacter(char); | ||
} | ||
} | ||
} | ||
/** | ||
* @param {string} key | ||
* @param {!{delay?: number, text?: string}=} options | ||
*/ | ||
async press(key, options = {}) { | ||
const { delay = null } = options; | ||
await this.down(key, options); | ||
if (delay) | ||
await new Promise(f => setTimeout(f, delay)); | ||
await this.sendCharacter(char); | ||
} | ||
await new Promise(f => setTimeout(f, options.delay)); | ||
await this.up(key); | ||
} | ||
} | ||
/** | ||
* @param {string} key | ||
* @param {!{delay?: number, text?: string}=} options | ||
*/ | ||
async press(key, options = {}) { | ||
const {delay = null} = options; | ||
await this.down(key, options); | ||
if (delay) | ||
await new Promise(f => setTimeout(f, options.delay)); | ||
await this.up(key); | ||
} | ||
} | ||
class Mouse { | ||
/** | ||
* @param {Puppeteer.CDPSession} client | ||
* @param {!Keyboard} keyboard | ||
*/ | ||
constructor(client, keyboard) { | ||
this._client = client; | ||
this._keyboard = keyboard; | ||
this._x = 0; | ||
this._y = 0; | ||
/** @type {'none'|'left'|'right'|'middle'} */ | ||
this._button = 'none'; | ||
} | ||
/** | ||
* @param {number} x | ||
* @param {number} y | ||
* @param {!{steps?: number}=} options | ||
*/ | ||
async move(x, y, options = {}) { | ||
const {steps = 1} = options; | ||
const fromX = this._x, fromY = this._y; | ||
this._x = x; | ||
this._y = y; | ||
for (let i = 1; i <= steps; i++) { | ||
await this._client.send('Input.dispatchMouseEvent', { | ||
type: 'mouseMoved', | ||
button: this._button, | ||
x: fromX + (this._x - fromX) * (i / steps), | ||
y: fromY + (this._y - fromY) * (i / steps), | ||
modifiers: this._keyboard._modifiers | ||
}); | ||
/** | ||
* @param {Puppeteer.CDPSession} client | ||
* @param {!Keyboard} keyboard | ||
*/ | ||
constructor(client, keyboard) { | ||
this._client = client; | ||
this._keyboard = keyboard; | ||
this._x = 0; | ||
this._y = 0; | ||
/** @type {'none'|'left'|'right'|'middle'} */ | ||
this._button = 'none'; | ||
} | ||
} | ||
/** | ||
* @param {number} x | ||
* @param {number} y | ||
* @param {!{delay?: number, button?: "left"|"right"|"middle", clickCount?: number}=} options | ||
*/ | ||
async click(x, y, options = {}) { | ||
const {delay = null} = options; | ||
if (delay !== null) { | ||
await Promise.all([ | ||
this.move(x, y), | ||
this.down(options), | ||
]); | ||
await new Promise(f => setTimeout(f, delay)); | ||
await this.up(options); | ||
} else { | ||
await Promise.all([ | ||
this.move(x, y), | ||
this.down(options), | ||
this.up(options), | ||
]); | ||
/** | ||
* @param {number} x | ||
* @param {number} y | ||
* @param {!{steps?: number}=} options | ||
*/ | ||
async move(x, y, options = {}) { | ||
const { steps = 1 } = options; | ||
const fromX = this._x, fromY = this._y; | ||
this._x = x; | ||
this._y = y; | ||
for (let i = 1; i <= steps; i++) { | ||
await this._client.send('Input.dispatchMouseEvent', { | ||
type: 'mouseMoved', | ||
button: this._button, | ||
x: fromX + (this._x - fromX) * (i / steps), | ||
y: fromY + (this._y - fromY) * (i / steps), | ||
modifiers: this._keyboard._modifiers | ||
}); | ||
} | ||
} | ||
} | ||
/** | ||
* @param {!{button?: "left"|"right"|"middle", clickCount?: number}=} options | ||
*/ | ||
async down(options = {}) { | ||
const {button = 'left', clickCount = 1} = options; | ||
this._button = button; | ||
await this._client.send('Input.dispatchMouseEvent', { | ||
type: 'mousePressed', | ||
button, | ||
x: this._x, | ||
y: this._y, | ||
modifiers: this._keyboard._modifiers, | ||
clickCount | ||
}); | ||
} | ||
/** | ||
* @param {!{button?: "left"|"right"|"middle", clickCount?: number}=} options | ||
*/ | ||
async up(options = {}) { | ||
const {button = 'left', clickCount = 1} = options; | ||
this._button = 'none'; | ||
await this._client.send('Input.dispatchMouseEvent', { | ||
type: 'mouseReleased', | ||
button, | ||
x: this._x, | ||
y: this._y, | ||
modifiers: this._keyboard._modifiers, | ||
clickCount | ||
}); | ||
} | ||
/** | ||
* @param {number} x | ||
* @param {number} y | ||
* @param {!{delay?: number, button?: "left"|"right"|"middle", clickCount?: number}=} options | ||
*/ | ||
async click(x, y, options = {}) { | ||
const { delay = null } = options; | ||
if (delay !== null) { | ||
await Promise.all([ | ||
this.move(x, y), | ||
this.down(options), | ||
]); | ||
await new Promise(f => setTimeout(f, delay)); | ||
await this.up(options); | ||
} | ||
else { | ||
await Promise.all([ | ||
this.move(x, y), | ||
this.down(options), | ||
this.up(options), | ||
]); | ||
} | ||
} | ||
/** | ||
* @param {!{button?: "left"|"right"|"middle", clickCount?: number}=} options | ||
*/ | ||
async down(options = {}) { | ||
const { button = 'left', clickCount = 1 } = options; | ||
this._button = button; | ||
await this._client.send('Input.dispatchMouseEvent', { | ||
type: 'mousePressed', | ||
button, | ||
x: this._x, | ||
y: this._y, | ||
modifiers: this._keyboard._modifiers, | ||
clickCount | ||
}); | ||
} | ||
/** | ||
* @param {!{button?: "left"|"right"|"middle", clickCount?: number}=} options | ||
*/ | ||
async up(options = {}) { | ||
const { button = 'left', clickCount = 1 } = options; | ||
this._button = 'none'; | ||
await this._client.send('Input.dispatchMouseEvent', { | ||
type: 'mouseReleased', | ||
button, | ||
x: this._x, | ||
y: this._y, | ||
modifiers: this._keyboard._modifiers, | ||
clickCount | ||
}); | ||
} | ||
} | ||
class Touchscreen { | ||
/** | ||
* @param {Puppeteer.CDPSession} client | ||
* @param {Keyboard} keyboard | ||
*/ | ||
constructor(client, keyboard) { | ||
this._client = client; | ||
this._keyboard = keyboard; | ||
} | ||
/** | ||
* @param {number} x | ||
* @param {number} y | ||
*/ | ||
async tap(x, y) { | ||
// Touches appear to be lost during the first frame after navigation. | ||
// This waits a frame before sending the tap. | ||
// @see https://crbug.com/613219 | ||
await this._client.send('Runtime.evaluate', { | ||
expression: 'new Promise(x => requestAnimationFrame(() => requestAnimationFrame(x)))', | ||
awaitPromise: true | ||
}); | ||
const touchPoints = [{x: Math.round(x), y: Math.round(y)}]; | ||
await this._client.send('Input.dispatchTouchEvent', { | ||
type: 'touchStart', | ||
touchPoints, | ||
modifiers: this._keyboard._modifiers | ||
}); | ||
await this._client.send('Input.dispatchTouchEvent', { | ||
type: 'touchEnd', | ||
touchPoints: [], | ||
modifiers: this._keyboard._modifiers | ||
}); | ||
} | ||
/** | ||
* @param {Puppeteer.CDPSession} client | ||
* @param {Keyboard} keyboard | ||
*/ | ||
constructor(client, keyboard) { | ||
this._client = client; | ||
this._keyboard = keyboard; | ||
} | ||
/** | ||
* @param {number} x | ||
* @param {number} y | ||
*/ | ||
async tap(x, y) { | ||
// Touches appear to be lost during the first frame after navigation. | ||
// This waits a frame before sending the tap. | ||
// @see https://crbug.com/613219 | ||
await this._client.send('Runtime.evaluate', { | ||
expression: 'new Promise(x => requestAnimationFrame(() => requestAnimationFrame(x)))', | ||
awaitPromise: true | ||
}); | ||
const touchPoints = [{ x: Math.round(x), y: Math.round(y) }]; | ||
await this._client.send('Input.dispatchTouchEvent', { | ||
type: 'touchStart', | ||
touchPoints, | ||
modifiers: this._keyboard._modifiers | ||
}); | ||
await this._client.send('Input.dispatchTouchEvent', { | ||
type: 'touchEnd', | ||
touchPoints: [], | ||
modifiers: this._keyboard._modifiers | ||
}); | ||
} | ||
} | ||
module.exports = { Keyboard, Mouse, Touchscreen}; | ||
module.exports = { Keyboard, Mouse, Touchscreen }; |
1034
lib/JSHandle.js
@@ -16,570 +16,501 @@ /** | ||
*/ | ||
const {helper, assert, debugError} = require('./helper'); | ||
const { helper, assert, debugError } = require('./helper'); | ||
function createJSHandle(context, remoteObject) { | ||
const frame = context.frame(); | ||
if (remoteObject.subtype === 'node' && frame) { | ||
const frameManager = frame._frameManager; | ||
return new ElementHandle(context, context._client, remoteObject, frameManager.page(), frameManager); | ||
} | ||
return new JSHandle(context, context._client, remoteObject); | ||
const frame = context.frame(); | ||
if (remoteObject.subtype === 'node' && frame) { | ||
const frameManager = frame._frameManager; | ||
return new ElementHandle(context, context._client, remoteObject, frameManager.page(), frameManager); | ||
} | ||
return new JSHandle(context, context._client, remoteObject); | ||
} | ||
class JSHandle { | ||
/** | ||
* @param {!Puppeteer.ExecutionContext} context | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Protocol.Runtime.RemoteObject} remoteObject | ||
*/ | ||
constructor(context, client, remoteObject) { | ||
this._context = context; | ||
this._client = client; | ||
this._remoteObject = remoteObject; | ||
this._disposed = false; | ||
} | ||
/** | ||
* @return {!Puppeteer.ExecutionContext} | ||
*/ | ||
executionContext() { | ||
return this._context; | ||
} | ||
/** | ||
* @param {Function|String} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<(!Object|undefined)>} | ||
*/ | ||
async evaluate(pageFunction, ...args) { | ||
return await this.executionContext().evaluate(pageFunction, this, ...args); | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
async evaluateHandle(pageFunction, ...args) { | ||
return await this.executionContext().evaluateHandle(pageFunction, this, ...args); | ||
} | ||
/** | ||
* @param {string} propertyName | ||
* @return {!Promise<?JSHandle>} | ||
*/ | ||
async getProperty(propertyName) { | ||
const objectHandle = await this.evaluateHandle((object, propertyName) => { | ||
const result = {__proto__: null}; | ||
result[propertyName] = object[propertyName]; | ||
return result; | ||
}, 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._client.send('Runtime.getProperties', { | ||
objectId: this._remoteObject.objectId, | ||
ownProperties: true | ||
}); | ||
const result = new Map(); | ||
for (const property of response.result) { | ||
if (!property.enumerable) | ||
continue; | ||
result.set(property.name, createJSHandle(this._context, property.value)); | ||
/** | ||
* @param {!Puppeteer.ExecutionContext} context | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Protocol.Runtime.RemoteObject} remoteObject | ||
*/ | ||
constructor(context, client, remoteObject) { | ||
this._context = context; | ||
this._client = client; | ||
this._remoteObject = remoteObject; | ||
this._disposed = false; | ||
} | ||
return result; | ||
} | ||
/** | ||
* @return {!Promise<?Object>} | ||
*/ | ||
async jsonValue() { | ||
if (this._remoteObject.objectId) { | ||
const response = await this._client.send('Runtime.callFunctionOn', { | ||
functionDeclaration: 'function() { return this; }', | ||
objectId: this._remoteObject.objectId, | ||
returnByValue: true, | ||
awaitPromise: true, | ||
}); | ||
return helper.valueFromRemoteObject(response.result); | ||
/** | ||
* @return {!Puppeteer.ExecutionContext} | ||
*/ | ||
executionContext() { | ||
return this._context; | ||
} | ||
return helper.valueFromRemoteObject(this._remoteObject); | ||
} | ||
/** | ||
* @return {?Puppeteer.ElementHandle} | ||
*/ | ||
asElement() { | ||
return null; | ||
} | ||
async dispose() { | ||
if (this._disposed) | ||
return; | ||
this._disposed = true; | ||
await helper.releaseObject(this._client, this._remoteObject); | ||
} | ||
/** | ||
* @override | ||
* @return {string} | ||
*/ | ||
toString() { | ||
if (this._remoteObject.objectId) { | ||
const type = this._remoteObject.subtype || this._remoteObject.type; | ||
return 'JSHandle@' + type; | ||
/** | ||
* @param {Function|String} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<(!Object|undefined)>} | ||
*/ | ||
async evaluate(pageFunction, ...args) { | ||
return await this.executionContext().evaluate(pageFunction, this, ...args); | ||
} | ||
return 'JSHandle:' + helper.valueFromRemoteObject(this._remoteObject); | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
async evaluateHandle(pageFunction, ...args) { | ||
return await this.executionContext().evaluateHandle(pageFunction, this, ...args); | ||
} | ||
/** | ||
* @param {string} propertyName | ||
* @return {!Promise<?JSHandle>} | ||
*/ | ||
async getProperty(propertyName) { | ||
const objectHandle = await this.evaluateHandle((object, propertyName) => { | ||
const result = { __proto__: null }; | ||
result[propertyName] = object[propertyName]; | ||
return result; | ||
}, 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._client.send('Runtime.getProperties', { | ||
objectId: this._remoteObject.objectId, | ||
ownProperties: true | ||
}); | ||
const result = new Map(); | ||
for (const property of response.result) { | ||
if (!property.enumerable) | ||
continue; | ||
result.set(property.name, createJSHandle(this._context, property.value)); | ||
} | ||
return result; | ||
} | ||
/** | ||
* @return {!Promise<?Object>} | ||
*/ | ||
async jsonValue() { | ||
if (this._remoteObject.objectId) { | ||
const response = await this._client.send('Runtime.callFunctionOn', { | ||
functionDeclaration: 'function() { return this; }', | ||
objectId: this._remoteObject.objectId, | ||
returnByValue: true, | ||
awaitPromise: true, | ||
}); | ||
return helper.valueFromRemoteObject(response.result); | ||
} | ||
return helper.valueFromRemoteObject(this._remoteObject); | ||
} | ||
/** | ||
* @return {?Puppeteer.ElementHandle} | ||
*/ | ||
asElement() { | ||
return null; | ||
} | ||
async dispose() { | ||
if (this._disposed) | ||
return; | ||
this._disposed = true; | ||
await helper.releaseObject(this._client, this._remoteObject); | ||
} | ||
/** | ||
* @override | ||
* @return {string} | ||
*/ | ||
toString() { | ||
if (this._remoteObject.objectId) { | ||
const type = this._remoteObject.subtype || this._remoteObject.type; | ||
return 'JSHandle@' + type; | ||
} | ||
return 'JSHandle:' + helper.valueFromRemoteObject(this._remoteObject); | ||
} | ||
} | ||
class ElementHandle extends JSHandle { | ||
/** | ||
* @param {!Puppeteer.ExecutionContext} context | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Protocol.Runtime.RemoteObject} remoteObject | ||
* @param {!Puppeteer.Page} page | ||
* @param {!Puppeteer.FrameManager} frameManager | ||
*/ | ||
constructor(context, client, remoteObject, page, frameManager) { | ||
super(context, client, remoteObject); | ||
this._client = client; | ||
this._remoteObject = remoteObject; | ||
this._page = page; | ||
this._frameManager = frameManager; | ||
this._disposed = false; | ||
} | ||
/** | ||
* @override | ||
* @return {?ElementHandle} | ||
*/ | ||
asElement() { | ||
return this; | ||
} | ||
/** | ||
* @return {!Promise<?Puppeteer.Frame>} | ||
*/ | ||
async contentFrame() { | ||
const nodeInfo = await this._client.send('DOM.describeNode', { | ||
objectId: this._remoteObject.objectId | ||
}); | ||
if (typeof nodeInfo.node.frameId !== 'string') | ||
return null; | ||
return this._frameManager.frame(nodeInfo.node.frameId); | ||
} | ||
async _scrollIntoViewIfNeeded() { | ||
const error = await this.evaluate(async(element, pageJavascriptEnabled) => { | ||
if (!element.isConnected) | ||
return 'Node is detached from document'; | ||
if (element.nodeType !== Node.ELEMENT_NODE) | ||
return 'Node is not of type HTMLElement'; | ||
// force-scroll if page's javascript is disabled. | ||
if (!pageJavascriptEnabled) { | ||
element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); | ||
return false; | ||
} | ||
const visibleRatio = await new Promise(resolve => { | ||
const observer = new IntersectionObserver(entries => { | ||
resolve(entries[0].intersectionRatio); | ||
observer.disconnect(); | ||
/** | ||
* @param {!Puppeteer.ExecutionContext} context | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Protocol.Runtime.RemoteObject} remoteObject | ||
* @param {!Puppeteer.Page} page | ||
* @param {!Puppeteer.FrameManager} frameManager | ||
*/ | ||
constructor(context, client, remoteObject, page, frameManager) { | ||
super(context, client, remoteObject); | ||
this._client = client; | ||
this._remoteObject = remoteObject; | ||
this._page = page; | ||
this._frameManager = frameManager; | ||
this._disposed = false; | ||
} | ||
/** | ||
* @override | ||
* @return {?ElementHandle} | ||
*/ | ||
asElement() { | ||
return this; | ||
} | ||
/** | ||
* @return {!Promise<?Puppeteer.Frame>} | ||
*/ | ||
async contentFrame() { | ||
const nodeInfo = await this._client.send('DOM.describeNode', { | ||
objectId: this._remoteObject.objectId | ||
}); | ||
observer.observe(element); | ||
}); | ||
if (visibleRatio !== 1.0) | ||
element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); | ||
return false; | ||
}, this._page._javascriptEnabled); | ||
if (error) | ||
throw new Error(error); | ||
} | ||
/** | ||
* @return {!Promise<!{x: number, y: number}>} | ||
*/ | ||
async _clickablePoint() { | ||
const [result, layoutMetrics] = await Promise.all([ | ||
this._client.send('DOM.getContentQuads', { | ||
objectId: this._remoteObject.objectId | ||
}).catch(debugError), | ||
this._client.send('Page.getLayoutMetrics'), | ||
]); | ||
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 {clientWidth, clientHeight} = layoutMetrics.layoutViewport; | ||
const quads = result.quads.map(quad => this._fromProtocolQuad(quad)).map(quad => this._intersectQuadWithViewport(quad, clientWidth, clientHeight)).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. | ||
const quad = quads[0]; | ||
let x = 0; | ||
let y = 0; | ||
for (const point of quad) { | ||
x += point.x; | ||
y += point.y; | ||
if (typeof nodeInfo.node.frameId !== 'string') | ||
return null; | ||
return this._frameManager.frame(nodeInfo.node.frameId); | ||
} | ||
return { | ||
x: x / 4, | ||
y: y / 4 | ||
}; | ||
} | ||
/** | ||
* @return {!Promise<void|Protocol.DOM.getBoxModelReturnValue>} | ||
*/ | ||
_getBoxModel() { | ||
return this._client.send('DOM.getBoxModel', { | ||
objectId: this._remoteObject.objectId | ||
}).catch(error => debugError(error)); | ||
} | ||
/** | ||
* @param {!Array<number>} quad | ||
* @return {!Array<{x: number, y: number}>} | ||
*/ | ||
_fromProtocolQuad(quad) { | ||
return [ | ||
{x: quad[0], y: quad[1]}, | ||
{x: quad[2], y: quad[3]}, | ||
{x: quad[4], y: quad[5]}, | ||
{x: quad[6], y: quad[7]} | ||
]; | ||
} | ||
/** | ||
* @param {!Array<{x: number, y: number}>} quad | ||
* @param {number} width | ||
* @param {number} height | ||
* @return {!Array<{x: number, y: number}>} | ||
*/ | ||
_intersectQuadWithViewport(quad, width, height) { | ||
return quad.map(point => ({ | ||
x: Math.min(Math.max(point.x, 0), width), | ||
y: Math.min(Math.max(point.y, 0), height), | ||
})); | ||
} | ||
async hover() { | ||
await this._scrollIntoViewIfNeeded(); | ||
const {x, y} = await this._clickablePoint(); | ||
await this._page.mouse.move(x, y); | ||
} | ||
/** | ||
* @param {!{delay?: number, button?: "left"|"right"|"middle", clickCount?: number}=} options | ||
*/ | ||
async click(options) { | ||
await this._scrollIntoViewIfNeeded(); | ||
const {x, y} = await this._clickablePoint(); | ||
await this._page.mouse.click(x, y, options); | ||
} | ||
/** | ||
* @param {!Array<string>} values | ||
* @return {!Promise<!Array<string>>} | ||
*/ | ||
async select(...values) { | ||
for (const value of values) | ||
assert(helper.isString(value), 'Values must be strings. Found value "' + value + '" of type "' + (typeof value) + '"'); | ||
return this.evaluate((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 {!Array<string>} filePaths | ||
*/ | ||
async uploadFile(...filePaths) { | ||
const isMultiple = await this.evaluate(element => element.multiple); | ||
assert(filePaths.length <= 1 || isMultiple, 'Multiple file uploads only work with <input type=file multiple>'); | ||
// These imports are only needed for `uploadFile`, so keep them | ||
// scoped here to avoid paying the cost unnecessarily. | ||
const path = require('path'); | ||
const mime = require('mime-types'); | ||
const fs = require('fs'); | ||
const readFileAsync = helper.promisify(fs.readFile); | ||
const promises = filePaths.map(filePath => readFileAsync(filePath)); | ||
const files = []; | ||
for (let i = 0; i < filePaths.length; i++) { | ||
const buffer = await promises[i]; | ||
const filePath = path.basename(filePaths[i]); | ||
const file = { | ||
name: filePath, | ||
content: buffer.toString('base64'), | ||
mimeType: mime.lookup(filePath), | ||
}; | ||
files.push(file); | ||
async _scrollIntoViewIfNeeded() { | ||
const error = await this.evaluate(async (element, pageJavascriptEnabled) => { | ||
if (!element.isConnected) | ||
return 'Node is detached from document'; | ||
if (element.nodeType !== Node.ELEMENT_NODE) | ||
return 'Node is not of type HTMLElement'; | ||
// force-scroll if page's javascript is disabled. | ||
if (!pageJavascriptEnabled) { | ||
element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); | ||
return false; | ||
} | ||
const visibleRatio = await new Promise(resolve => { | ||
const observer = new IntersectionObserver(entries => { | ||
resolve(entries[0].intersectionRatio); | ||
observer.disconnect(); | ||
}); | ||
observer.observe(element); | ||
}); | ||
if (visibleRatio !== 1.0) | ||
element.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); | ||
return false; | ||
}, this._page._javascriptEnabled); | ||
if (error) | ||
throw new Error(error); | ||
} | ||
await this.evaluateHandle(async(element, files) => { | ||
const dt = new DataTransfer(); | ||
for (const item of files) { | ||
const response = await fetch(`data:${item.mimeType};base64,${item.content}`); | ||
const file = new File([await response.blob()], item.name); | ||
dt.items.add(file); | ||
} | ||
element.files = dt.files; | ||
element.dispatchEvent(new Event('input', { bubbles: true })); | ||
}, files); | ||
} | ||
async tap() { | ||
await this._scrollIntoViewIfNeeded(); | ||
const {x, y} = await this._clickablePoint(); | ||
await this._page.touchscreen.tap(x, y); | ||
} | ||
async focus() { | ||
await this.evaluate(element => element.focus()); | ||
} | ||
/** | ||
* @param {string} text | ||
* @param {{delay: (number|undefined)}=} options | ||
*/ | ||
async type(text, options) { | ||
await this.focus(); | ||
await this._page.keyboard.type(text, options); | ||
} | ||
/** | ||
* @param {string} key | ||
* @param {!{delay?: number, text?: string}=} options | ||
*/ | ||
async press(key, options) { | ||
await this.focus(); | ||
await this._page.keyboard.press(key, options); | ||
} | ||
/** | ||
* @return {!Promise<?{x: number, y: number, width: number, height: number}>} | ||
*/ | ||
async boundingBox() { | ||
const result = await this._getBoxModel(); | ||
if (!result) | ||
return null; | ||
const quad = result.model.border; | ||
const x = Math.min(quad[0], quad[2], quad[4], quad[6]); | ||
const y = Math.min(quad[1], quad[3], quad[5], quad[7]); | ||
const width = Math.max(quad[0], quad[2], quad[4], quad[6]) - x; | ||
const height = Math.max(quad[1], quad[3], quad[5], quad[7]) - y; | ||
return {x, y, width, height}; | ||
} | ||
/** | ||
* @return {!Promise<?BoxModel>} | ||
*/ | ||
async boxModel() { | ||
const result = await this._getBoxModel(); | ||
if (!result) | ||
return null; | ||
const {content, padding, border, margin, width, height} = result.model; | ||
return { | ||
content: this._fromProtocolQuad(content), | ||
padding: this._fromProtocolQuad(padding), | ||
border: this._fromProtocolQuad(border), | ||
margin: this._fromProtocolQuad(margin), | ||
width, | ||
height | ||
}; | ||
} | ||
/** | ||
* | ||
* @param {!Object=} options | ||
* @returns {!Promise<string|!Buffer>} | ||
*/ | ||
async screenshot(options = {}) { | ||
let needsViewportReset = false; | ||
let boundingBox = await this.boundingBox(); | ||
assert(boundingBox, 'Node is either not visible or not an HTMLElement'); | ||
const viewport = this._page.viewport(); | ||
if (viewport && (boundingBox.width > viewport.width || boundingBox.height > viewport.height)) { | ||
const newViewport = { | ||
width: Math.max(viewport.width, Math.ceil(boundingBox.width)), | ||
height: Math.max(viewport.height, Math.ceil(boundingBox.height)), | ||
}; | ||
await this._page.setViewport(Object.assign({}, viewport, newViewport)); | ||
needsViewportReset = true; | ||
/** | ||
* @return {!Promise<!{x: number, y: number}>} | ||
*/ | ||
async _clickablePoint() { | ||
const [result, layoutMetrics] = await Promise.all([ | ||
this._client.send('DOM.getContentQuads', { | ||
objectId: this._remoteObject.objectId | ||
}).catch(debugError), | ||
this._client.send('Page.getLayoutMetrics'), | ||
]); | ||
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 { clientWidth, clientHeight } = layoutMetrics.layoutViewport; | ||
const quads = result.quads.map(quad => this._fromProtocolQuad(quad)).map(quad => this._intersectQuadWithViewport(quad, clientWidth, clientHeight)).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. | ||
const quad = quads[0]; | ||
let x = 0; | ||
let y = 0; | ||
for (const point of quad) { | ||
x += point.x; | ||
y += point.y; | ||
} | ||
return { | ||
x: x / 4, | ||
y: y / 4 | ||
}; | ||
} | ||
await this._scrollIntoViewIfNeeded(); | ||
boundingBox = await this.boundingBox(); | ||
assert(boundingBox, 'Node is either not visible or not an HTMLElement'); | ||
assert(boundingBox.width !== 0, 'Node has 0 width.'); | ||
assert(boundingBox.height !== 0, 'Node has 0 height.'); | ||
const { layoutViewport: { pageX, pageY } } = await this._client.send('Page.getLayoutMetrics'); | ||
const clip = Object.assign({}, boundingBox); | ||
clip.x += pageX; | ||
clip.y += pageY; | ||
const imageData = await this._page.screenshot(Object.assign({}, { | ||
clip | ||
}, options)); | ||
if (needsViewportReset) | ||
await this._page.setViewport(viewport); | ||
return imageData; | ||
} | ||
/** | ||
* @param {string} selector | ||
* @return {!Promise<?ElementHandle>} | ||
*/ | ||
async $(selector) { | ||
const handle = await this.evaluateHandle( | ||
(element, selector) => element.querySelector(selector), | ||
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.evaluateHandle( | ||
(element, selector) => element.querySelectorAll(selector), | ||
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 {!Promise<void|Protocol.DOM.getBoxModelReturnValue>} | ||
*/ | ||
_getBoxModel() { | ||
return this._client.send('DOM.getBoxModel', { | ||
objectId: this._remoteObject.objectId | ||
}).catch(error => debugError(error)); | ||
} | ||
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 elementHandle.evaluate(pageFunction, ...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.evaluateHandle( | ||
(element, selector) => Array.from(element.querySelectorAll(selector)), | ||
selector | ||
); | ||
const result = await arrayHandle.evaluate(pageFunction, ...args); | ||
await arrayHandle.dispose(); | ||
return result; | ||
} | ||
/** | ||
* @param {string} expression | ||
* @return {!Promise<!Array<!ElementHandle>>} | ||
*/ | ||
async $x(expression) { | ||
const arrayHandle = await this.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; | ||
}, | ||
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); | ||
/** | ||
* @param {!Array<number>} quad | ||
* @return {!Array<{x: number, y: number}>} | ||
*/ | ||
_fromProtocolQuad(quad) { | ||
return [ | ||
{ x: quad[0], y: quad[1] }, | ||
{ x: quad[2], y: quad[3] }, | ||
{ x: quad[4], y: quad[5] }, | ||
{ x: quad[6], y: quad[7] } | ||
]; | ||
} | ||
return result; | ||
} | ||
/** | ||
* @returns {!Promise<boolean>} | ||
*/ | ||
isIntersectingViewport() { | ||
return this.evaluate(async element => { | ||
const visibleRatio = await new Promise(resolve => { | ||
const observer = new IntersectionObserver(entries => { | ||
resolve(entries[0].intersectionRatio); | ||
observer.disconnect(); | ||
/** | ||
* @param {!Array<{x: number, y: number}>} quad | ||
* @param {number} width | ||
* @param {number} height | ||
* @return {!Array<{x: number, y: number}>} | ||
*/ | ||
_intersectQuadWithViewport(quad, width, height) { | ||
return quad.map(point => ({ | ||
x: Math.min(Math.max(point.x, 0), width), | ||
y: Math.min(Math.max(point.y, 0), height), | ||
})); | ||
} | ||
async hover() { | ||
await this._scrollIntoViewIfNeeded(); | ||
const { x, y } = await this._clickablePoint(); | ||
await this._page.mouse.move(x, y); | ||
} | ||
/** | ||
* @param {!{delay?: number, button?: "left"|"right"|"middle", clickCount?: number}=} options | ||
*/ | ||
async click(options) { | ||
await this._scrollIntoViewIfNeeded(); | ||
const { x, y } = await this._clickablePoint(); | ||
await this._page.mouse.click(x, y, options); | ||
} | ||
/** | ||
* @param {!Array<string>} values | ||
* @return {!Promise<!Array<string>>} | ||
*/ | ||
async select(...values) { | ||
for (const value of values) | ||
assert(helper.isString(value), 'Values must be strings. Found value "' + value + '" of type "' + (typeof value) + '"'); | ||
return this.evaluate((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 {!Array<string>} filePaths | ||
*/ | ||
async uploadFile(...filePaths) { | ||
const isMultiple = await this.evaluate(element => element.multiple); | ||
assert(filePaths.length <= 1 || isMultiple, 'Multiple file uploads only work with <input type=file multiple>'); | ||
// These imports are only needed for `uploadFile`, so keep them | ||
// scoped here to avoid paying the cost unnecessarily. | ||
const path = require('path'); | ||
const mime = require('mime-types'); | ||
const fs = require('fs'); | ||
const readFileAsync = helper.promisify(fs.readFile); | ||
const promises = filePaths.map(filePath => readFileAsync(filePath)); | ||
const files = []; | ||
for (let i = 0; i < filePaths.length; i++) { | ||
const buffer = await promises[i]; | ||
const filePath = path.basename(filePaths[i]); | ||
const file = { | ||
name: filePath, | ||
content: buffer.toString('base64'), | ||
mimeType: mime.lookup(filePath), | ||
}; | ||
files.push(file); | ||
} | ||
await this.evaluateHandle(async (element, files) => { | ||
const dt = new DataTransfer(); | ||
for (const item of files) { | ||
const response = await fetch(`data:${item.mimeType};base64,${item.content}`); | ||
const file = new File([await response.blob()], item.name); | ||
dt.items.add(file); | ||
} | ||
element.files = dt.files; | ||
element.dispatchEvent(new Event('input', { bubbles: true })); | ||
element.dispatchEvent(new Event('change', { bubbles: true })); | ||
}, files); | ||
} | ||
async tap() { | ||
await this._scrollIntoViewIfNeeded(); | ||
const { x, y } = await this._clickablePoint(); | ||
await this._page.touchscreen.tap(x, y); | ||
} | ||
async focus() { | ||
await this.evaluate(element => element.focus()); | ||
} | ||
/** | ||
* @param {string} text | ||
* @param {{delay: (number|undefined)}=} options | ||
*/ | ||
async type(text, options) { | ||
await this.focus(); | ||
await this._page.keyboard.type(text, options); | ||
} | ||
/** | ||
* @param {string} key | ||
* @param {!{delay?: number, text?: string}=} options | ||
*/ | ||
async press(key, options) { | ||
await this.focus(); | ||
await this._page.keyboard.press(key, options); | ||
} | ||
/** | ||
* @return {!Promise<?{x: number, y: number, width: number, height: number}>} | ||
*/ | ||
async boundingBox() { | ||
const result = await this._getBoxModel(); | ||
if (!result) | ||
return null; | ||
const quad = result.model.border; | ||
const x = Math.min(quad[0], quad[2], quad[4], quad[6]); | ||
const y = Math.min(quad[1], quad[3], quad[5], quad[7]); | ||
const width = Math.max(quad[0], quad[2], quad[4], quad[6]) - x; | ||
const height = Math.max(quad[1], quad[3], quad[5], quad[7]) - y; | ||
return { x, y, width, height }; | ||
} | ||
/** | ||
* @return {!Promise<?BoxModel>} | ||
*/ | ||
async boxModel() { | ||
const result = await this._getBoxModel(); | ||
if (!result) | ||
return null; | ||
const { content, padding, border, margin, width, height } = result.model; | ||
return { | ||
content: this._fromProtocolQuad(content), | ||
padding: this._fromProtocolQuad(padding), | ||
border: this._fromProtocolQuad(border), | ||
margin: this._fromProtocolQuad(margin), | ||
width, | ||
height | ||
}; | ||
} | ||
/** | ||
* | ||
* @param {!Object=} options | ||
* @returns {!Promise<string|!Buffer>} | ||
*/ | ||
async screenshot(options = {}) { | ||
let needsViewportReset = false; | ||
let boundingBox = await this.boundingBox(); | ||
assert(boundingBox, 'Node is either not visible or not an HTMLElement'); | ||
const viewport = this._page.viewport(); | ||
if (viewport && (boundingBox.width > viewport.width || boundingBox.height > viewport.height)) { | ||
const newViewport = { | ||
width: Math.max(viewport.width, Math.ceil(boundingBox.width)), | ||
height: Math.max(viewport.height, Math.ceil(boundingBox.height)), | ||
}; | ||
await this._page.setViewport(Object.assign({}, viewport, newViewport)); | ||
needsViewportReset = true; | ||
} | ||
await this._scrollIntoViewIfNeeded(); | ||
boundingBox = await this.boundingBox(); | ||
assert(boundingBox, 'Node is either not visible or not an HTMLElement'); | ||
assert(boundingBox.width !== 0, 'Node has 0 width.'); | ||
assert(boundingBox.height !== 0, 'Node has 0 height.'); | ||
const { layoutViewport: { pageX, pageY } } = await this._client.send('Page.getLayoutMetrics'); | ||
const clip = Object.assign({}, boundingBox); | ||
clip.x += pageX; | ||
clip.y += pageY; | ||
const imageData = await this._page.screenshot(Object.assign({}, { | ||
clip | ||
}, options)); | ||
if (needsViewportReset) | ||
await this._page.setViewport(viewport); | ||
return imageData; | ||
} | ||
/** | ||
* @param {string} selector | ||
* @return {!Promise<?ElementHandle>} | ||
*/ | ||
async $(selector) { | ||
const handle = await this.evaluateHandle((element, selector) => element.querySelector(selector), 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.evaluateHandle((element, selector) => element.querySelectorAll(selector), 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 elementHandle.evaluate(pageFunction, ...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.evaluateHandle((element, selector) => Array.from(element.querySelectorAll(selector)), selector); | ||
const result = await arrayHandle.evaluate(pageFunction, ...args); | ||
await arrayHandle.dispose(); | ||
return result; | ||
} | ||
/** | ||
* @param {string} expression | ||
* @return {!Promise<!Array<!ElementHandle>>} | ||
*/ | ||
async $x(expression) { | ||
const arrayHandle = await this.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; | ||
}, 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; | ||
} | ||
/** | ||
* @returns {!Promise<boolean>} | ||
*/ | ||
isIntersectingViewport() { | ||
return this.evaluate(async (element) => { | ||
const visibleRatio = await new Promise(resolve => { | ||
const observer = new IntersectionObserver(entries => { | ||
resolve(entries[0].intersectionRatio); | ||
observer.disconnect(); | ||
}); | ||
observer.observe(element); | ||
}); | ||
return visibleRatio > 0; | ||
}); | ||
observer.observe(element); | ||
}); | ||
return visibleRatio > 0; | ||
}); | ||
} | ||
} | ||
} | ||
function computeQuadArea(quad) { | ||
// Compute sum of all directed areas of adjacent triangles | ||
// https://en.wikipedia.org/wiki/Polygon#Simple_polygons | ||
let area = 0; | ||
for (let i = 0; i < quad.length; ++i) { | ||
const p1 = quad[i]; | ||
const p2 = quad[(i + 1) % quad.length]; | ||
area += (p1.x * p2.y - p2.x * p1.y) / 2; | ||
} | ||
return Math.abs(area); | ||
// Compute sum of all directed areas of adjacent triangles | ||
// https://en.wikipedia.org/wiki/Polygon#Simple_polygons | ||
let area = 0; | ||
for (let i = 0; i < quad.length; ++i) { | ||
const p1 = quad[i]; | ||
const p2 = quad[(i + 1) % quad.length]; | ||
area += (p1.x * p2.y - p2.x * p1.y) / 2; | ||
} | ||
return Math.abs(area); | ||
} | ||
/** | ||
@@ -594,3 +525,2 @@ * @typedef {Object} BoxModel | ||
*/ | ||
module.exports = {createJSHandle, JSHandle, ElementHandle}; | ||
module.exports = { createJSHandle, JSHandle, ElementHandle }; |
1402
lib/Launcher.js
@@ -24,156 +24,139 @@ /** | ||
const BrowserFetcher = require('./BrowserFetcher'); | ||
const {Connection} = require('./Connection'); | ||
const {Browser} = require('./Browser'); | ||
const { Connection } = require('./Connection'); | ||
const { Browser } = require('./Browser'); | ||
const readline = require('readline'); | ||
const fs = require('fs'); | ||
const {helper, assert, debugError} = require('./helper'); | ||
const { helper, assert, debugError } = require('./helper'); | ||
const debugLauncher = require('debug')(`puppeteer:launcher`); | ||
const {TimeoutError} = require('./Errors'); | ||
const { TimeoutError } = require('./Errors'); | ||
const WebSocketTransport = require('./WebSocketTransport'); | ||
const PipeTransport = require('./PipeTransport'); | ||
const mkdtempAsync = helper.promisify(fs.mkdtemp); | ||
const removeFolderAsync = helper.promisify(removeFolder); | ||
const writeFileAsync = helper.promisify(fs.writeFile); | ||
class BrowserRunner { | ||
/** | ||
* @param {string} executablePath | ||
* @param {!Array<string>} processArguments | ||
* @param {string=} tempDirectory | ||
*/ | ||
constructor(executablePath, processArguments, tempDirectory) { | ||
this._executablePath = executablePath; | ||
this._processArguments = processArguments; | ||
this._tempDirectory = tempDirectory; | ||
this.proc = null; | ||
this.connection = null; | ||
this._closed = true; | ||
this._listeners = []; | ||
} | ||
/** | ||
* @param {!(Launcher.LaunchOptions)=} options | ||
*/ | ||
start(options = {}) { | ||
const { | ||
handleSIGINT, | ||
handleSIGTERM, | ||
handleSIGHUP, | ||
dumpio, | ||
env, | ||
pipe | ||
} = options; | ||
/** @type {!Array<"ignore"|"pipe">} */ | ||
let stdio = ['pipe', 'pipe', 'pipe']; | ||
if (pipe) { | ||
if (dumpio) | ||
stdio = ['ignore', 'pipe', 'pipe', 'pipe', 'pipe']; | ||
else | ||
stdio = ['ignore', 'ignore', 'ignore', 'pipe', 'pipe']; | ||
/** | ||
* @param {string} executablePath | ||
* @param {!Array<string>} processArguments | ||
* @param {string=} tempDirectory | ||
*/ | ||
constructor(executablePath, processArguments, tempDirectory) { | ||
this._executablePath = executablePath; | ||
this._processArguments = processArguments; | ||
this._tempDirectory = tempDirectory; | ||
this.proc = null; | ||
this.connection = null; | ||
this._closed = true; | ||
this._listeners = []; | ||
} | ||
assert(!this.proc, 'This process has previously been started.'); | ||
debugLauncher(`Calling ${this._executablePath} ${this._processArguments.join(' ')}`); | ||
this.proc = childProcess.spawn( | ||
this._executablePath, | ||
this._processArguments, | ||
{ | ||
// On non-windows platforms, `detached: true` makes child process a leader of a new | ||
// process group, making it possible to kill child process tree with `.kill(-pid)` command. | ||
// @see https://nodejs.org/api/child_process.html#child_process_options_detached | ||
detached: process.platform !== 'win32', | ||
env, | ||
stdio | ||
/** | ||
* @param {!(Launcher.LaunchOptions)=} options | ||
*/ | ||
start(options = {}) { | ||
const { handleSIGINT, handleSIGTERM, handleSIGHUP, dumpio, env, pipe } = options; | ||
/** @type {!Array<"ignore"|"pipe">} */ | ||
let stdio = ['pipe', 'pipe', 'pipe']; | ||
if (pipe) { | ||
if (dumpio) | ||
stdio = ['ignore', 'pipe', 'pipe', 'pipe', 'pipe']; | ||
else | ||
stdio = ['ignore', 'ignore', 'ignore', 'pipe', 'pipe']; | ||
} | ||
); | ||
if (dumpio) { | ||
this.proc.stderr.pipe(process.stderr); | ||
this.proc.stdout.pipe(process.stdout); | ||
assert(!this.proc, 'This process has previously been started.'); | ||
debugLauncher(`Calling ${this._executablePath} ${this._processArguments.join(' ')}`); | ||
this.proc = childProcess.spawn(this._executablePath, this._processArguments, { | ||
// On non-windows platforms, `detached: true` makes child process a leader of a new | ||
// process group, making it possible to kill child process tree with `.kill(-pid)` command. | ||
// @see https://nodejs.org/api/child_process.html#child_process_options_detached | ||
detached: process.platform !== 'win32', | ||
env, | ||
stdio | ||
}); | ||
if (dumpio) { | ||
this.proc.stderr.pipe(process.stderr); | ||
this.proc.stdout.pipe(process.stdout); | ||
} | ||
this._closed = false; | ||
this._processClosing = new Promise((fulfill, reject) => { | ||
this.proc.once('exit', () => { | ||
this._closed = true; | ||
// Cleanup as processes exit. | ||
if (this._tempDirectory) { | ||
removeFolderAsync(this._tempDirectory) | ||
.then(() => fulfill()) | ||
.catch(err => console.error(err)); | ||
} | ||
else { | ||
fulfill(); | ||
} | ||
}); | ||
}); | ||
this._listeners = [helper.addEventListener(process, 'exit', this.kill.bind(this))]; | ||
if (handleSIGINT) | ||
this._listeners.push(helper.addEventListener(process, 'SIGINT', () => { this.kill(); process.exit(130); })); | ||
if (handleSIGTERM) | ||
this._listeners.push(helper.addEventListener(process, 'SIGTERM', this.close.bind(this))); | ||
if (handleSIGHUP) | ||
this._listeners.push(helper.addEventListener(process, 'SIGHUP', this.close.bind(this))); | ||
} | ||
this._closed = false; | ||
this._processClosing = new Promise((fulfill, reject) => { | ||
this.proc.once('exit', () => { | ||
this._closed = true; | ||
// Cleanup as processes exit. | ||
/** | ||
* @return {Promise} | ||
*/ | ||
close() { | ||
if (this._closed) | ||
return Promise.resolve(); | ||
helper.removeEventListeners(this._listeners); | ||
if (this._tempDirectory) { | ||
removeFolderAsync(this._tempDirectory) | ||
.then(() => fulfill()) | ||
.catch(err => console.error(err)); | ||
} else { | ||
fulfill(); | ||
this.kill(); | ||
} | ||
}); | ||
}); | ||
this._listeners = [ helper.addEventListener(process, 'exit', this.kill.bind(this)) ]; | ||
if (handleSIGINT) | ||
this._listeners.push(helper.addEventListener(process, 'SIGINT', () => { this.kill(); process.exit(130); })); | ||
if (handleSIGTERM) | ||
this._listeners.push(helper.addEventListener(process, 'SIGTERM', this.close.bind(this))); | ||
if (handleSIGHUP) | ||
this._listeners.push(helper.addEventListener(process, 'SIGHUP', this.close.bind(this))); | ||
} | ||
/** | ||
* @return {Promise} | ||
*/ | ||
close() { | ||
if (this._closed) | ||
return Promise.resolve(); | ||
helper.removeEventListeners(this._listeners); | ||
if (this._tempDirectory) { | ||
this.kill(); | ||
} else if (this.connection) { | ||
// Attempt to close the browser gracefully | ||
this.connection.send('Browser.close').catch(error => { | ||
debugError(error); | ||
this.kill(); | ||
}); | ||
else if (this.connection) { | ||
// Attempt to close the browser gracefully | ||
this.connection.send('Browser.close').catch(error => { | ||
debugError(error); | ||
this.kill(); | ||
}); | ||
} | ||
return this._processClosing; | ||
} | ||
return this._processClosing; | ||
} | ||
// This function has to be sync to be used as 'exit' event handler. | ||
kill() { | ||
helper.removeEventListeners(this._listeners); | ||
if (this.proc && this.proc.pid && !this.proc.killed && !this._closed) { | ||
try { | ||
if (process.platform === 'win32') | ||
childProcess.execSync(`taskkill /pid ${this.proc.pid} /T /F`); | ||
else | ||
process.kill(-this.proc.pid, 'SIGKILL'); | ||
} catch (error) { | ||
// the process might have already stopped | ||
} | ||
// This function has to be sync to be used as 'exit' event handler. | ||
kill() { | ||
helper.removeEventListeners(this._listeners); | ||
if (this.proc && this.proc.pid && !this.proc.killed && !this._closed) { | ||
try { | ||
if (process.platform === 'win32') | ||
childProcess.execSync(`taskkill /pid ${this.proc.pid} /T /F`); | ||
else | ||
process.kill(-this.proc.pid, 'SIGKILL'); | ||
} | ||
catch (error) { | ||
// the process might have already stopped | ||
} | ||
} | ||
// Attempt to remove temporary profile directory to avoid littering. | ||
try { | ||
removeFolder.sync(this._tempDirectory); | ||
} | ||
catch (error) { } | ||
} | ||
// Attempt to remove temporary profile directory to avoid littering. | ||
try { | ||
removeFolder.sync(this._tempDirectory); | ||
} catch (error) { } | ||
} | ||
/** | ||
* @param {!({usePipe?: boolean, timeout: number, slowMo: number, preferredRevision: string})} options | ||
* | ||
* @return {!Promise<!Connection>} | ||
*/ | ||
async setupConnection(options) { | ||
const { | ||
usePipe, | ||
timeout, | ||
slowMo, | ||
preferredRevision | ||
} = options; | ||
if (!usePipe) { | ||
const browserWSEndpoint = await waitForWSEndpoint(this.proc, timeout, preferredRevision); | ||
const transport = await WebSocketTransport.create(browserWSEndpoint); | ||
this.connection = new Connection(browserWSEndpoint, transport, slowMo); | ||
} else { | ||
const transport = new PipeTransport(/** @type {!NodeJS.WritableStream} */(this.proc.stdio[3]), /** @type {!NodeJS.ReadableStream} */ (this.proc.stdio[4])); | ||
this.connection = new Connection('', transport, slowMo); | ||
/** | ||
* @param {!({usePipe?: boolean, timeout: number, slowMo: number, preferredRevision: string})} options | ||
* | ||
* @return {!Promise<!Connection>} | ||
*/ | ||
async setupConnection(options) { | ||
const { usePipe, timeout, slowMo, preferredRevision } = options; | ||
if (!usePipe) { | ||
const browserWSEndpoint = await waitForWSEndpoint(this.proc, timeout, preferredRevision); | ||
const transport = await WebSocketTransport.create(browserWSEndpoint); | ||
this.connection = new Connection(browserWSEndpoint, transport, slowMo); | ||
} | ||
else { | ||
// stdio was assigned during start(), and the 'pipe' option there adds the 4th and 5th items to stdio array | ||
const { 3: pipeWrite, 4: pipeRead } = /** @type {!Array<any>} */ (this.proc.stdio); | ||
const transport = new PipeTransport(/** @type {!NodeJS.WritableStream} */ pipeWrite, /** @type {!NodeJS.ReadableStream} */ pipeRead); | ||
this.connection = new Connection('', transport, slowMo); | ||
} | ||
return this.connection; | ||
} | ||
return this.connection; | ||
} | ||
} | ||
/** | ||
@@ -183,176 +166,133 @@ * @implements {!Puppeteer.ProductLauncher} | ||
class ChromeLauncher { | ||
/** | ||
* @param {string} projectRoot | ||
* @param {string} preferredRevision | ||
* @param {boolean} isPuppeteerCore | ||
*/ | ||
constructor(projectRoot, preferredRevision, isPuppeteerCore) { | ||
this._projectRoot = projectRoot; | ||
this._preferredRevision = preferredRevision; | ||
this._isPuppeteerCore = isPuppeteerCore; | ||
} | ||
/** | ||
* @param {!(Launcher.LaunchOptions & Launcher.ChromeArgOptions & Launcher.BrowserOptions)=} options | ||
* @return {!Promise<!Browser>} | ||
*/ | ||
async launch(options = {}) { | ||
const { | ||
ignoreDefaultArgs = false, | ||
args = [], | ||
dumpio = false, | ||
executablePath = null, | ||
pipe = false, | ||
env = process.env, | ||
handleSIGINT = true, | ||
handleSIGTERM = true, | ||
handleSIGHUP = true, | ||
ignoreHTTPSErrors = false, | ||
defaultViewport = {width: 800, height: 600}, | ||
slowMo = 0, | ||
timeout = 30000 | ||
} = options; | ||
const profilePath = path.join(os.tmpdir(), 'puppeteer_dev_chrome_profile-'); | ||
const chromeArguments = []; | ||
if (!ignoreDefaultArgs) | ||
chromeArguments.push(...this.defaultArgs(options)); | ||
else if (Array.isArray(ignoreDefaultArgs)) | ||
chromeArguments.push(...this.defaultArgs(options).filter(arg => !ignoreDefaultArgs.includes(arg))); | ||
else | ||
chromeArguments.push(...args); | ||
let temporaryUserDataDir = null; | ||
if (!chromeArguments.some(argument => argument.startsWith('--remote-debugging-'))) | ||
chromeArguments.push(pipe ? '--remote-debugging-pipe' : '--remote-debugging-port=0'); | ||
if (!chromeArguments.some(arg => arg.startsWith('--user-data-dir'))) { | ||
temporaryUserDataDir = await mkdtempAsync(profilePath); | ||
chromeArguments.push(`--user-data-dir=${temporaryUserDataDir}`); | ||
/** | ||
* @param {string} projectRoot | ||
* @param {string} preferredRevision | ||
* @param {boolean} isPuppeteerCore | ||
*/ | ||
constructor(projectRoot, preferredRevision, isPuppeteerCore) { | ||
this._projectRoot = projectRoot; | ||
this._preferredRevision = preferredRevision; | ||
this._isPuppeteerCore = isPuppeteerCore; | ||
} | ||
let chromeExecutable = executablePath; | ||
if (!executablePath) { | ||
const {missingText, executablePath} = resolveExecutablePath(this); | ||
if (missingText) | ||
throw new Error(missingText); | ||
chromeExecutable = executablePath; | ||
/** | ||
* @param {!(Launcher.LaunchOptions & Launcher.ChromeArgOptions & Launcher.BrowserOptions)=} options | ||
* @return {!Promise<!Browser>} | ||
*/ | ||
async launch(options = {}) { | ||
const { ignoreDefaultArgs = false, args = [], dumpio = false, executablePath = null, pipe = false, env = process.env, handleSIGINT = true, handleSIGTERM = true, handleSIGHUP = true, ignoreHTTPSErrors = false, defaultViewport = { width: 800, height: 600 }, slowMo = 0, timeout = 30000 } = options; | ||
const profilePath = path.join(os.tmpdir(), 'puppeteer_dev_chrome_profile-'); | ||
const chromeArguments = []; | ||
if (!ignoreDefaultArgs) | ||
chromeArguments.push(...this.defaultArgs(options)); | ||
else if (Array.isArray(ignoreDefaultArgs)) | ||
chromeArguments.push(...this.defaultArgs(options).filter(arg => !ignoreDefaultArgs.includes(arg))); | ||
else | ||
chromeArguments.push(...args); | ||
let temporaryUserDataDir = null; | ||
if (!chromeArguments.some(argument => argument.startsWith('--remote-debugging-'))) | ||
chromeArguments.push(pipe ? '--remote-debugging-pipe' : '--remote-debugging-port=0'); | ||
if (!chromeArguments.some(arg => arg.startsWith('--user-data-dir'))) { | ||
temporaryUserDataDir = await mkdtempAsync(profilePath); | ||
chromeArguments.push(`--user-data-dir=${temporaryUserDataDir}`); | ||
} | ||
let chromeExecutable = executablePath; | ||
if (!executablePath) { | ||
const { missingText, executablePath } = resolveExecutablePath(this); | ||
if (missingText) | ||
throw new Error(missingText); | ||
chromeExecutable = executablePath; | ||
} | ||
const usePipe = chromeArguments.includes('--remote-debugging-pipe'); | ||
const runner = new BrowserRunner(chromeExecutable, chromeArguments, temporaryUserDataDir); | ||
runner.start({ handleSIGHUP, handleSIGTERM, handleSIGINT, dumpio, env, pipe: usePipe }); | ||
try { | ||
const connection = await runner.setupConnection({ usePipe, timeout, slowMo, preferredRevision: this._preferredRevision }); | ||
const browser = await Browser.create(connection, [], ignoreHTTPSErrors, defaultViewport, runner.proc, runner.close.bind(runner)); | ||
await browser.waitForTarget(t => t.type() === 'page'); | ||
return browser; | ||
} | ||
catch (error) { | ||
runner.kill(); | ||
throw error; | ||
} | ||
} | ||
const usePipe = chromeArguments.includes('--remote-debugging-pipe'); | ||
const runner = new BrowserRunner(chromeExecutable, chromeArguments, temporaryUserDataDir); | ||
runner.start({handleSIGHUP, handleSIGTERM, handleSIGINT, dumpio, env, pipe: usePipe}); | ||
try { | ||
const connection = await runner.setupConnection({usePipe, timeout, slowMo, preferredRevision: this._preferredRevision}); | ||
const browser = await Browser.create(connection, [], ignoreHTTPSErrors, defaultViewport, runner.proc, runner.close.bind(runner)); | ||
await browser.waitForTarget(t => t.type() === 'page'); | ||
return browser; | ||
} catch (error) { | ||
runner.kill(); | ||
throw error; | ||
/** | ||
* @param {!Launcher.ChromeArgOptions=} options | ||
* @return {!Array<string>} | ||
*/ | ||
defaultArgs(options = {}) { | ||
const chromeArguments = [ | ||
'--disable-background-networking', | ||
'--enable-features=NetworkService,NetworkServiceInProcess', | ||
'--disable-background-timer-throttling', | ||
'--disable-backgrounding-occluded-windows', | ||
'--disable-breakpad', | ||
'--disable-client-side-phishing-detection', | ||
'--disable-component-extensions-with-background-pages', | ||
'--disable-default-apps', | ||
'--disable-dev-shm-usage', | ||
'--disable-extensions', | ||
'--disable-features=TranslateUI', | ||
'--disable-hang-monitor', | ||
'--disable-ipc-flooding-protection', | ||
'--disable-popup-blocking', | ||
'--disable-prompt-on-repost', | ||
'--disable-renderer-backgrounding', | ||
'--disable-sync', | ||
'--force-color-profile=srgb', | ||
'--metrics-recording-only', | ||
'--no-first-run', | ||
'--enable-automation', | ||
'--password-store=basic', | ||
'--use-mock-keychain', | ||
]; | ||
const { devtools = false, headless = !devtools, args = [], userDataDir = null } = options; | ||
if (userDataDir) | ||
chromeArguments.push(`--user-data-dir=${userDataDir}`); | ||
if (devtools) | ||
chromeArguments.push('--auto-open-devtools-for-tabs'); | ||
if (headless) { | ||
chromeArguments.push('--headless', '--hide-scrollbars', '--mute-audio'); | ||
} | ||
if (args.every(arg => arg.startsWith('-'))) | ||
chromeArguments.push('about:blank'); | ||
chromeArguments.push(...args); | ||
return chromeArguments; | ||
} | ||
} | ||
/** | ||
* @param {!Launcher.ChromeArgOptions=} options | ||
* @return {!Array<string>} | ||
*/ | ||
defaultArgs(options = {}) { | ||
const chromeArguments = [ | ||
'--disable-background-networking', | ||
'--enable-features=NetworkService,NetworkServiceInProcess', | ||
'--disable-background-timer-throttling', | ||
'--disable-backgrounding-occluded-windows', | ||
'--disable-breakpad', | ||
'--disable-client-side-phishing-detection', | ||
'--disable-component-extensions-with-background-pages', | ||
'--disable-default-apps', | ||
'--disable-dev-shm-usage', | ||
'--disable-extensions', | ||
'--disable-features=TranslateUI', | ||
'--disable-hang-monitor', | ||
'--disable-ipc-flooding-protection', | ||
'--disable-popup-blocking', | ||
'--disable-prompt-on-repost', | ||
'--disable-renderer-backgrounding', | ||
'--disable-sync', | ||
'--force-color-profile=srgb', | ||
'--metrics-recording-only', | ||
'--no-first-run', | ||
'--enable-automation', | ||
'--password-store=basic', | ||
'--use-mock-keychain', | ||
]; | ||
const { | ||
devtools = false, | ||
headless = !devtools, | ||
args = [], | ||
userDataDir = null | ||
} = options; | ||
if (userDataDir) | ||
chromeArguments.push(`--user-data-dir=${userDataDir}`); | ||
if (devtools) | ||
chromeArguments.push('--auto-open-devtools-for-tabs'); | ||
if (headless) { | ||
chromeArguments.push( | ||
'--headless', | ||
'--hide-scrollbars', | ||
'--mute-audio' | ||
); | ||
/** | ||
* @return {string} | ||
*/ | ||
executablePath() { | ||
return resolveExecutablePath(this).executablePath; | ||
} | ||
if (args.every(arg => arg.startsWith('-'))) | ||
chromeArguments.push('about:blank'); | ||
chromeArguments.push(...args); | ||
return chromeArguments; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
executablePath() { | ||
return resolveExecutablePath(this).executablePath; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
get product() { | ||
return 'chrome'; | ||
} | ||
/** | ||
* @param {!(Launcher.BrowserOptions & {browserWSEndpoint?: string, browserURL?: string, transport?: !Puppeteer.ConnectionTransport})} options | ||
* @return {!Promise<!Browser>} | ||
*/ | ||
async connect(options) { | ||
const { | ||
browserWSEndpoint, | ||
browserURL, | ||
ignoreHTTPSErrors = false, | ||
defaultViewport = {width: 800, height: 600}, | ||
transport, | ||
slowMo = 0, | ||
} = options; | ||
assert(Number(!!browserWSEndpoint) + Number(!!browserURL) + Number(!!transport) === 1, 'Exactly one of browserWSEndpoint, browserURL or transport must be passed to puppeteer.connect'); | ||
let connection = null; | ||
if (transport) { | ||
connection = new Connection('', transport, slowMo); | ||
} else if (browserWSEndpoint) { | ||
const connectionTransport = await WebSocketTransport.create(browserWSEndpoint); | ||
connection = new Connection(browserWSEndpoint, connectionTransport, slowMo); | ||
} else if (browserURL) { | ||
const connectionURL = await getWSEndpoint(browserURL); | ||
const connectionTransport = await WebSocketTransport.create(connectionURL); | ||
connection = new Connection(connectionURL, connectionTransport, slowMo); | ||
/** | ||
* @return {string} | ||
*/ | ||
get product() { | ||
return 'chrome'; | ||
} | ||
const {browserContextIds} = await connection.send('Target.getBrowserContexts'); | ||
return Browser.create(connection, browserContextIds, ignoreHTTPSErrors, defaultViewport, null, () => connection.send('Browser.close').catch(debugError)); | ||
} | ||
/** | ||
* @param {!(Launcher.BrowserOptions & {browserWSEndpoint?: string, browserURL?: string, transport?: !Puppeteer.ConnectionTransport})} options | ||
* @return {!Promise<!Browser>} | ||
*/ | ||
async connect(options) { | ||
const { browserWSEndpoint, browserURL, ignoreHTTPSErrors = false, defaultViewport = { width: 800, height: 600 }, transport, slowMo = 0, } = options; | ||
assert(Number(!!browserWSEndpoint) + Number(!!browserURL) + Number(!!transport) === 1, 'Exactly one of browserWSEndpoint, browserURL or transport must be passed to puppeteer.connect'); | ||
let connection = null; | ||
if (transport) { | ||
connection = new Connection('', transport, slowMo); | ||
} | ||
else if (browserWSEndpoint) { | ||
const connectionTransport = await WebSocketTransport.create(browserWSEndpoint); | ||
connection = new Connection(browserWSEndpoint, connectionTransport, slowMo); | ||
} | ||
else if (browserURL) { | ||
const connectionURL = await getWSEndpoint(browserURL); | ||
const connectionTransport = await WebSocketTransport.create(connectionURL); | ||
connection = new Connection(connectionURL, connectionTransport, slowMo); | ||
} | ||
const { browserContextIds } = await connection.send('Target.getBrowserContexts'); | ||
return Browser.create(connection, browserContextIds, ignoreHTTPSErrors, defaultViewport, null, () => connection.send('Browser.close').catch(debugError)); | ||
} | ||
} | ||
/** | ||
@@ -362,369 +302,295 @@ * @implements {!Puppeteer.ProductLauncher} | ||
class FirefoxLauncher { | ||
/** | ||
* @param {string} projectRoot | ||
* @param {string} preferredRevision | ||
* @param {boolean} isPuppeteerCore | ||
*/ | ||
constructor(projectRoot, preferredRevision, isPuppeteerCore) { | ||
this._projectRoot = projectRoot; | ||
this._preferredRevision = preferredRevision; | ||
this._isPuppeteerCore = isPuppeteerCore; | ||
} | ||
/** | ||
* @param {!(Launcher.LaunchOptions & Launcher.ChromeArgOptions & Launcher.BrowserOptions & {extraPrefsFirefox?: !object})=} options | ||
* @return {!Promise<!Browser>} | ||
*/ | ||
async launch(options = {}) { | ||
const { | ||
ignoreDefaultArgs = false, | ||
args = [], | ||
dumpio = false, | ||
executablePath = null, | ||
pipe = false, | ||
env = process.env, | ||
handleSIGINT = true, | ||
handleSIGTERM = true, | ||
handleSIGHUP = true, | ||
ignoreHTTPSErrors = false, | ||
defaultViewport = {width: 800, height: 600}, | ||
slowMo = 0, | ||
timeout = 30000, | ||
extraPrefsFirefox = {} | ||
} = options; | ||
const firefoxArguments = []; | ||
if (!ignoreDefaultArgs) | ||
firefoxArguments.push(...this.defaultArgs(options)); | ||
else if (Array.isArray(ignoreDefaultArgs)) | ||
firefoxArguments.push(...this.defaultArgs(options).filter(arg => !ignoreDefaultArgs.includes(arg))); | ||
else | ||
firefoxArguments.push(...args); | ||
let temporaryUserDataDir = null; | ||
if (!firefoxArguments.includes('-profile') && !firefoxArguments.includes('--profile')) { | ||
temporaryUserDataDir = await this._createProfile(extraPrefsFirefox); | ||
firefoxArguments.push('--profile'); | ||
firefoxArguments.push(temporaryUserDataDir); | ||
/** | ||
* @param {string} projectRoot | ||
* @param {string} preferredRevision | ||
* @param {boolean} isPuppeteerCore | ||
*/ | ||
constructor(projectRoot, preferredRevision, isPuppeteerCore) { | ||
this._projectRoot = projectRoot; | ||
this._preferredRevision = preferredRevision; | ||
this._isPuppeteerCore = isPuppeteerCore; | ||
} | ||
let executable = executablePath; | ||
if (!executablePath) { | ||
const {missingText, executablePath} = resolveExecutablePath(this); | ||
if (missingText) | ||
throw new Error(missingText); | ||
executable = executablePath; | ||
/** | ||
* @param {!(Launcher.LaunchOptions & Launcher.ChromeArgOptions & Launcher.BrowserOptions & {extraPrefsFirefox?: !object})=} options | ||
* @return {!Promise<!Browser>} | ||
*/ | ||
async launch(options = {}) { | ||
const { ignoreDefaultArgs = false, args = [], dumpio = false, executablePath = null, pipe = false, env = process.env, handleSIGINT = true, handleSIGTERM = true, handleSIGHUP = true, ignoreHTTPSErrors = false, defaultViewport = { width: 800, height: 600 }, slowMo = 0, timeout = 30000, extraPrefsFirefox = {} } = options; | ||
const firefoxArguments = []; | ||
if (!ignoreDefaultArgs) | ||
firefoxArguments.push(...this.defaultArgs(options)); | ||
else if (Array.isArray(ignoreDefaultArgs)) | ||
firefoxArguments.push(...this.defaultArgs(options).filter(arg => !ignoreDefaultArgs.includes(arg))); | ||
else | ||
firefoxArguments.push(...args); | ||
if (!firefoxArguments.some(argument => argument.startsWith('--remote-debugging-'))) | ||
firefoxArguments.push('--remote-debugging-port=0'); | ||
let temporaryUserDataDir = null; | ||
if (!firefoxArguments.includes('-profile') && !firefoxArguments.includes('--profile')) { | ||
temporaryUserDataDir = await this._createProfile(extraPrefsFirefox); | ||
firefoxArguments.push('--profile'); | ||
firefoxArguments.push(temporaryUserDataDir); | ||
} | ||
await this._updateRevision(); | ||
let firefoxExecutable = executablePath; | ||
if (!executablePath) { | ||
const { missingText, executablePath } = resolveExecutablePath(this); | ||
if (missingText) | ||
throw new Error(missingText); | ||
firefoxExecutable = executablePath; | ||
} | ||
const runner = new BrowserRunner(firefoxExecutable, firefoxArguments, temporaryUserDataDir); | ||
runner.start({ handleSIGHUP, handleSIGTERM, handleSIGINT, dumpio, env, pipe }); | ||
try { | ||
const connection = await runner.setupConnection({ usePipe: pipe, timeout, slowMo, preferredRevision: this._preferredRevision }); | ||
const browser = await Browser.create(connection, [], ignoreHTTPSErrors, defaultViewport, runner.proc, runner.close.bind(runner)); | ||
await browser.waitForTarget(t => t.type() === 'page'); | ||
return browser; | ||
} | ||
catch (error) { | ||
runner.kill(); | ||
throw error; | ||
} | ||
} | ||
const runner = new BrowserRunner(executable, firefoxArguments, temporaryUserDataDir); | ||
runner.start({handleSIGHUP, handleSIGTERM, handleSIGINT, dumpio, env, pipe}); | ||
try { | ||
const connection = await runner.setupConnection({usePipe: pipe, timeout, slowMo, preferredRevision: this._preferredRevision}); | ||
const browser = await Browser.create(connection, [], ignoreHTTPSErrors, defaultViewport, runner.proc, runner.close.bind(runner)); | ||
await browser.waitForTarget(t => t.type() === 'page'); | ||
return browser; | ||
} catch (error) { | ||
runner.kill(); | ||
throw error; | ||
/** | ||
* @param {!(Launcher.BrowserOptions & {browserWSEndpoint?: string, browserURL?: string, transport?: !Puppeteer.ConnectionTransport})} options | ||
* @return {!Promise<!Browser>} | ||
*/ | ||
async connect(options) { | ||
const { browserWSEndpoint, browserURL, ignoreHTTPSErrors = false, defaultViewport = { width: 800, height: 600 }, transport, slowMo = 0, } = options; | ||
assert(Number(!!browserWSEndpoint) + Number(!!browserURL) + Number(!!transport) === 1, 'Exactly one of browserWSEndpoint, browserURL or transport must be passed to puppeteer.connect'); | ||
let connection = null; | ||
if (transport) { | ||
connection = new Connection('', transport, slowMo); | ||
} | ||
else if (browserWSEndpoint) { | ||
const connectionTransport = await WebSocketTransport.create(browserWSEndpoint); | ||
connection = new Connection(browserWSEndpoint, connectionTransport, slowMo); | ||
} | ||
else if (browserURL) { | ||
const connectionURL = await getWSEndpoint(browserURL); | ||
const connectionTransport = await WebSocketTransport.create(connectionURL); | ||
connection = new Connection(connectionURL, connectionTransport, slowMo); | ||
} | ||
const { browserContextIds } = await connection.send('Target.getBrowserContexts'); | ||
return Browser.create(connection, browserContextIds, ignoreHTTPSErrors, defaultViewport, null, () => connection.send('Browser.close').catch(debugError)); | ||
} | ||
} | ||
/** | ||
* @param {!(Launcher.BrowserOptions & {browserWSEndpoint?: string, browserURL?: string, transport?: !Puppeteer.ConnectionTransport})} options | ||
* @return {!Promise<!Browser>} | ||
*/ | ||
async connect(options) { | ||
const { | ||
browserWSEndpoint, | ||
browserURL, | ||
ignoreHTTPSErrors = false, | ||
defaultViewport = {width: 800, height: 600}, | ||
transport, | ||
slowMo = 0, | ||
} = options; | ||
assert(Number(!!browserWSEndpoint) + Number(!!browserURL) + Number(!!transport) === 1, 'Exactly one of browserWSEndpoint, browserURL or transport must be passed to puppeteer.connect'); | ||
let connection = null; | ||
if (transport) { | ||
connection = new Connection('', transport, slowMo); | ||
} else if (browserWSEndpoint) { | ||
const connectionTransport = await WebSocketTransport.create(browserWSEndpoint); | ||
connection = new Connection(browserWSEndpoint, connectionTransport, slowMo); | ||
} else if (browserURL) { | ||
const connectionURL = await getWSEndpoint(browserURL); | ||
const connectionTransport = await WebSocketTransport.create(connectionURL); | ||
connection = new Connection(connectionURL, connectionTransport, slowMo); | ||
/** | ||
* @return {string} | ||
*/ | ||
executablePath() { | ||
return resolveExecutablePath(this).executablePath; | ||
} | ||
const {browserContextIds} = await connection.send('Target.getBrowserContexts'); | ||
return Browser.create(connection, browserContextIds, ignoreHTTPSErrors, defaultViewport, null, () => connection.send('Browser.close').catch(debugError)); | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
executablePath() { | ||
const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH || process.env.npm_config_puppeteer_executable_path || process.env.npm_package_config_puppeteer_executable_path; | ||
// TODO get resolveExecutablePath working for Firefox | ||
if (!executablePath) | ||
throw new Error('Please set PUPPETEER_EXECUTABLE_PATH to a Firefox binary.'); | ||
return executablePath; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
get product() { | ||
return 'firefox'; | ||
} | ||
/** | ||
* @param {!Launcher.ChromeArgOptions=} options | ||
* @return {!Array<string>} | ||
*/ | ||
defaultArgs(options = {}) { | ||
const firefoxArguments = [ | ||
'--remote-debugging-port=0', | ||
'--no-remote', | ||
'--foreground', | ||
]; | ||
const { | ||
devtools = false, | ||
headless = !devtools, | ||
args = [], | ||
userDataDir = null | ||
} = options; | ||
if (userDataDir) { | ||
firefoxArguments.push('--profile'); | ||
firefoxArguments.push(userDataDir); | ||
async _updateRevision() { | ||
// replace 'latest' placeholder with actual downloaded revision | ||
if (this._preferredRevision === 'latest') { | ||
const browserFetcher = new BrowserFetcher(this._projectRoot, { product: this.product }); | ||
const localRevisions = await browserFetcher.localRevisions(); | ||
if (localRevisions[0]) | ||
this._preferredRevision = localRevisions[0]; | ||
} | ||
} | ||
if (headless) | ||
firefoxArguments.push('--headless'); | ||
if (devtools) | ||
firefoxArguments.push('--devtools'); | ||
if (args.every(arg => arg.startsWith('-'))) | ||
firefoxArguments.push('about:blank'); | ||
firefoxArguments.push(...args); | ||
return firefoxArguments; | ||
} | ||
/** | ||
* @param {!Object=} extraPrefs | ||
* @return {!Promise<string>} | ||
*/ | ||
async _createProfile(extraPrefs) { | ||
const profilePath = await mkdtempAsync(path.join(os.tmpdir(), 'puppeteer_dev_firefox_profile-')); | ||
const prefsJS = []; | ||
const userJS = []; | ||
const server = 'dummy.test'; | ||
const defaultPreferences = { | ||
// Make sure Shield doesn't hit the network. | ||
'app.normandy.api_url': '', | ||
// Disable Firefox old build background check | ||
'app.update.checkInstallTime': false, | ||
// Disable automatically upgrading Firefox | ||
'app.update.disabledForTesting': true, | ||
// Increase the APZ content response timeout to 1 minute | ||
'apz.content_response_timeout': 60000, | ||
// Prevent various error message on the console | ||
// jest-puppeteer asserts that no error message is emitted by the console | ||
'browser.contentblocking.features.standard': '-tp,tpPrivate,cookieBehavior0,-cm,-fp', | ||
// Enable the dump function: which sends messages to the system | ||
// console | ||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1543115 | ||
'browser.dom.window.dump.enabled': true, | ||
// Disable topstories | ||
'browser.newtabpage.activity-stream.feeds.section.topstories': false, | ||
// Always display a blank page | ||
'browser.newtabpage.enabled': false, | ||
// Background thumbnails in particular cause grief: and disabling | ||
// thumbnails in general cannot hurt | ||
'browser.pagethumbnails.capturing_disabled': true, | ||
// Disable safebrowsing components. | ||
'browser.safebrowsing.blockedURIs.enabled': false, | ||
'browser.safebrowsing.downloads.enabled': false, | ||
'browser.safebrowsing.malware.enabled': false, | ||
'browser.safebrowsing.passwords.enabled': false, | ||
'browser.safebrowsing.phishing.enabled': false, | ||
// Disable updates to search engines. | ||
'browser.search.update': false, | ||
// Do not restore the last open set of tabs if the browser has crashed | ||
'browser.sessionstore.resume_from_crash': false, | ||
// Skip check for default browser on startup | ||
'browser.shell.checkDefaultBrowser': false, | ||
// Disable newtabpage | ||
'browser.startup.homepage': 'about:blank', | ||
// Do not redirect user when a milstone upgrade of Firefox is detected | ||
'browser.startup.homepage_override.mstone': 'ignore', | ||
// Start with a blank page about:blank | ||
'browser.startup.page': 0, | ||
// Do not allow background tabs to be zombified on Android: otherwise for | ||
// tests that open additional tabs: the test harness tab itself might get | ||
// unloaded | ||
'browser.tabs.disableBackgroundZombification': false, | ||
// Do not warn when closing all other open tabs | ||
'browser.tabs.warnOnCloseOtherTabs': false, | ||
// Do not warn when multiple tabs will be opened | ||
'browser.tabs.warnOnOpen': false, | ||
// Disable the UI tour. | ||
'browser.uitour.enabled': false, | ||
// Turn off search suggestions in the location bar so as not to trigger | ||
// network connections. | ||
'browser.urlbar.suggest.searches': false, | ||
// Disable first run splash page on Windows 10 | ||
'browser.usedOnWindows10.introURL': '', | ||
// Do not warn on quitting Firefox | ||
'browser.warnOnQuit': false, | ||
// Do not show datareporting policy notifications which can | ||
// interfere with tests | ||
'datareporting.healthreport.about.reportUrl': `http://${server}/dummy/abouthealthreport/`, | ||
'datareporting.healthreport.documentServerURI': `http://${server}/dummy/healthreport/`, | ||
'datareporting.healthreport.logging.consoleEnabled': false, | ||
'datareporting.healthreport.service.enabled': false, | ||
'datareporting.healthreport.service.firstRun': false, | ||
'datareporting.healthreport.uploadEnabled': false, | ||
'datareporting.policy.dataSubmissionEnabled': false, | ||
'datareporting.policy.dataSubmissionPolicyAccepted': false, | ||
'datareporting.policy.dataSubmissionPolicyBypassNotification': true, | ||
// DevTools JSONViewer sometimes fails to load dependencies with its require.js. | ||
// This doesn't affect Puppeteer but spams console (Bug 1424372) | ||
'devtools.jsonview.enabled': false, | ||
// Disable popup-blocker | ||
'dom.disable_open_during_load': false, | ||
// Enable the support for File object creation in the content process | ||
// Required for |Page.setFileInputFiles| protocol method. | ||
'dom.file.createInChild': true, | ||
// Disable the ProcessHangMonitor | ||
'dom.ipc.reportProcessHangs': false, | ||
// Disable slow script dialogues | ||
'dom.max_chrome_script_run_time': 0, | ||
'dom.max_script_run_time': 0, | ||
// Only load extensions from the application and user profile | ||
// AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION | ||
'extensions.autoDisableScopes': 0, | ||
'extensions.enabledScopes': 5, | ||
// Disable metadata caching for installed add-ons by default | ||
'extensions.getAddons.cache.enabled': false, | ||
// Disable installing any distribution extensions or add-ons. | ||
'extensions.installDistroAddons': false, | ||
// Disabled screenshots extension | ||
'extensions.screenshots.disabled': true, | ||
// Turn off extension updates so they do not bother tests | ||
'extensions.update.enabled': false, | ||
// Turn off extension updates so they do not bother tests | ||
'extensions.update.notifyUser': false, | ||
// Make sure opening about:addons will not hit the network | ||
'extensions.webservice.discoverURL': `http://${server}/dummy/discoveryURL`, | ||
// Allow the application to have focus even it runs in the background | ||
'focusmanager.testmode': true, | ||
// Disable useragent updates | ||
'general.useragent.updates.enabled': false, | ||
// Always use network provider for geolocation tests so we bypass the | ||
// macOS dialog raised by the corelocation provider | ||
'geo.provider.testing': true, | ||
// Do not scan Wifi | ||
'geo.wifi.scan': false, | ||
// No hang monitor | ||
'hangmonitor.timeout': 0, | ||
// Show chrome errors and warnings in the error console | ||
'javascript.options.showInConsole': true, | ||
// Disable download and usage of OpenH264: and Widevine plugins | ||
'media.gmp-manager.updateEnabled': false, | ||
// Prevent various error message on the console | ||
// jest-puppeteer asserts that no error message is emitted by the console | ||
'network.cookie.cookieBehavior': 0, | ||
// Do not prompt for temporary redirects | ||
'network.http.prompt-temp-redirect': false, | ||
// Disable speculative connections so they are not reported as leaking | ||
// when they are hanging around | ||
'network.http.speculative-parallel-limit': 0, | ||
// Do not automatically switch between offline and online | ||
'network.manage-offline-status': false, | ||
// Make sure SNTP requests do not hit the network | ||
'network.sntp.pools': server, | ||
// Disable Flash. | ||
'plugin.state.flash': 0, | ||
'privacy.trackingprotection.enabled': false, | ||
// Enable Remote Agent | ||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1544393 | ||
'remote.enabled': true, | ||
// Don't do network connections for mitm priming | ||
'security.certerrors.mitm.priming.enabled': false, | ||
// Local documents have access to all other local documents, | ||
// including directory listings | ||
'security.fileuri.strict_origin_policy': false, | ||
// Do not wait for the notification button security delay | ||
'security.notification_enable_delay': 0, | ||
// Ensure blocklist updates do not hit the network | ||
'services.settings.server': `http://${server}/dummy/blocklist/`, | ||
// Do not automatically fill sign-in forms with known usernames and | ||
// passwords | ||
'signon.autofillForms': false, | ||
// Disable password capture, so that tests that include forms are not | ||
// influenced by the presence of the persistent doorhanger notification | ||
'signon.rememberSignons': false, | ||
// Disable first-run welcome page | ||
'startup.homepage_welcome_url': 'about:blank', | ||
// Disable first-run welcome page | ||
'startup.homepage_welcome_url.additional': '', | ||
// Disable browser animations (tabs, fullscreen, sliding alerts) | ||
'toolkit.cosmeticAnimations.enabled': false, | ||
// We want to collect telemetry, but we don't want to send in the results | ||
'toolkit.telemetry.server': `https://${server}/dummy/telemetry/`, | ||
// Prevent starting into safe mode after application crashes | ||
'toolkit.startup.max_resumed_crashes': -1, | ||
}; | ||
Object.assign(defaultPreferences, extraPrefs); | ||
for (const [key, value] of Object.entries(defaultPreferences)) | ||
userJS.push(`user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});`); | ||
await writeFileAsync(path.join(profilePath, 'user.js'), userJS.join('\n')); | ||
await writeFileAsync(path.join(profilePath, 'prefs.js'), prefsJS.join('\n')); | ||
return profilePath; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
get product() { | ||
return 'firefox'; | ||
} | ||
/** | ||
* @param {!Launcher.ChromeArgOptions=} options | ||
* @return {!Array<string>} | ||
*/ | ||
defaultArgs(options = {}) { | ||
const firefoxArguments = [ | ||
'--no-remote', | ||
'--foreground', | ||
]; | ||
const { devtools = false, headless = !devtools, args = [], userDataDir = null } = options; | ||
if (userDataDir) { | ||
firefoxArguments.push('--profile'); | ||
firefoxArguments.push(userDataDir); | ||
} | ||
if (headless) | ||
firefoxArguments.push('--headless'); | ||
if (devtools) | ||
firefoxArguments.push('--devtools'); | ||
if (args.every(arg => arg.startsWith('-'))) | ||
firefoxArguments.push('about:blank'); | ||
firefoxArguments.push(...args); | ||
return firefoxArguments; | ||
} | ||
/** | ||
* @param {!Object=} extraPrefs | ||
* @return {!Promise<string>} | ||
*/ | ||
async _createProfile(extraPrefs) { | ||
const profilePath = await mkdtempAsync(path.join(os.tmpdir(), 'puppeteer_dev_firefox_profile-')); | ||
const prefsJS = []; | ||
const userJS = []; | ||
const server = 'dummy.test'; | ||
const defaultPreferences = { | ||
// Make sure Shield doesn't hit the network. | ||
'app.normandy.api_url': '', | ||
// Disable Firefox old build background check | ||
'app.update.checkInstallTime': false, | ||
// Disable automatically upgrading Firefox | ||
'app.update.disabledForTesting': true, | ||
// Increase the APZ content response timeout to 1 minute | ||
'apz.content_response_timeout': 60000, | ||
// Prevent various error message on the console | ||
// jest-puppeteer asserts that no error message is emitted by the console | ||
'browser.contentblocking.features.standard': '-tp,tpPrivate,cookieBehavior0,-cm,-fp', | ||
// Enable the dump function: which sends messages to the system | ||
// console | ||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1543115 | ||
'browser.dom.window.dump.enabled': true, | ||
// Disable topstories | ||
'browser.newtabpage.activity-stream.feeds.section.topstories': false, | ||
// Always display a blank page | ||
'browser.newtabpage.enabled': false, | ||
// Background thumbnails in particular cause grief: and disabling | ||
// thumbnails in general cannot hurt | ||
'browser.pagethumbnails.capturing_disabled': true, | ||
// Disable safebrowsing components. | ||
'browser.safebrowsing.blockedURIs.enabled': false, | ||
'browser.safebrowsing.downloads.enabled': false, | ||
'browser.safebrowsing.malware.enabled': false, | ||
'browser.safebrowsing.passwords.enabled': false, | ||
'browser.safebrowsing.phishing.enabled': false, | ||
// Disable updates to search engines. | ||
'browser.search.update': false, | ||
// Do not restore the last open set of tabs if the browser has crashed | ||
'browser.sessionstore.resume_from_crash': false, | ||
// Skip check for default browser on startup | ||
'browser.shell.checkDefaultBrowser': false, | ||
// Disable newtabpage | ||
'browser.startup.homepage': 'about:blank', | ||
// Do not redirect user when a milstone upgrade of Firefox is detected | ||
'browser.startup.homepage_override.mstone': 'ignore', | ||
// Start with a blank page about:blank | ||
'browser.startup.page': 0, | ||
// Do not allow background tabs to be zombified on Android: otherwise for | ||
// tests that open additional tabs: the test harness tab itself might get | ||
// unloaded | ||
'browser.tabs.disableBackgroundZombification': false, | ||
// Do not warn when closing all other open tabs | ||
'browser.tabs.warnOnCloseOtherTabs': false, | ||
// Do not warn when multiple tabs will be opened | ||
'browser.tabs.warnOnOpen': false, | ||
// Disable the UI tour. | ||
'browser.uitour.enabled': false, | ||
// Turn off search suggestions in the location bar so as not to trigger | ||
// network connections. | ||
'browser.urlbar.suggest.searches': false, | ||
// Disable first run splash page on Windows 10 | ||
'browser.usedOnWindows10.introURL': '', | ||
// Do not warn on quitting Firefox | ||
'browser.warnOnQuit': false, | ||
// Do not show datareporting policy notifications which can | ||
// interfere with tests | ||
'datareporting.healthreport.about.reportUrl': `http://${server}/dummy/abouthealthreport/`, | ||
'datareporting.healthreport.documentServerURI': `http://${server}/dummy/healthreport/`, | ||
'datareporting.healthreport.logging.consoleEnabled': false, | ||
'datareporting.healthreport.service.enabled': false, | ||
'datareporting.healthreport.service.firstRun': false, | ||
'datareporting.healthreport.uploadEnabled': false, | ||
'datareporting.policy.dataSubmissionEnabled': false, | ||
'datareporting.policy.dataSubmissionPolicyAccepted': false, | ||
'datareporting.policy.dataSubmissionPolicyBypassNotification': true, | ||
// DevTools JSONViewer sometimes fails to load dependencies with its require.js. | ||
// This doesn't affect Puppeteer but spams console (Bug 1424372) | ||
'devtools.jsonview.enabled': false, | ||
// Disable popup-blocker | ||
'dom.disable_open_during_load': false, | ||
// Enable the support for File object creation in the content process | ||
// Required for |Page.setFileInputFiles| protocol method. | ||
'dom.file.createInChild': true, | ||
// Disable the ProcessHangMonitor | ||
'dom.ipc.reportProcessHangs': false, | ||
// Disable slow script dialogues | ||
'dom.max_chrome_script_run_time': 0, | ||
'dom.max_script_run_time': 0, | ||
// Only load extensions from the application and user profile | ||
// AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION | ||
'extensions.autoDisableScopes': 0, | ||
'extensions.enabledScopes': 5, | ||
// Disable metadata caching for installed add-ons by default | ||
'extensions.getAddons.cache.enabled': false, | ||
// Disable installing any distribution extensions or add-ons. | ||
'extensions.installDistroAddons': false, | ||
// Disabled screenshots extension | ||
'extensions.screenshots.disabled': true, | ||
// Turn off extension updates so they do not bother tests | ||
'extensions.update.enabled': false, | ||
// Turn off extension updates so they do not bother tests | ||
'extensions.update.notifyUser': false, | ||
// Make sure opening about:addons will not hit the network | ||
'extensions.webservice.discoverURL': `http://${server}/dummy/discoveryURL`, | ||
// Allow the application to have focus even it runs in the background | ||
'focusmanager.testmode': true, | ||
// Disable useragent updates | ||
'general.useragent.updates.enabled': false, | ||
// Always use network provider for geolocation tests so we bypass the | ||
// macOS dialog raised by the corelocation provider | ||
'geo.provider.testing': true, | ||
// Do not scan Wifi | ||
'geo.wifi.scan': false, | ||
// No hang monitor | ||
'hangmonitor.timeout': 0, | ||
// Show chrome errors and warnings in the error console | ||
'javascript.options.showInConsole': true, | ||
// Disable download and usage of OpenH264: and Widevine plugins | ||
'media.gmp-manager.updateEnabled': false, | ||
// Prevent various error message on the console | ||
// jest-puppeteer asserts that no error message is emitted by the console | ||
'network.cookie.cookieBehavior': 0, | ||
// Do not prompt for temporary redirects | ||
'network.http.prompt-temp-redirect': false, | ||
// Disable speculative connections so they are not reported as leaking | ||
// when they are hanging around | ||
'network.http.speculative-parallel-limit': 0, | ||
// Do not automatically switch between offline and online | ||
'network.manage-offline-status': false, | ||
// Make sure SNTP requests do not hit the network | ||
'network.sntp.pools': server, | ||
// Disable Flash. | ||
'plugin.state.flash': 0, | ||
'privacy.trackingprotection.enabled': false, | ||
// Enable Remote Agent | ||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1544393 | ||
'remote.enabled': true, | ||
// Don't do network connections for mitm priming | ||
'security.certerrors.mitm.priming.enabled': false, | ||
// Local documents have access to all other local documents, | ||
// including directory listings | ||
'security.fileuri.strict_origin_policy': false, | ||
// Do not wait for the notification button security delay | ||
'security.notification_enable_delay': 0, | ||
// Ensure blocklist updates do not hit the network | ||
'services.settings.server': `http://${server}/dummy/blocklist/`, | ||
// Do not automatically fill sign-in forms with known usernames and | ||
// passwords | ||
'signon.autofillForms': false, | ||
// Disable password capture, so that tests that include forms are not | ||
// influenced by the presence of the persistent doorhanger notification | ||
'signon.rememberSignons': false, | ||
// Disable first-run welcome page | ||
'startup.homepage_welcome_url': 'about:blank', | ||
// Disable first-run welcome page | ||
'startup.homepage_welcome_url.additional': '', | ||
// Disable browser animations (tabs, fullscreen, sliding alerts) | ||
'toolkit.cosmeticAnimations.enabled': false, | ||
// We want to collect telemetry, but we don't want to send in the results | ||
'toolkit.telemetry.server': `https://${server}/dummy/telemetry/`, | ||
// Prevent starting into safe mode after application crashes | ||
'toolkit.startup.max_resumed_crashes': -1, | ||
}; | ||
Object.assign(defaultPreferences, extraPrefs); | ||
for (const [key, value] of Object.entries(defaultPreferences)) | ||
userJS.push(`user_pref(${JSON.stringify(key)}, ${JSON.stringify(value)});`); | ||
await writeFileAsync(path.join(profilePath, 'user.js'), userJS.join('\n')); | ||
await writeFileAsync(path.join(profilePath, 'prefs.js'), prefsJS.join('\n')); | ||
return profilePath; | ||
} | ||
} | ||
/** | ||
@@ -737,52 +603,47 @@ * @param {!Puppeteer.ChildProcess} browserProcess | ||
function waitForWSEndpoint(browserProcess, timeout, preferredRevision) { | ||
return new Promise((resolve, reject) => { | ||
const rl = readline.createInterface({ input: browserProcess.stderr }); | ||
let stderr = ''; | ||
const listeners = [ | ||
helper.addEventListener(rl, 'line', onLine), | ||
helper.addEventListener(rl, 'close', () => onClose()), | ||
helper.addEventListener(browserProcess, 'exit', () => onClose()), | ||
helper.addEventListener(browserProcess, 'error', error => onClose(error)) | ||
]; | ||
const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0; | ||
/** | ||
* @param {!Error=} error | ||
*/ | ||
function onClose(error) { | ||
cleanup(); | ||
reject(new Error([ | ||
'Failed to launch the browser process!' + (error ? ' ' + error.message : ''), | ||
stderr, | ||
'', | ||
'TROUBLESHOOTING: https://github.com/puppeteer/puppeteer/blob/master/docs/troubleshooting.md', | ||
'', | ||
].join('\n'))); | ||
} | ||
function onTimeout() { | ||
cleanup(); | ||
reject(new TimeoutError(`Timed out after ${timeout} ms while trying to connect to the browser! Only Chrome at revision r${preferredRevision} is guaranteed to work.`)); | ||
} | ||
/** | ||
* @param {string} line | ||
*/ | ||
function onLine(line) { | ||
stderr += line + '\n'; | ||
const match = line.match(/^DevTools listening on (ws:\/\/.*)$/); | ||
if (!match) | ||
return; | ||
cleanup(); | ||
resolve(match[1]); | ||
} | ||
function cleanup() { | ||
if (timeoutId) | ||
clearTimeout(timeoutId); | ||
helper.removeEventListeners(listeners); | ||
} | ||
}); | ||
return new Promise((resolve, reject) => { | ||
const rl = readline.createInterface({ input: browserProcess.stderr }); | ||
let stderr = ''; | ||
const listeners = [ | ||
helper.addEventListener(rl, 'line', onLine), | ||
helper.addEventListener(rl, 'close', () => onClose()), | ||
helper.addEventListener(browserProcess, 'exit', () => onClose()), | ||
helper.addEventListener(browserProcess, 'error', error => onClose(error)) | ||
]; | ||
const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0; | ||
/** | ||
* @param {!Error=} error | ||
*/ | ||
function onClose(error) { | ||
cleanup(); | ||
reject(new Error([ | ||
'Failed to launch the browser process!' + (error ? ' ' + error.message : ''), | ||
stderr, | ||
'', | ||
'TROUBLESHOOTING: https://github.com/puppeteer/puppeteer/blob/master/docs/troubleshooting.md', | ||
'', | ||
].join('\n'))); | ||
} | ||
function onTimeout() { | ||
cleanup(); | ||
reject(new TimeoutError(`Timed out after ${timeout} ms while trying to connect to the browser! Only Chrome at revision r${preferredRevision} is guaranteed to work.`)); | ||
} | ||
/** | ||
* @param {string} line | ||
*/ | ||
function onLine(line) { | ||
stderr += line + '\n'; | ||
const match = line.match(/^DevTools listening on (ws:\/\/.*)$/); | ||
if (!match) | ||
return; | ||
cleanup(); | ||
resolve(match[1]); | ||
} | ||
function cleanup() { | ||
if (timeoutId) | ||
clearTimeout(timeoutId); | ||
helper.removeEventListeners(listeners); | ||
} | ||
}); | ||
} | ||
/** | ||
@@ -793,30 +654,26 @@ * @param {string} browserURL | ||
function getWSEndpoint(browserURL) { | ||
let resolve, reject; | ||
const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); | ||
const endpointURL = URL.resolve(browserURL, '/json/version'); | ||
const protocol = endpointURL.startsWith('https') ? https : http; | ||
const requestOptions = Object.assign(URL.parse(endpointURL), { method: 'GET' }); | ||
const request = protocol.request(requestOptions, res => { | ||
let data = ''; | ||
if (res.statusCode !== 200) { | ||
// Consume response data to free up memory. | ||
res.resume(); | ||
reject(new Error('HTTP ' + res.statusCode)); | ||
return; | ||
} | ||
res.setEncoding('utf8'); | ||
res.on('data', chunk => data += chunk); | ||
res.on('end', () => resolve(JSON.parse(data).webSocketDebuggerUrl)); | ||
}); | ||
request.on('error', reject); | ||
request.end(); | ||
return promise.catch(e => { | ||
e.message = `Failed to fetch browser webSocket url from ${endpointURL}: ` + e.message; | ||
throw e; | ||
}); | ||
let resolve, reject; | ||
const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); | ||
const endpointURL = URL.resolve(browserURL, '/json/version'); | ||
const protocol = endpointURL.startsWith('https') ? https : http; | ||
const requestOptions = Object.assign(URL.parse(endpointURL), { method: 'GET' }); | ||
const request = protocol.request(requestOptions, res => { | ||
let data = ''; | ||
if (res.statusCode !== 200) { | ||
// Consume response data to free up memory. | ||
res.resume(); | ||
reject(new Error('HTTP ' + res.statusCode)); | ||
return; | ||
} | ||
res.setEncoding('utf8'); | ||
res.on('data', chunk => data += chunk); | ||
res.on('end', () => resolve(JSON.parse(data).webSocketDebuggerUrl)); | ||
}); | ||
request.on('error', reject); | ||
request.end(); | ||
return promise.catch(e => { | ||
e.message = `Failed to fetch browser webSocket url from ${endpointURL}: ` + e.message; | ||
throw e; | ||
}); | ||
} | ||
/** | ||
@@ -828,24 +685,23 @@ * @param {ChromeLauncher|FirefoxLauncher} launcher | ||
function resolveExecutablePath(launcher) { | ||
// puppeteer-core doesn't take into account PUPPETEER_* env variables. | ||
if (!launcher._isPuppeteerCore) { | ||
const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH || process.env.npm_config_puppeteer_executable_path || process.env.npm_package_config_puppeteer_executable_path; | ||
if (executablePath) { | ||
const missingText = !fs.existsSync(executablePath) ? 'Tried to use PUPPETEER_EXECUTABLE_PATH env variable to launch browser but did not find any executable at: ' + executablePath : null; | ||
return { executablePath, missingText }; | ||
// puppeteer-core doesn't take into account PUPPETEER_* env variables. | ||
if (!launcher._isPuppeteerCore) { | ||
const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH || process.env.npm_config_puppeteer_executable_path || process.env.npm_package_config_puppeteer_executable_path; | ||
if (executablePath) { | ||
const missingText = !fs.existsSync(executablePath) ? 'Tried to use PUPPETEER_EXECUTABLE_PATH env variable to launch browser but did not find any executable at: ' + executablePath : null; | ||
return { executablePath, missingText }; | ||
} | ||
} | ||
} | ||
const browserFetcher = new BrowserFetcher(launcher._projectRoot); | ||
if (!launcher._isPuppeteerCore) { | ||
const revision = process.env['PUPPETEER_CHROMIUM_REVISION']; | ||
if (revision) { | ||
const revisionInfo = browserFetcher.revisionInfo(revision); | ||
const missingText = !revisionInfo.local ? 'Tried to use PUPPETEER_CHROMIUM_REVISION env variable to launch browser but did not find executable at: ' + revisionInfo.executablePath : null; | ||
return {executablePath: revisionInfo.executablePath, missingText}; | ||
const browserFetcher = new BrowserFetcher(launcher._projectRoot, { product: launcher.product }); | ||
if (!launcher._isPuppeteerCore && launcher.product === 'chrome') { | ||
const revision = process.env['PUPPETEER_CHROMIUM_REVISION']; | ||
if (revision) { | ||
const revisionInfo = browserFetcher.revisionInfo(revision); | ||
const missingText = !revisionInfo.local ? 'Tried to use PUPPETEER_CHROMIUM_REVISION env variable to launch browser but did not find executable at: ' + revisionInfo.executablePath : null; | ||
return { executablePath: revisionInfo.executablePath, missingText }; | ||
} | ||
} | ||
} | ||
const revisionInfo = browserFetcher.revisionInfo(launcher._preferredRevision); | ||
const missingText = !revisionInfo.local ? `Browser is not downloaded. Run "npm install" or "yarn install"` : null; | ||
return {executablePath: revisionInfo.executablePath, missingText}; | ||
const revisionInfo = browserFetcher.revisionInfo(launcher._preferredRevision); | ||
const missingText = !revisionInfo.local ? `Could not find browser revision ${launcher._preferredRevision}. Run "npm install" or "yarn install" to download a browser binary.` : null; | ||
return { executablePath: revisionInfo.executablePath, missingText }; | ||
} | ||
/** | ||
@@ -859,15 +715,13 @@ * @param {string} projectRoot | ||
function Launcher(projectRoot, preferredRevision, isPuppeteerCore, product) { | ||
// puppeteer-core doesn't take into account PUPPETEER_* env variables. | ||
if (!product && !isPuppeteerCore) | ||
product = process.env.PUPPETEER_PRODUCT || process.env.npm_config_puppeteer_product || process.env.npm_package_config_puppeteer_product; | ||
switch (product) { | ||
case 'firefox': | ||
return new FirefoxLauncher(projectRoot, preferredRevision, isPuppeteerCore); | ||
case 'chrome': | ||
default: | ||
return new ChromeLauncher(projectRoot, preferredRevision, isPuppeteerCore); | ||
} | ||
// puppeteer-core doesn't take into account PUPPETEER_* env variables. | ||
if (!product && !isPuppeteerCore) | ||
product = process.env.PUPPETEER_PRODUCT || process.env.npm_config_puppeteer_product || process.env.npm_package_config_puppeteer_product; | ||
switch (product) { | ||
case 'firefox': | ||
return new FirefoxLauncher(projectRoot, preferredRevision, isPuppeteerCore); | ||
case 'chrome': | ||
default: | ||
return new ChromeLauncher(projectRoot, preferredRevision, isPuppeteerCore); | ||
} | ||
} | ||
/** | ||
@@ -880,3 +734,2 @@ * @typedef {Object} Launcher.ChromeArgOptions | ||
*/ | ||
/** | ||
@@ -894,3 +747,2 @@ * @typedef {Object} Launcher.LaunchOptions | ||
*/ | ||
/** | ||
@@ -902,4 +754,2 @@ * @typedef {Object} Launcher.BrowserOptions | ||
*/ | ||
module.exports = Launcher; |
@@ -16,184 +16,162 @@ /** | ||
*/ | ||
const {helper, assert} = require('./helper'); | ||
const {Events} = require('./Events'); | ||
const {TimeoutError} = require('./Errors'); | ||
const { helper, assert } = require('./helper'); | ||
const { Events } = require('./Events'); | ||
const { TimeoutError } = require('./Errors'); | ||
class LifecycleWatcher { | ||
/** | ||
* @param {!Puppeteer.FrameManager} frameManager | ||
* @param {!Puppeteer.Frame} frame | ||
* @param {string|!Array<string>} waitUntil | ||
* @param {number} timeout | ||
*/ | ||
constructor(frameManager, frame, waitUntil, timeout) { | ||
if (Array.isArray(waitUntil)) | ||
waitUntil = waitUntil.slice(); | ||
else if (typeof waitUntil === 'string') | ||
waitUntil = [waitUntil]; | ||
this._expectedLifecycle = waitUntil.map(value => { | ||
const protocolEvent = puppeteerToProtocolLifecycle.get(value); | ||
assert(protocolEvent, 'Unknown value for options.waitUntil: ' + value); | ||
return protocolEvent; | ||
}); | ||
this._frameManager = frameManager; | ||
this._frame = frame; | ||
this._initialLoaderId = frame._loaderId; | ||
this._timeout = timeout; | ||
/** @type {?Puppeteer.Request} */ | ||
this._navigationRequest = null; | ||
this._eventListeners = [ | ||
helper.addEventListener(frameManager._client, Events.CDPSession.Disconnected, () => this._terminate(new Error('Navigation failed because browser has disconnected!'))), | ||
helper.addEventListener(this._frameManager, Events.FrameManager.LifecycleEvent, this._checkLifecycleComplete.bind(this)), | ||
helper.addEventListener(this._frameManager, Events.FrameManager.FrameNavigatedWithinDocument, this._navigatedWithinDocument.bind(this)), | ||
helper.addEventListener(this._frameManager, Events.FrameManager.FrameDetached, this._onFrameDetached.bind(this)), | ||
helper.addEventListener(this._frameManager.networkManager(), Events.NetworkManager.Request, this._onRequest.bind(this)), | ||
]; | ||
this._sameDocumentNavigationPromise = new Promise(fulfill => { | ||
this._sameDocumentNavigationCompleteCallback = fulfill; | ||
}); | ||
this._lifecyclePromise = new Promise(fulfill => { | ||
this._lifecycleCallback = fulfill; | ||
}); | ||
this._newDocumentNavigationPromise = new Promise(fulfill => { | ||
this._newDocumentNavigationCompleteCallback = fulfill; | ||
}); | ||
this._timeoutPromise = this._createTimeoutPromise(); | ||
this._terminationPromise = new Promise(fulfill => { | ||
this._terminationCallback = fulfill; | ||
}); | ||
this._checkLifecycleComplete(); | ||
} | ||
/** | ||
* @param {!Puppeteer.Request} request | ||
*/ | ||
_onRequest(request) { | ||
if (request.frame() !== this._frame || !request.isNavigationRequest()) | ||
return; | ||
this._navigationRequest = request; | ||
} | ||
/** | ||
* @param {!Puppeteer.Frame} frame | ||
*/ | ||
_onFrameDetached(frame) { | ||
if (this._frame === frame) { | ||
this._terminationCallback.call(null, new Error('Navigating frame was detached')); | ||
return; | ||
/** | ||
* @param {!Puppeteer.FrameManager} frameManager | ||
* @param {!Puppeteer.Frame} frame | ||
* @param {string|!Array<string>} waitUntil | ||
* @param {number} timeout | ||
*/ | ||
constructor(frameManager, frame, waitUntil, timeout) { | ||
if (Array.isArray(waitUntil)) | ||
waitUntil = waitUntil.slice(); | ||
else if (typeof waitUntil === 'string') | ||
waitUntil = [waitUntil]; | ||
this._expectedLifecycle = waitUntil.map(value => { | ||
const protocolEvent = puppeteerToProtocolLifecycle.get(value); | ||
assert(protocolEvent, 'Unknown value for options.waitUntil: ' + value); | ||
return protocolEvent; | ||
}); | ||
this._frameManager = frameManager; | ||
this._frame = frame; | ||
this._initialLoaderId = frame._loaderId; | ||
this._timeout = timeout; | ||
/** @type {?Puppeteer.Request} */ | ||
this._navigationRequest = null; | ||
this._eventListeners = [ | ||
helper.addEventListener(frameManager._client, Events.CDPSession.Disconnected, () => this._terminate(new Error('Navigation failed because browser has disconnected!'))), | ||
helper.addEventListener(this._frameManager, Events.FrameManager.LifecycleEvent, this._checkLifecycleComplete.bind(this)), | ||
helper.addEventListener(this._frameManager, Events.FrameManager.FrameNavigatedWithinDocument, this._navigatedWithinDocument.bind(this)), | ||
helper.addEventListener(this._frameManager, Events.FrameManager.FrameDetached, this._onFrameDetached.bind(this)), | ||
helper.addEventListener(this._frameManager.networkManager(), Events.NetworkManager.Request, this._onRequest.bind(this)), | ||
]; | ||
this._sameDocumentNavigationPromise = new Promise(fulfill => { | ||
this._sameDocumentNavigationCompleteCallback = fulfill; | ||
}); | ||
this._lifecyclePromise = new Promise(fulfill => { | ||
this._lifecycleCallback = fulfill; | ||
}); | ||
this._newDocumentNavigationPromise = new Promise(fulfill => { | ||
this._newDocumentNavigationCompleteCallback = fulfill; | ||
}); | ||
this._timeoutPromise = this._createTimeoutPromise(); | ||
this._terminationPromise = new Promise(fulfill => { | ||
this._terminationCallback = fulfill; | ||
}); | ||
this._checkLifecycleComplete(); | ||
} | ||
this._checkLifecycleComplete(); | ||
} | ||
/** | ||
* @return {?Puppeteer.Response} | ||
*/ | ||
navigationResponse() { | ||
return this._navigationRequest ? this._navigationRequest.response() : null; | ||
} | ||
/** | ||
* @param {!Error} error | ||
*/ | ||
_terminate(error) { | ||
this._terminationCallback.call(null, error); | ||
} | ||
/** | ||
* @return {!Promise<?Error>} | ||
*/ | ||
sameDocumentNavigationPromise() { | ||
return this._sameDocumentNavigationPromise; | ||
} | ||
/** | ||
* @return {!Promise<?Error>} | ||
*/ | ||
newDocumentNavigationPromise() { | ||
return this._newDocumentNavigationPromise; | ||
} | ||
/** | ||
* @return {!Promise} | ||
*/ | ||
lifecyclePromise() { | ||
return this._lifecyclePromise; | ||
} | ||
/** | ||
* @return {!Promise<?Error>} | ||
*/ | ||
timeoutOrTerminationPromise() { | ||
return Promise.race([this._timeoutPromise, this._terminationPromise]); | ||
} | ||
/** | ||
* @return {!Promise<?Error>} | ||
*/ | ||
_createTimeoutPromise() { | ||
if (!this._timeout) | ||
return new Promise(() => {}); | ||
const errorMessage = 'Navigation timeout of ' + this._timeout + ' ms exceeded'; | ||
return new Promise(fulfill => this._maximumTimer = setTimeout(fulfill, this._timeout)) | ||
.then(() => new TimeoutError(errorMessage)); | ||
} | ||
/** | ||
* @param {!Puppeteer.Frame} frame | ||
*/ | ||
_navigatedWithinDocument(frame) { | ||
if (frame !== this._frame) | ||
return; | ||
this._hasSameDocumentNavigation = true; | ||
this._checkLifecycleComplete(); | ||
} | ||
_checkLifecycleComplete() { | ||
// We expect navigation to commit. | ||
if (!checkLifecycle(this._frame, this._expectedLifecycle)) | ||
return; | ||
this._lifecycleCallback(); | ||
if (this._frame._loaderId === this._initialLoaderId && !this._hasSameDocumentNavigation) | ||
return; | ||
if (this._hasSameDocumentNavigation) | ||
this._sameDocumentNavigationCompleteCallback(); | ||
if (this._frame._loaderId !== this._initialLoaderId) | ||
this._newDocumentNavigationCompleteCallback(); | ||
/** | ||
* @param {!Puppeteer.Request} request | ||
*/ | ||
_onRequest(request) { | ||
if (request.frame() !== this._frame || !request.isNavigationRequest()) | ||
return; | ||
this._navigationRequest = request; | ||
} | ||
/** | ||
* @param {!Puppeteer.Frame} frame | ||
* @param {!Array<string>} expectedLifecycle | ||
* @return {boolean} | ||
*/ | ||
function checkLifecycle(frame, expectedLifecycle) { | ||
for (const event of expectedLifecycle) { | ||
if (!frame._lifecycleEvents.has(event)) | ||
return false; | ||
} | ||
for (const child of frame.childFrames()) { | ||
if (!checkLifecycle(child, expectedLifecycle)) | ||
return false; | ||
} | ||
return true; | ||
_onFrameDetached(frame) { | ||
if (this._frame === frame) { | ||
this._terminationCallback.call(null, new Error('Navigating frame was detached')); | ||
return; | ||
} | ||
this._checkLifecycleComplete(); | ||
} | ||
} | ||
dispose() { | ||
helper.removeEventListeners(this._eventListeners); | ||
clearTimeout(this._maximumTimer); | ||
} | ||
/** | ||
* @return {?Puppeteer.Response} | ||
*/ | ||
navigationResponse() { | ||
return this._navigationRequest ? this._navigationRequest.response() : null; | ||
} | ||
/** | ||
* @param {!Error} error | ||
*/ | ||
_terminate(error) { | ||
this._terminationCallback.call(null, error); | ||
} | ||
/** | ||
* @return {!Promise<?Error>} | ||
*/ | ||
sameDocumentNavigationPromise() { | ||
return this._sameDocumentNavigationPromise; | ||
} | ||
/** | ||
* @return {!Promise<?Error>} | ||
*/ | ||
newDocumentNavigationPromise() { | ||
return this._newDocumentNavigationPromise; | ||
} | ||
/** | ||
* @return {!Promise} | ||
*/ | ||
lifecyclePromise() { | ||
return this._lifecyclePromise; | ||
} | ||
/** | ||
* @return {!Promise<?Error>} | ||
*/ | ||
timeoutOrTerminationPromise() { | ||
return Promise.race([this._timeoutPromise, this._terminationPromise]); | ||
} | ||
/** | ||
* @return {!Promise<?Error>} | ||
*/ | ||
_createTimeoutPromise() { | ||
if (!this._timeout) | ||
return new Promise(() => { }); | ||
const errorMessage = 'Navigation timeout of ' + this._timeout + ' ms exceeded'; | ||
return new Promise(fulfill => this._maximumTimer = setTimeout(fulfill, this._timeout)) | ||
.then(() => new TimeoutError(errorMessage)); | ||
} | ||
/** | ||
* @param {!Puppeteer.Frame} frame | ||
*/ | ||
_navigatedWithinDocument(frame) { | ||
if (frame !== this._frame) | ||
return; | ||
this._hasSameDocumentNavigation = true; | ||
this._checkLifecycleComplete(); | ||
} | ||
_checkLifecycleComplete() { | ||
// We expect navigation to commit. | ||
if (!checkLifecycle(this._frame, this._expectedLifecycle)) | ||
return; | ||
this._lifecycleCallback(); | ||
if (this._frame._loaderId === this._initialLoaderId && !this._hasSameDocumentNavigation) | ||
return; | ||
if (this._hasSameDocumentNavigation) | ||
this._sameDocumentNavigationCompleteCallback(); | ||
if (this._frame._loaderId !== this._initialLoaderId) | ||
this._newDocumentNavigationCompleteCallback(); | ||
/** | ||
* @param {!Puppeteer.Frame} frame | ||
* @param {!Array<string>} expectedLifecycle | ||
* @return {boolean} | ||
*/ | ||
function checkLifecycle(frame, expectedLifecycle) { | ||
for (const event of expectedLifecycle) { | ||
if (!frame._lifecycleEvents.has(event)) | ||
return false; | ||
} | ||
for (const child of frame.childFrames()) { | ||
if (!checkLifecycle(child, expectedLifecycle)) | ||
return false; | ||
} | ||
return true; | ||
} | ||
} | ||
dispose() { | ||
helper.removeEventListeners(this._eventListeners); | ||
clearTimeout(this._maximumTimer); | ||
} | ||
} | ||
const puppeteerToProtocolLifecycle = new Map([ | ||
['load', 'load'], | ||
['domcontentloaded', 'DOMContentLoaded'], | ||
['networkidle0', 'networkIdle'], | ||
['networkidle2', 'networkAlmostIdle'], | ||
['load', 'load'], | ||
['domcontentloaded', 'DOMContentLoaded'], | ||
['networkidle0', 'networkIdle'], | ||
['networkidle2', 'networkAlmostIdle'], | ||
]); | ||
module.exports = {LifecycleWatcher}; | ||
module.exports = { LifecycleWatcher }; |
@@ -21,117 +21,104 @@ /** | ||
class Multimap { | ||
constructor() { | ||
this._map = new Map(); | ||
} | ||
/** | ||
* @param {T} key | ||
* @param {V} value | ||
*/ | ||
set(key, value) { | ||
let set = this._map.get(key); | ||
if (!set) { | ||
set = new Set(); | ||
this._map.set(key, set); | ||
constructor() { | ||
this._map = new Map(); | ||
} | ||
set.add(value); | ||
} | ||
/** | ||
* @param {T} key | ||
* @return {!Set<V>} | ||
*/ | ||
get(key) { | ||
let result = this._map.get(key); | ||
if (!result) | ||
result = new Set(); | ||
return result; | ||
} | ||
/** | ||
* @param {T} key | ||
* @return {boolean} | ||
*/ | ||
has(key) { | ||
return this._map.has(key); | ||
} | ||
/** | ||
* @param {T} key | ||
* @param {V} value | ||
* @return {boolean} | ||
*/ | ||
hasValue(key, value) { | ||
const set = this._map.get(key); | ||
if (!set) | ||
return false; | ||
return set.has(value); | ||
} | ||
/** | ||
* @return {number} | ||
*/ | ||
get size() { | ||
return this._map.size; | ||
} | ||
/** | ||
* @param {T} key | ||
* @param {V} value | ||
* @return {boolean} | ||
*/ | ||
delete(key, value) { | ||
const values = this.get(key); | ||
const result = values.delete(value); | ||
if (!values.size) | ||
this._map.delete(key); | ||
return result; | ||
} | ||
/** | ||
* @param {T} key | ||
*/ | ||
deleteAll(key) { | ||
this._map.delete(key); | ||
} | ||
/** | ||
* @param {T} key | ||
* @return {V} | ||
*/ | ||
firstValue(key) { | ||
const set = this._map.get(key); | ||
if (!set) | ||
return null; | ||
return set.values().next().value; | ||
} | ||
/** | ||
* @return {T} | ||
*/ | ||
firstKey() { | ||
return this._map.keys().next().value; | ||
} | ||
/** | ||
* @return {!Array<V>} | ||
*/ | ||
valuesArray() { | ||
const result = []; | ||
for (const key of this._map.keys()) | ||
result.push(...Array.from(this._map.get(key).values())); | ||
return result; | ||
} | ||
/** | ||
* @return {!Array<T>} | ||
*/ | ||
keysArray() { | ||
return Array.from(this._map.keys()); | ||
} | ||
clear() { | ||
this._map.clear(); | ||
} | ||
/** | ||
* @param {T} key | ||
* @param {V} value | ||
*/ | ||
set(key, value) { | ||
let set = this._map.get(key); | ||
if (!set) { | ||
set = new Set(); | ||
this._map.set(key, set); | ||
} | ||
set.add(value); | ||
} | ||
/** | ||
* @param {T} key | ||
* @return {!Set<V>} | ||
*/ | ||
get(key) { | ||
let result = this._map.get(key); | ||
if (!result) | ||
result = new Set(); | ||
return result; | ||
} | ||
/** | ||
* @param {T} key | ||
* @return {boolean} | ||
*/ | ||
has(key) { | ||
return this._map.has(key); | ||
} | ||
/** | ||
* @param {T} key | ||
* @param {V} value | ||
* @return {boolean} | ||
*/ | ||
hasValue(key, value) { | ||
const set = this._map.get(key); | ||
if (!set) | ||
return false; | ||
return set.has(value); | ||
} | ||
/** | ||
* @return {number} | ||
*/ | ||
get size() { | ||
return this._map.size; | ||
} | ||
/** | ||
* @param {T} key | ||
* @param {V} value | ||
* @return {boolean} | ||
*/ | ||
delete(key, value) { | ||
const values = this.get(key); | ||
const result = values.delete(value); | ||
if (!values.size) | ||
this._map.delete(key); | ||
return result; | ||
} | ||
/** | ||
* @param {T} key | ||
*/ | ||
deleteAll(key) { | ||
this._map.delete(key); | ||
} | ||
/** | ||
* @param {T} key | ||
* @return {V} | ||
*/ | ||
firstValue(key) { | ||
const set = this._map.get(key); | ||
if (!set) | ||
return null; | ||
return set.values().next().value; | ||
} | ||
/** | ||
* @return {T} | ||
*/ | ||
firstKey() { | ||
return this._map.keys().next().value; | ||
} | ||
/** | ||
* @return {!Array<V>} | ||
*/ | ||
valuesArray() { | ||
const result = []; | ||
for (const key of this._map.keys()) | ||
result.push(...Array.from(this._map.get(key).values())); | ||
return result; | ||
} | ||
/** | ||
* @return {!Array<T>} | ||
*/ | ||
keysArray() { | ||
return Array.from(this._map.keys()); | ||
} | ||
clear() { | ||
this._map.clear(); | ||
} | ||
} | ||
module.exports = Multimap; |
@@ -17,699 +17,628 @@ /** | ||
const EventEmitter = require('events'); | ||
const {helper, assert, debugError} = require('./helper'); | ||
const {Events} = require('./Events'); | ||
const { helper, assert, debugError } = require('./helper'); | ||
const { Events } = require('./Events'); | ||
class NetworkManager extends EventEmitter { | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Puppeteer.FrameManager} frameManager | ||
*/ | ||
constructor(client, ignoreHTTPSErrors, frameManager) { | ||
super(); | ||
this._client = client; | ||
this._ignoreHTTPSErrors = ignoreHTTPSErrors; | ||
this._frameManager = frameManager; | ||
/** @type {!Map<string, !Request>} */ | ||
this._requestIdToRequest = new Map(); | ||
/** @type {!Map<string, !Protocol.Network.requestWillBeSentPayload>} */ | ||
this._requestIdToRequestWillBeSentEvent = new Map(); | ||
/** @type {!Object<string, string>} */ | ||
this._extraHTTPHeaders = {}; | ||
this._offline = false; | ||
/** @type {?{username: string, password: string}} */ | ||
this._credentials = null; | ||
/** @type {!Set<string>} */ | ||
this._attemptedAuthentications = new Set(); | ||
this._userRequestInterceptionEnabled = false; | ||
this._protocolRequestInterceptionEnabled = false; | ||
this._userCacheDisabled = false; | ||
/** @type {!Map<string, string>} */ | ||
this._requestIdToInterceptionId = new Map(); | ||
this._client.on('Fetch.requestPaused', this._onRequestPaused.bind(this)); | ||
this._client.on('Fetch.authRequired', this._onAuthRequired.bind(this)); | ||
this._client.on('Network.requestWillBeSent', this._onRequestWillBeSent.bind(this)); | ||
this._client.on('Network.requestServedFromCache', this._onRequestServedFromCache.bind(this)); | ||
this._client.on('Network.responseReceived', this._onResponseReceived.bind(this)); | ||
this._client.on('Network.loadingFinished', this._onLoadingFinished.bind(this)); | ||
this._client.on('Network.loadingFailed', this._onLoadingFailed.bind(this)); | ||
} | ||
async initialize() { | ||
await this._client.send('Network.enable'); | ||
if (this._ignoreHTTPSErrors) | ||
await this._client.send('Security.setIgnoreCertificateErrors', {ignore: true}); | ||
} | ||
/** | ||
* @param {?{username: string, password: string}} credentials | ||
*/ | ||
async authenticate(credentials) { | ||
this._credentials = credentials; | ||
await this._updateProtocolRequestInterception(); | ||
} | ||
/** | ||
* @param {!Object<string, string>} extraHTTPHeaders | ||
*/ | ||
async setExtraHTTPHeaders(extraHTTPHeaders) { | ||
this._extraHTTPHeaders = {}; | ||
for (const key of Object.keys(extraHTTPHeaders)) { | ||
const value = extraHTTPHeaders[key]; | ||
assert(helper.isString(value), `Expected value of header "${key}" to be String, but "${typeof value}" is found.`); | ||
this._extraHTTPHeaders[key.toLowerCase()] = value; | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Puppeteer.FrameManager} frameManager | ||
*/ | ||
constructor(client, ignoreHTTPSErrors, frameManager) { | ||
super(); | ||
this._client = client; | ||
this._ignoreHTTPSErrors = ignoreHTTPSErrors; | ||
this._frameManager = frameManager; | ||
/** @type {!Map<string, !Request>} */ | ||
this._requestIdToRequest = new Map(); | ||
/** @type {!Map<string, !Protocol.Network.requestWillBeSentPayload>} */ | ||
this._requestIdToRequestWillBeSentEvent = new Map(); | ||
/** @type {!Object<string, string>} */ | ||
this._extraHTTPHeaders = {}; | ||
this._offline = false; | ||
/** @type {?{username: string, password: string}} */ | ||
this._credentials = null; | ||
/** @type {!Set<string>} */ | ||
this._attemptedAuthentications = new Set(); | ||
this._userRequestInterceptionEnabled = false; | ||
this._protocolRequestInterceptionEnabled = false; | ||
this._userCacheDisabled = false; | ||
/** @type {!Map<string, string>} */ | ||
this._requestIdToInterceptionId = new Map(); | ||
this._client.on('Fetch.requestPaused', this._onRequestPaused.bind(this)); | ||
this._client.on('Fetch.authRequired', this._onAuthRequired.bind(this)); | ||
this._client.on('Network.requestWillBeSent', this._onRequestWillBeSent.bind(this)); | ||
this._client.on('Network.requestServedFromCache', this._onRequestServedFromCache.bind(this)); | ||
this._client.on('Network.responseReceived', this._onResponseReceived.bind(this)); | ||
this._client.on('Network.loadingFinished', this._onLoadingFinished.bind(this)); | ||
this._client.on('Network.loadingFailed', this._onLoadingFailed.bind(this)); | ||
} | ||
await this._client.send('Network.setExtraHTTPHeaders', { headers: this._extraHTTPHeaders }); | ||
} | ||
/** | ||
* @return {!Object<string, string>} | ||
*/ | ||
extraHTTPHeaders() { | ||
return Object.assign({}, this._extraHTTPHeaders); | ||
} | ||
/** | ||
* @param {boolean} value | ||
*/ | ||
async setOfflineMode(value) { | ||
if (this._offline === value) | ||
return; | ||
this._offline = value; | ||
await this._client.send('Network.emulateNetworkConditions', { | ||
offline: this._offline, | ||
// values of 0 remove any active throttling. crbug.com/456324#c9 | ||
latency: 0, | ||
downloadThroughput: -1, | ||
uploadThroughput: -1 | ||
}); | ||
} | ||
/** | ||
* @param {string} userAgent | ||
*/ | ||
async setUserAgent(userAgent) { | ||
await this._client.send('Network.setUserAgentOverride', { userAgent }); | ||
} | ||
/** | ||
* @param {boolean} enabled | ||
*/ | ||
async setCacheEnabled(enabled) { | ||
this._userCacheDisabled = !enabled; | ||
await this._updateProtocolCacheDisabled(); | ||
} | ||
/** | ||
* @param {boolean} value | ||
*/ | ||
async setRequestInterception(value) { | ||
this._userRequestInterceptionEnabled = value; | ||
await this._updateProtocolRequestInterception(); | ||
} | ||
async _updateProtocolRequestInterception() { | ||
const enabled = this._userRequestInterceptionEnabled || !!this._credentials; | ||
if (enabled === this._protocolRequestInterceptionEnabled) | ||
return; | ||
this._protocolRequestInterceptionEnabled = enabled; | ||
if (enabled) { | ||
await Promise.all([ | ||
this._updateProtocolCacheDisabled(), | ||
this._client.send('Fetch.enable', { | ||
handleAuthRequests: true, | ||
patterns: [{urlPattern: '*'}], | ||
}), | ||
]); | ||
} else { | ||
await Promise.all([ | ||
this._updateProtocolCacheDisabled(), | ||
this._client.send('Fetch.disable') | ||
]); | ||
async initialize() { | ||
await this._client.send('Network.enable'); | ||
if (this._ignoreHTTPSErrors) | ||
await this._client.send('Security.setIgnoreCertificateErrors', { ignore: true }); | ||
} | ||
} | ||
async _updateProtocolCacheDisabled() { | ||
await this._client.send('Network.setCacheDisabled', { | ||
cacheDisabled: this._userCacheDisabled || this._protocolRequestInterceptionEnabled | ||
}); | ||
} | ||
/** | ||
* @param {!Protocol.Network.requestWillBeSentPayload} event | ||
*/ | ||
_onRequestWillBeSent(event) { | ||
// Request interception doesn't happen for data URLs with Network Service. | ||
if (this._protocolRequestInterceptionEnabled && !event.request.url.startsWith('data:')) { | ||
const requestId = event.requestId; | ||
const interceptionId = this._requestIdToInterceptionId.get(requestId); | ||
if (interceptionId) { | ||
this._onRequest(event, interceptionId); | ||
this._requestIdToInterceptionId.delete(requestId); | ||
} else { | ||
this._requestIdToRequestWillBeSentEvent.set(event.requestId, event); | ||
} | ||
return; | ||
/** | ||
* @param {?{username: string, password: string}} credentials | ||
*/ | ||
async authenticate(credentials) { | ||
this._credentials = credentials; | ||
await this._updateProtocolRequestInterception(); | ||
} | ||
this._onRequest(event, null); | ||
} | ||
/** | ||
* @param {!Protocol.Fetch.authRequiredPayload} event | ||
*/ | ||
_onAuthRequired(event) { | ||
/** @type {"Default"|"CancelAuth"|"ProvideCredentials"} */ | ||
let response = 'Default'; | ||
if (this._attemptedAuthentications.has(event.requestId)) { | ||
response = 'CancelAuth'; | ||
} else if (this._credentials) { | ||
response = 'ProvideCredentials'; | ||
this._attemptedAuthentications.add(event.requestId); | ||
/** | ||
* @param {!Object<string, string>} extraHTTPHeaders | ||
*/ | ||
async setExtraHTTPHeaders(extraHTTPHeaders) { | ||
this._extraHTTPHeaders = {}; | ||
for (const key of Object.keys(extraHTTPHeaders)) { | ||
const value = extraHTTPHeaders[key]; | ||
assert(helper.isString(value), `Expected value of header "${key}" to be String, but "${typeof value}" is found.`); | ||
this._extraHTTPHeaders[key.toLowerCase()] = value; | ||
} | ||
await this._client.send('Network.setExtraHTTPHeaders', { headers: this._extraHTTPHeaders }); | ||
} | ||
const {username, password} = this._credentials || {username: undefined, password: undefined}; | ||
this._client.send('Fetch.continueWithAuth', { | ||
requestId: event.requestId, | ||
authChallengeResponse: { response, username, password }, | ||
}).catch(debugError); | ||
} | ||
/** | ||
* @param {!Protocol.Fetch.requestPausedPayload} event | ||
*/ | ||
_onRequestPaused(event) { | ||
if (!this._userRequestInterceptionEnabled && this._protocolRequestInterceptionEnabled) { | ||
this._client.send('Fetch.continueRequest', { | ||
requestId: event.requestId | ||
}).catch(debugError); | ||
/** | ||
* @return {!Object<string, string>} | ||
*/ | ||
extraHTTPHeaders() { | ||
return Object.assign({}, this._extraHTTPHeaders); | ||
} | ||
const requestId = event.networkId; | ||
const interceptionId = event.requestId; | ||
if (requestId && this._requestIdToRequestWillBeSentEvent.has(requestId)) { | ||
const requestWillBeSentEvent = this._requestIdToRequestWillBeSentEvent.get(requestId); | ||
this._onRequest(requestWillBeSentEvent, interceptionId); | ||
this._requestIdToRequestWillBeSentEvent.delete(requestId); | ||
} else { | ||
this._requestIdToInterceptionId.set(requestId, interceptionId); | ||
/** | ||
* @param {boolean} value | ||
*/ | ||
async setOfflineMode(value) { | ||
if (this._offline === value) | ||
return; | ||
this._offline = value; | ||
await this._client.send('Network.emulateNetworkConditions', { | ||
offline: this._offline, | ||
// values of 0 remove any active throttling. crbug.com/456324#c9 | ||
latency: 0, | ||
downloadThroughput: -1, | ||
uploadThroughput: -1 | ||
}); | ||
} | ||
} | ||
/** | ||
* @param {!Protocol.Network.requestWillBeSentPayload} event | ||
* @param {?string} interceptionId | ||
*/ | ||
_onRequest(event, interceptionId) { | ||
let redirectChain = []; | ||
if (event.redirectResponse) { | ||
const request = this._requestIdToRequest.get(event.requestId); | ||
// If we connect late to the target, we could have missed the requestWillBeSent event. | ||
if (request) { | ||
this._handleRequestRedirect(request, event.redirectResponse); | ||
redirectChain = request._redirectChain; | ||
} | ||
/** | ||
* @param {string} userAgent | ||
*/ | ||
async setUserAgent(userAgent) { | ||
await this._client.send('Network.setUserAgentOverride', { userAgent }); | ||
} | ||
const frame = event.frameId ? this._frameManager.frame(event.frameId) : null; | ||
const request = new Request(this._client, frame, interceptionId, this._userRequestInterceptionEnabled, event, redirectChain); | ||
this._requestIdToRequest.set(event.requestId, request); | ||
this.emit(Events.NetworkManager.Request, request); | ||
} | ||
/** | ||
* @param {!Protocol.Network.requestServedFromCachePayload} event | ||
*/ | ||
_onRequestServedFromCache(event) { | ||
const request = this._requestIdToRequest.get(event.requestId); | ||
if (request) | ||
request._fromMemoryCache = true; | ||
} | ||
/** | ||
* @param {!Request} request | ||
* @param {!Protocol.Network.Response} responsePayload | ||
*/ | ||
_handleRequestRedirect(request, responsePayload) { | ||
const response = new Response(this._client, request, responsePayload); | ||
request._response = response; | ||
request._redirectChain.push(request); | ||
response._bodyLoadedPromiseFulfill.call(null, new Error('Response body is unavailable for redirect responses')); | ||
this._requestIdToRequest.delete(request._requestId); | ||
this._attemptedAuthentications.delete(request._interceptionId); | ||
this.emit(Events.NetworkManager.Response, response); | ||
this.emit(Events.NetworkManager.RequestFinished, request); | ||
} | ||
/** | ||
* @param {!Protocol.Network.responseReceivedPayload} event | ||
*/ | ||
_onResponseReceived(event) { | ||
const request = this._requestIdToRequest.get(event.requestId); | ||
// FileUpload sends a response without a matching request. | ||
if (!request) | ||
return; | ||
const response = new Response(this._client, request, event.response); | ||
request._response = response; | ||
this.emit(Events.NetworkManager.Response, response); | ||
} | ||
/** | ||
* @param {!Protocol.Network.loadingFinishedPayload} event | ||
*/ | ||
_onLoadingFinished(event) { | ||
const request = this._requestIdToRequest.get(event.requestId); | ||
// For certain requestIds we never receive requestWillBeSent event. | ||
// @see https://crbug.com/750469 | ||
if (!request) | ||
return; | ||
// Under certain conditions we never get the Network.responseReceived | ||
// event from protocol. @see https://crbug.com/883475 | ||
if (request.response()) | ||
request.response()._bodyLoadedPromiseFulfill.call(null); | ||
this._requestIdToRequest.delete(request._requestId); | ||
this._attemptedAuthentications.delete(request._interceptionId); | ||
this.emit(Events.NetworkManager.RequestFinished, request); | ||
} | ||
/** | ||
* @param {!Protocol.Network.loadingFailedPayload} event | ||
*/ | ||
_onLoadingFailed(event) { | ||
const request = this._requestIdToRequest.get(event.requestId); | ||
// For certain requestIds we never receive requestWillBeSent event. | ||
// @see https://crbug.com/750469 | ||
if (!request) | ||
return; | ||
request._failureText = event.errorText; | ||
const response = request.response(); | ||
if (response) | ||
response._bodyLoadedPromiseFulfill.call(null); | ||
this._requestIdToRequest.delete(request._requestId); | ||
this._attemptedAuthentications.delete(request._interceptionId); | ||
this.emit(Events.NetworkManager.RequestFailed, request); | ||
} | ||
/** | ||
* @param {boolean} enabled | ||
*/ | ||
async setCacheEnabled(enabled) { | ||
this._userCacheDisabled = !enabled; | ||
await this._updateProtocolCacheDisabled(); | ||
} | ||
/** | ||
* @param {boolean} value | ||
*/ | ||
async setRequestInterception(value) { | ||
this._userRequestInterceptionEnabled = value; | ||
await this._updateProtocolRequestInterception(); | ||
} | ||
async _updateProtocolRequestInterception() { | ||
const enabled = this._userRequestInterceptionEnabled || !!this._credentials; | ||
if (enabled === this._protocolRequestInterceptionEnabled) | ||
return; | ||
this._protocolRequestInterceptionEnabled = enabled; | ||
if (enabled) { | ||
await Promise.all([ | ||
this._updateProtocolCacheDisabled(), | ||
this._client.send('Fetch.enable', { | ||
handleAuthRequests: true, | ||
patterns: [{ urlPattern: '*' }], | ||
}), | ||
]); | ||
} | ||
else { | ||
await Promise.all([ | ||
this._updateProtocolCacheDisabled(), | ||
this._client.send('Fetch.disable') | ||
]); | ||
} | ||
} | ||
async _updateProtocolCacheDisabled() { | ||
await this._client.send('Network.setCacheDisabled', { | ||
cacheDisabled: this._userCacheDisabled || this._protocolRequestInterceptionEnabled | ||
}); | ||
} | ||
/** | ||
* @param {!Protocol.Network.requestWillBeSentPayload} event | ||
*/ | ||
_onRequestWillBeSent(event) { | ||
// Request interception doesn't happen for data URLs with Network Service. | ||
if (this._protocolRequestInterceptionEnabled && !event.request.url.startsWith('data:')) { | ||
const requestId = event.requestId; | ||
const interceptionId = this._requestIdToInterceptionId.get(requestId); | ||
if (interceptionId) { | ||
this._onRequest(event, interceptionId); | ||
this._requestIdToInterceptionId.delete(requestId); | ||
} | ||
else { | ||
this._requestIdToRequestWillBeSentEvent.set(event.requestId, event); | ||
} | ||
return; | ||
} | ||
this._onRequest(event, null); | ||
} | ||
/** | ||
* @param {!Protocol.Fetch.authRequiredPayload} event | ||
*/ | ||
_onAuthRequired(event) { | ||
/** @type {"Default"|"CancelAuth"|"ProvideCredentials"} */ | ||
let response = 'Default'; | ||
if (this._attemptedAuthentications.has(event.requestId)) { | ||
response = 'CancelAuth'; | ||
} | ||
else if (this._credentials) { | ||
response = 'ProvideCredentials'; | ||
this._attemptedAuthentications.add(event.requestId); | ||
} | ||
const { username, password } = this._credentials || { username: undefined, password: undefined }; | ||
this._client.send('Fetch.continueWithAuth', { | ||
requestId: event.requestId, | ||
authChallengeResponse: { response, username, password }, | ||
}).catch(debugError); | ||
} | ||
/** | ||
* @param {!Protocol.Fetch.requestPausedPayload} event | ||
*/ | ||
_onRequestPaused(event) { | ||
if (!this._userRequestInterceptionEnabled && this._protocolRequestInterceptionEnabled) { | ||
this._client.send('Fetch.continueRequest', { | ||
requestId: event.requestId | ||
}).catch(debugError); | ||
} | ||
const requestId = event.networkId; | ||
const interceptionId = event.requestId; | ||
if (requestId && this._requestIdToRequestWillBeSentEvent.has(requestId)) { | ||
const requestWillBeSentEvent = this._requestIdToRequestWillBeSentEvent.get(requestId); | ||
this._onRequest(requestWillBeSentEvent, interceptionId); | ||
this._requestIdToRequestWillBeSentEvent.delete(requestId); | ||
} | ||
else { | ||
this._requestIdToInterceptionId.set(requestId, interceptionId); | ||
} | ||
} | ||
/** | ||
* @param {!Protocol.Network.requestWillBeSentPayload} event | ||
* @param {?string} interceptionId | ||
*/ | ||
_onRequest(event, interceptionId) { | ||
let redirectChain = []; | ||
if (event.redirectResponse) { | ||
const request = this._requestIdToRequest.get(event.requestId); | ||
// If we connect late to the target, we could have missed the requestWillBeSent event. | ||
if (request) { | ||
this._handleRequestRedirect(request, event.redirectResponse); | ||
redirectChain = request._redirectChain; | ||
} | ||
} | ||
const frame = event.frameId ? this._frameManager.frame(event.frameId) : null; | ||
const request = new Request(this._client, frame, interceptionId, this._userRequestInterceptionEnabled, event, redirectChain); | ||
this._requestIdToRequest.set(event.requestId, request); | ||
this.emit(Events.NetworkManager.Request, request); | ||
} | ||
/** | ||
* @param {!Protocol.Network.requestServedFromCachePayload} event | ||
*/ | ||
_onRequestServedFromCache(event) { | ||
const request = this._requestIdToRequest.get(event.requestId); | ||
if (request) | ||
request._fromMemoryCache = true; | ||
} | ||
/** | ||
* @param {!Request} request | ||
* @param {!Protocol.Network.Response} responsePayload | ||
*/ | ||
_handleRequestRedirect(request, responsePayload) { | ||
const response = new Response(this._client, request, responsePayload); | ||
request._response = response; | ||
request._redirectChain.push(request); | ||
response._bodyLoadedPromiseFulfill.call(null, new Error('Response body is unavailable for redirect responses')); | ||
this._requestIdToRequest.delete(request._requestId); | ||
this._attemptedAuthentications.delete(request._interceptionId); | ||
this.emit(Events.NetworkManager.Response, response); | ||
this.emit(Events.NetworkManager.RequestFinished, request); | ||
} | ||
/** | ||
* @param {!Protocol.Network.responseReceivedPayload} event | ||
*/ | ||
_onResponseReceived(event) { | ||
const request = this._requestIdToRequest.get(event.requestId); | ||
// FileUpload sends a response without a matching request. | ||
if (!request) | ||
return; | ||
const response = new Response(this._client, request, event.response); | ||
request._response = response; | ||
this.emit(Events.NetworkManager.Response, response); | ||
} | ||
/** | ||
* @param {!Protocol.Network.loadingFinishedPayload} event | ||
*/ | ||
_onLoadingFinished(event) { | ||
const request = this._requestIdToRequest.get(event.requestId); | ||
// For certain requestIds we never receive requestWillBeSent event. | ||
// @see https://crbug.com/750469 | ||
if (!request) | ||
return; | ||
// Under certain conditions we never get the Network.responseReceived | ||
// event from protocol. @see https://crbug.com/883475 | ||
if (request.response()) | ||
request.response()._bodyLoadedPromiseFulfill.call(null); | ||
this._requestIdToRequest.delete(request._requestId); | ||
this._attemptedAuthentications.delete(request._interceptionId); | ||
this.emit(Events.NetworkManager.RequestFinished, request); | ||
} | ||
/** | ||
* @param {!Protocol.Network.loadingFailedPayload} event | ||
*/ | ||
_onLoadingFailed(event) { | ||
const request = this._requestIdToRequest.get(event.requestId); | ||
// For certain requestIds we never receive requestWillBeSent event. | ||
// @see https://crbug.com/750469 | ||
if (!request) | ||
return; | ||
request._failureText = event.errorText; | ||
const response = request.response(); | ||
if (response) | ||
response._bodyLoadedPromiseFulfill.call(null); | ||
this._requestIdToRequest.delete(request._requestId); | ||
this._attemptedAuthentications.delete(request._interceptionId); | ||
this.emit(Events.NetworkManager.RequestFailed, request); | ||
} | ||
} | ||
class Request { | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {?Puppeteer.Frame} frame | ||
* @param {string} interceptionId | ||
* @param {boolean} allowInterception | ||
* @param {!Protocol.Network.requestWillBeSentPayload} event | ||
* @param {!Array<!Request>} redirectChain | ||
*/ | ||
constructor(client, frame, interceptionId, allowInterception, event, redirectChain) { | ||
this._client = client; | ||
this._requestId = event.requestId; | ||
this._isNavigationRequest = event.requestId === event.loaderId && event.type === 'Document'; | ||
this._interceptionId = interceptionId; | ||
this._allowInterception = allowInterception; | ||
this._interceptionHandled = false; | ||
this._response = null; | ||
this._failureText = null; | ||
this._url = event.request.url; | ||
this._resourceType = event.type.toLowerCase(); | ||
this._method = event.request.method; | ||
this._postData = event.request.postData; | ||
this._headers = {}; | ||
this._frame = frame; | ||
this._redirectChain = redirectChain; | ||
for (const key of Object.keys(event.request.headers)) | ||
this._headers[key.toLowerCase()] = event.request.headers[key]; | ||
this._fromMemoryCache = false; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
url() { | ||
return this._url; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
resourceType() { | ||
return this._resourceType; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
method() { | ||
return this._method; | ||
} | ||
/** | ||
* @return {string|undefined} | ||
*/ | ||
postData() { | ||
return this._postData; | ||
} | ||
/** | ||
* @return {!Object} | ||
*/ | ||
headers() { | ||
return this._headers; | ||
} | ||
/** | ||
* @return {?Response} | ||
*/ | ||
response() { | ||
return this._response; | ||
} | ||
/** | ||
* @return {?Puppeteer.Frame} | ||
*/ | ||
frame() { | ||
return this._frame; | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
isNavigationRequest() { | ||
return this._isNavigationRequest; | ||
} | ||
/** | ||
* @return {!Array<!Request>} | ||
*/ | ||
redirectChain() { | ||
return this._redirectChain.slice(); | ||
} | ||
/** | ||
* @return {?{errorText: string}} | ||
*/ | ||
failure() { | ||
if (!this._failureText) | ||
return null; | ||
return { | ||
errorText: this._failureText | ||
}; | ||
} | ||
/** | ||
* @param {!{url?: string, method?:string, postData?: string, headers?: !Object}} overrides | ||
*/ | ||
async continue(overrides = {}) { | ||
// Request interception is not supported for data: urls. | ||
if (this._url.startsWith('data:')) | ||
return; | ||
assert(this._allowInterception, 'Request Interception is not enabled!'); | ||
assert(!this._interceptionHandled, 'Request is already handled!'); | ||
const { | ||
url, | ||
method, | ||
postData, | ||
headers | ||
} = overrides; | ||
this._interceptionHandled = true; | ||
await this._client.send('Fetch.continueRequest', { | ||
requestId: this._interceptionId, | ||
url, | ||
method, | ||
postData, | ||
headers: headers ? headersArray(headers) : undefined, | ||
}).catch(error => { | ||
// In certain cases, protocol will return error if the request was already canceled | ||
// or the page was closed. We should tolerate these errors. | ||
debugError(error); | ||
}); | ||
} | ||
/** | ||
* @param {!{status: number, headers: Object, contentType: string, body: (string|Buffer)}} response | ||
*/ | ||
async respond(response) { | ||
// Mocking responses for dataURL requests is not currently supported. | ||
if (this._url.startsWith('data:')) | ||
return; | ||
assert(this._allowInterception, 'Request Interception is not enabled!'); | ||
assert(!this._interceptionHandled, 'Request is already handled!'); | ||
this._interceptionHandled = true; | ||
const responseBody = response.body && helper.isString(response.body) ? Buffer.from(/** @type {string} */(response.body)) : /** @type {?Buffer} */(response.body || null); | ||
/** @type {!Object<string, string>} */ | ||
const responseHeaders = {}; | ||
if (response.headers) { | ||
for (const header of Object.keys(response.headers)) | ||
responseHeaders[header.toLowerCase()] = response.headers[header]; | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {?Puppeteer.Frame} frame | ||
* @param {string} interceptionId | ||
* @param {boolean} allowInterception | ||
* @param {!Protocol.Network.requestWillBeSentPayload} event | ||
* @param {!Array<!Request>} redirectChain | ||
*/ | ||
constructor(client, frame, interceptionId, allowInterception, event, redirectChain) { | ||
this._client = client; | ||
this._requestId = event.requestId; | ||
this._isNavigationRequest = event.requestId === event.loaderId && event.type === 'Document'; | ||
this._interceptionId = interceptionId; | ||
this._allowInterception = allowInterception; | ||
this._interceptionHandled = false; | ||
this._response = null; | ||
this._failureText = null; | ||
this._url = event.request.url; | ||
this._resourceType = event.type.toLowerCase(); | ||
this._method = event.request.method; | ||
this._postData = event.request.postData; | ||
this._headers = {}; | ||
this._frame = frame; | ||
this._redirectChain = redirectChain; | ||
for (const key of Object.keys(event.request.headers)) | ||
this._headers[key.toLowerCase()] = event.request.headers[key]; | ||
this._fromMemoryCache = false; | ||
} | ||
if (response.contentType) | ||
responseHeaders['content-type'] = response.contentType; | ||
if (responseBody && !('content-length' in responseHeaders)) | ||
responseHeaders['content-length'] = String(Buffer.byteLength(responseBody)); | ||
await this._client.send('Fetch.fulfillRequest', { | ||
requestId: this._interceptionId, | ||
responseCode: response.status || 200, | ||
responsePhrase: STATUS_TEXTS[response.status || 200], | ||
responseHeaders: headersArray(responseHeaders), | ||
body: responseBody ? responseBody.toString('base64') : undefined, | ||
}).catch(error => { | ||
// In certain cases, protocol will return error if the request was already canceled | ||
// or the page was closed. We should tolerate these errors. | ||
debugError(error); | ||
}); | ||
} | ||
/** | ||
* @param {string=} errorCode | ||
*/ | ||
async abort(errorCode = 'failed') { | ||
// Request interception is not supported for data: urls. | ||
if (this._url.startsWith('data:')) | ||
return; | ||
const errorReason = errorReasons[errorCode]; | ||
assert(errorReason, 'Unknown error code: ' + errorCode); | ||
assert(this._allowInterception, 'Request Interception is not enabled!'); | ||
assert(!this._interceptionHandled, 'Request is already handled!'); | ||
this._interceptionHandled = true; | ||
await this._client.send('Fetch.failRequest', { | ||
requestId: this._interceptionId, | ||
errorReason | ||
}).catch(error => { | ||
// In certain cases, protocol will return error if the request was already canceled | ||
// or the page was closed. We should tolerate these errors. | ||
debugError(error); | ||
}); | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
url() { | ||
return this._url; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
resourceType() { | ||
return this._resourceType; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
method() { | ||
return this._method; | ||
} | ||
/** | ||
* @return {string|undefined} | ||
*/ | ||
postData() { | ||
return this._postData; | ||
} | ||
/** | ||
* @return {!Object} | ||
*/ | ||
headers() { | ||
return this._headers; | ||
} | ||
/** | ||
* @return {?Response} | ||
*/ | ||
response() { | ||
return this._response; | ||
} | ||
/** | ||
* @return {?Puppeteer.Frame} | ||
*/ | ||
frame() { | ||
return this._frame; | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
isNavigationRequest() { | ||
return this._isNavigationRequest; | ||
} | ||
/** | ||
* @return {!Array<!Request>} | ||
*/ | ||
redirectChain() { | ||
return this._redirectChain.slice(); | ||
} | ||
/** | ||
* @return {?{errorText: string}} | ||
*/ | ||
failure() { | ||
if (!this._failureText) | ||
return null; | ||
return { | ||
errorText: this._failureText | ||
}; | ||
} | ||
/** | ||
* @param {!{url?: string, method?:string, postData?: string, headers?: !Object}} overrides | ||
*/ | ||
async continue(overrides = {}) { | ||
// Request interception is not supported for data: urls. | ||
if (this._url.startsWith('data:')) | ||
return; | ||
assert(this._allowInterception, 'Request Interception is not enabled!'); | ||
assert(!this._interceptionHandled, 'Request is already handled!'); | ||
const { url, method, postData, headers } = overrides; | ||
this._interceptionHandled = true; | ||
await this._client.send('Fetch.continueRequest', { | ||
requestId: this._interceptionId, | ||
url, | ||
method, | ||
postData, | ||
headers: headers ? headersArray(headers) : undefined, | ||
}).catch(error => { | ||
// In certain cases, protocol will return error if the request was already canceled | ||
// or the page was closed. We should tolerate these errors. | ||
debugError(error); | ||
}); | ||
} | ||
/** | ||
* @param {!{status: number, headers: Object, contentType: string, body: (string|Buffer)}} response | ||
*/ | ||
async respond(response) { | ||
// Mocking responses for dataURL requests is not currently supported. | ||
if (this._url.startsWith('data:')) | ||
return; | ||
assert(this._allowInterception, 'Request Interception is not enabled!'); | ||
assert(!this._interceptionHandled, 'Request is already handled!'); | ||
this._interceptionHandled = true; | ||
const responseBody = response.body && helper.isString(response.body) ? Buffer.from(/** @type {string} */ (response.body)) : /** @type {?Buffer} */ (response.body || null); | ||
/** @type {!Object<string, string>} */ | ||
const responseHeaders = {}; | ||
if (response.headers) { | ||
for (const header of Object.keys(response.headers)) | ||
responseHeaders[header.toLowerCase()] = response.headers[header]; | ||
} | ||
if (response.contentType) | ||
responseHeaders['content-type'] = response.contentType; | ||
if (responseBody && !('content-length' in responseHeaders)) | ||
responseHeaders['content-length'] = String(Buffer.byteLength(responseBody)); | ||
await this._client.send('Fetch.fulfillRequest', { | ||
requestId: this._interceptionId, | ||
responseCode: response.status || 200, | ||
responsePhrase: STATUS_TEXTS[response.status || 200], | ||
responseHeaders: headersArray(responseHeaders), | ||
body: responseBody ? responseBody.toString('base64') : undefined, | ||
}).catch(error => { | ||
// In certain cases, protocol will return error if the request was already canceled | ||
// or the page was closed. We should tolerate these errors. | ||
debugError(error); | ||
}); | ||
} | ||
/** | ||
* @param {string=} errorCode | ||
*/ | ||
async abort(errorCode = 'failed') { | ||
// Request interception is not supported for data: urls. | ||
if (this._url.startsWith('data:')) | ||
return; | ||
const errorReason = errorReasons[errorCode]; | ||
assert(errorReason, 'Unknown error code: ' + errorCode); | ||
assert(this._allowInterception, 'Request Interception is not enabled!'); | ||
assert(!this._interceptionHandled, 'Request is already handled!'); | ||
this._interceptionHandled = true; | ||
await this._client.send('Fetch.failRequest', { | ||
requestId: this._interceptionId, | ||
errorReason | ||
}).catch(error => { | ||
// In certain cases, protocol will return error if the request was already canceled | ||
// or the page was closed. We should tolerate these errors. | ||
debugError(error); | ||
}); | ||
} | ||
} | ||
const errorReasons = { | ||
'aborted': 'Aborted', | ||
'accessdenied': 'AccessDenied', | ||
'addressunreachable': 'AddressUnreachable', | ||
'blockedbyclient': 'BlockedByClient', | ||
'blockedbyresponse': 'BlockedByResponse', | ||
'connectionaborted': 'ConnectionAborted', | ||
'connectionclosed': 'ConnectionClosed', | ||
'connectionfailed': 'ConnectionFailed', | ||
'connectionrefused': 'ConnectionRefused', | ||
'connectionreset': 'ConnectionReset', | ||
'internetdisconnected': 'InternetDisconnected', | ||
'namenotresolved': 'NameNotResolved', | ||
'timedout': 'TimedOut', | ||
'failed': 'Failed', | ||
'aborted': 'Aborted', | ||
'accessdenied': 'AccessDenied', | ||
'addressunreachable': 'AddressUnreachable', | ||
'blockedbyclient': 'BlockedByClient', | ||
'blockedbyresponse': 'BlockedByResponse', | ||
'connectionaborted': 'ConnectionAborted', | ||
'connectionclosed': 'ConnectionClosed', | ||
'connectionfailed': 'ConnectionFailed', | ||
'connectionrefused': 'ConnectionRefused', | ||
'connectionreset': 'ConnectionReset', | ||
'internetdisconnected': 'InternetDisconnected', | ||
'namenotresolved': 'NameNotResolved', | ||
'timedout': 'TimedOut', | ||
'failed': 'Failed', | ||
}; | ||
class Response { | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Request} request | ||
* @param {!Protocol.Network.Response} responsePayload | ||
*/ | ||
constructor(client, request, responsePayload) { | ||
this._client = client; | ||
this._request = request; | ||
this._contentPromise = null; | ||
this._bodyLoadedPromise = new Promise(fulfill => { | ||
this._bodyLoadedPromiseFulfill = fulfill; | ||
}); | ||
this._remoteAddress = { | ||
ip: responsePayload.remoteIPAddress, | ||
port: responsePayload.remotePort, | ||
}; | ||
this._status = responsePayload.status; | ||
this._statusText = responsePayload.statusText; | ||
this._url = request.url(); | ||
this._fromDiskCache = !!responsePayload.fromDiskCache; | ||
this._fromServiceWorker = !!responsePayload.fromServiceWorker; | ||
this._headers = {}; | ||
for (const key of Object.keys(responsePayload.headers)) | ||
this._headers[key.toLowerCase()] = responsePayload.headers[key]; | ||
this._securityDetails = responsePayload.securityDetails ? new SecurityDetails(responsePayload.securityDetails) : null; | ||
} | ||
/** | ||
* @return {{ip: string, port: number}} | ||
*/ | ||
remoteAddress() { | ||
return this._remoteAddress; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
url() { | ||
return this._url; | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
ok() { | ||
return this._status === 0 || (this._status >= 200 && this._status <= 299); | ||
} | ||
/** | ||
* @return {number} | ||
*/ | ||
status() { | ||
return this._status; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
statusText() { | ||
return this._statusText; | ||
} | ||
/** | ||
* @return {!Object} | ||
*/ | ||
headers() { | ||
return this._headers; | ||
} | ||
/** | ||
* @return {?SecurityDetails} | ||
*/ | ||
securityDetails() { | ||
return this._securityDetails; | ||
} | ||
/** | ||
* @return {!Promise<!Buffer>} | ||
*/ | ||
buffer() { | ||
if (!this._contentPromise) { | ||
this._contentPromise = this._bodyLoadedPromise.then(async error => { | ||
if (error) | ||
throw error; | ||
const response = await this._client.send('Network.getResponseBody', { | ||
requestId: this._request._requestId | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Request} request | ||
* @param {!Protocol.Network.Response} responsePayload | ||
*/ | ||
constructor(client, request, responsePayload) { | ||
this._client = client; | ||
this._request = request; | ||
this._contentPromise = null; | ||
this._bodyLoadedPromise = new Promise(fulfill => { | ||
this._bodyLoadedPromiseFulfill = fulfill; | ||
}); | ||
return Buffer.from(response.body, response.base64Encoded ? 'base64' : 'utf8'); | ||
}); | ||
this._remoteAddress = { | ||
ip: responsePayload.remoteIPAddress, | ||
port: responsePayload.remotePort, | ||
}; | ||
this._status = responsePayload.status; | ||
this._statusText = responsePayload.statusText; | ||
this._url = request.url(); | ||
this._fromDiskCache = !!responsePayload.fromDiskCache; | ||
this._fromServiceWorker = !!responsePayload.fromServiceWorker; | ||
this._headers = {}; | ||
for (const key of Object.keys(responsePayload.headers)) | ||
this._headers[key.toLowerCase()] = responsePayload.headers[key]; | ||
this._securityDetails = responsePayload.securityDetails ? new SecurityDetails(responsePayload.securityDetails) : null; | ||
} | ||
return this._contentPromise; | ||
} | ||
/** | ||
* @return {!Promise<string>} | ||
*/ | ||
async text() { | ||
const content = await this.buffer(); | ||
return content.toString('utf8'); | ||
} | ||
/** | ||
* @return {!Promise<!Object>} | ||
*/ | ||
async json() { | ||
const content = await this.text(); | ||
return JSON.parse(content); | ||
} | ||
/** | ||
* @return {!Request} | ||
*/ | ||
request() { | ||
return this._request; | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
fromCache() { | ||
return this._fromDiskCache || this._request._fromMemoryCache; | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
fromServiceWorker() { | ||
return this._fromServiceWorker; | ||
} | ||
/** | ||
* @return {?Puppeteer.Frame} | ||
*/ | ||
frame() { | ||
return this._request.frame(); | ||
} | ||
/** | ||
* @return {{ip: string, port: number}} | ||
*/ | ||
remoteAddress() { | ||
return this._remoteAddress; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
url() { | ||
return this._url; | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
ok() { | ||
return this._status === 0 || (this._status >= 200 && this._status <= 299); | ||
} | ||
/** | ||
* @return {number} | ||
*/ | ||
status() { | ||
return this._status; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
statusText() { | ||
return this._statusText; | ||
} | ||
/** | ||
* @return {!Object} | ||
*/ | ||
headers() { | ||
return this._headers; | ||
} | ||
/** | ||
* @return {?SecurityDetails} | ||
*/ | ||
securityDetails() { | ||
return this._securityDetails; | ||
} | ||
/** | ||
* @return {!Promise<!Buffer>} | ||
*/ | ||
buffer() { | ||
if (!this._contentPromise) { | ||
this._contentPromise = this._bodyLoadedPromise.then(async (error) => { | ||
if (error) | ||
throw error; | ||
const response = await this._client.send('Network.getResponseBody', { | ||
requestId: this._request._requestId | ||
}); | ||
return Buffer.from(response.body, response.base64Encoded ? 'base64' : 'utf8'); | ||
}); | ||
} | ||
return this._contentPromise; | ||
} | ||
/** | ||
* @return {!Promise<string>} | ||
*/ | ||
async text() { | ||
const content = await this.buffer(); | ||
return content.toString('utf8'); | ||
} | ||
/** | ||
* @return {!Promise<!Object>} | ||
*/ | ||
async json() { | ||
const content = await this.text(); | ||
return JSON.parse(content); | ||
} | ||
/** | ||
* @return {!Request} | ||
*/ | ||
request() { | ||
return this._request; | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
fromCache() { | ||
return this._fromDiskCache || this._request._fromMemoryCache; | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
fromServiceWorker() { | ||
return this._fromServiceWorker; | ||
} | ||
/** | ||
* @return {?Puppeteer.Frame} | ||
*/ | ||
frame() { | ||
return this._request.frame(); | ||
} | ||
} | ||
class SecurityDetails { | ||
/** | ||
* @param {!Protocol.Network.SecurityDetails} securityPayload | ||
*/ | ||
constructor(securityPayload) { | ||
this._subjectName = securityPayload['subjectName']; | ||
this._issuer = securityPayload['issuer']; | ||
this._validFrom = securityPayload['validFrom']; | ||
this._validTo = securityPayload['validTo']; | ||
this._protocol = securityPayload['protocol']; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
subjectName() { | ||
return this._subjectName; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
issuer() { | ||
return this._issuer; | ||
} | ||
/** | ||
* @return {number} | ||
*/ | ||
validFrom() { | ||
return this._validFrom; | ||
} | ||
/** | ||
* @return {number} | ||
*/ | ||
validTo() { | ||
return this._validTo; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
protocol() { | ||
return this._protocol; | ||
} | ||
/** | ||
* @param {!Protocol.Network.SecurityDetails} securityPayload | ||
*/ | ||
constructor(securityPayload) { | ||
this._subjectName = securityPayload['subjectName']; | ||
this._issuer = securityPayload['issuer']; | ||
this._validFrom = securityPayload['validFrom']; | ||
this._validTo = securityPayload['validTo']; | ||
this._protocol = securityPayload['protocol']; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
subjectName() { | ||
return this._subjectName; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
issuer() { | ||
return this._issuer; | ||
} | ||
/** | ||
* @return {number} | ||
*/ | ||
validFrom() { | ||
return this._validFrom; | ||
} | ||
/** | ||
* @return {number} | ||
*/ | ||
validTo() { | ||
return this._validTo; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
protocol() { | ||
return this._protocol; | ||
} | ||
} | ||
/** | ||
@@ -720,77 +649,75 @@ * @param {Object<string, string>} headers | ||
function headersArray(headers) { | ||
const result = []; | ||
for (const name in headers) { | ||
if (!Object.is(headers[name], undefined)) | ||
result.push({name, value: headers[name] + ''}); | ||
} | ||
return result; | ||
const result = []; | ||
for (const name in headers) { | ||
if (!Object.is(headers[name], undefined)) | ||
result.push({ name, value: headers[name] + '' }); | ||
} | ||
return result; | ||
} | ||
// List taken from https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml with extra 306 and 418 codes. | ||
const STATUS_TEXTS = { | ||
'100': 'Continue', | ||
'101': 'Switching Protocols', | ||
'102': 'Processing', | ||
'103': 'Early Hints', | ||
'200': 'OK', | ||
'201': 'Created', | ||
'202': 'Accepted', | ||
'203': 'Non-Authoritative Information', | ||
'204': 'No Content', | ||
'205': 'Reset Content', | ||
'206': 'Partial Content', | ||
'207': 'Multi-Status', | ||
'208': 'Already Reported', | ||
'226': 'IM Used', | ||
'300': 'Multiple Choices', | ||
'301': 'Moved Permanently', | ||
'302': 'Found', | ||
'303': 'See Other', | ||
'304': 'Not Modified', | ||
'305': 'Use Proxy', | ||
'306': 'Switch Proxy', | ||
'307': 'Temporary Redirect', | ||
'308': 'Permanent Redirect', | ||
'400': 'Bad Request', | ||
'401': 'Unauthorized', | ||
'402': 'Payment Required', | ||
'403': 'Forbidden', | ||
'404': 'Not Found', | ||
'405': 'Method Not Allowed', | ||
'406': 'Not Acceptable', | ||
'407': 'Proxy Authentication Required', | ||
'408': 'Request Timeout', | ||
'409': 'Conflict', | ||
'410': 'Gone', | ||
'411': 'Length Required', | ||
'412': 'Precondition Failed', | ||
'413': 'Payload Too Large', | ||
'414': 'URI Too Long', | ||
'415': 'Unsupported Media Type', | ||
'416': 'Range Not Satisfiable', | ||
'417': 'Expectation Failed', | ||
'418': 'I\'m a teapot', | ||
'421': 'Misdirected Request', | ||
'422': 'Unprocessable Entity', | ||
'423': 'Locked', | ||
'424': 'Failed Dependency', | ||
'425': 'Too Early', | ||
'426': 'Upgrade Required', | ||
'428': 'Precondition Required', | ||
'429': 'Too Many Requests', | ||
'431': 'Request Header Fields Too Large', | ||
'451': 'Unavailable For Legal Reasons', | ||
'500': 'Internal Server Error', | ||
'501': 'Not Implemented', | ||
'502': 'Bad Gateway', | ||
'503': 'Service Unavailable', | ||
'504': 'Gateway Timeout', | ||
'505': 'HTTP Version Not Supported', | ||
'506': 'Variant Also Negotiates', | ||
'507': 'Insufficient Storage', | ||
'508': 'Loop Detected', | ||
'510': 'Not Extended', | ||
'511': 'Network Authentication Required', | ||
'100': 'Continue', | ||
'101': 'Switching Protocols', | ||
'102': 'Processing', | ||
'103': 'Early Hints', | ||
'200': 'OK', | ||
'201': 'Created', | ||
'202': 'Accepted', | ||
'203': 'Non-Authoritative Information', | ||
'204': 'No Content', | ||
'205': 'Reset Content', | ||
'206': 'Partial Content', | ||
'207': 'Multi-Status', | ||
'208': 'Already Reported', | ||
'226': 'IM Used', | ||
'300': 'Multiple Choices', | ||
'301': 'Moved Permanently', | ||
'302': 'Found', | ||
'303': 'See Other', | ||
'304': 'Not Modified', | ||
'305': 'Use Proxy', | ||
'306': 'Switch Proxy', | ||
'307': 'Temporary Redirect', | ||
'308': 'Permanent Redirect', | ||
'400': 'Bad Request', | ||
'401': 'Unauthorized', | ||
'402': 'Payment Required', | ||
'403': 'Forbidden', | ||
'404': 'Not Found', | ||
'405': 'Method Not Allowed', | ||
'406': 'Not Acceptable', | ||
'407': 'Proxy Authentication Required', | ||
'408': 'Request Timeout', | ||
'409': 'Conflict', | ||
'410': 'Gone', | ||
'411': 'Length Required', | ||
'412': 'Precondition Failed', | ||
'413': 'Payload Too Large', | ||
'414': 'URI Too Long', | ||
'415': 'Unsupported Media Type', | ||
'416': 'Range Not Satisfiable', | ||
'417': 'Expectation Failed', | ||
'418': 'I\'m a teapot', | ||
'421': 'Misdirected Request', | ||
'422': 'Unprocessable Entity', | ||
'423': 'Locked', | ||
'424': 'Failed Dependency', | ||
'425': 'Too Early', | ||
'426': 'Upgrade Required', | ||
'428': 'Precondition Required', | ||
'429': 'Too Many Requests', | ||
'431': 'Request Header Fields Too Large', | ||
'451': 'Unavailable For Legal Reasons', | ||
'500': 'Internal Server Error', | ||
'501': 'Not Implemented', | ||
'502': 'Bad Gateway', | ||
'503': 'Service Unavailable', | ||
'504': 'Gateway Timeout', | ||
'505': 'HTTP Version Not Supported', | ||
'506': 'Variant Also Negotiates', | ||
'507': 'Insufficient Storage', | ||
'508': 'Loop Detected', | ||
'510': 'Not Extended', | ||
'511': 'Network Authentication Required', | ||
}; | ||
module.exports = {Request, Response, NetworkManager, SecurityDetails}; | ||
module.exports = { Request, Response, NetworkManager, SecurityDetails }; |
2343
lib/Page.js
@@ -16,1134 +16,1013 @@ /** | ||
*/ | ||
const fs = require('fs'); | ||
const EventEmitter = require('events'); | ||
const mime = require('mime'); | ||
const {Events} = require('./Events'); | ||
const {Connection} = require('./Connection'); | ||
const {Dialog} = require('./Dialog'); | ||
const {EmulationManager} = require('./EmulationManager'); | ||
const {FrameManager} = require('./FrameManager'); | ||
const {Keyboard, Mouse, Touchscreen} = require('./Input'); | ||
const { Events } = require('./Events'); | ||
const { Connection } = require('./Connection'); | ||
const { Dialog } = require('./Dialog'); | ||
const { EmulationManager } = require('./EmulationManager'); | ||
const { FrameManager } = require('./FrameManager'); | ||
const { Keyboard, Mouse, Touchscreen } = require('./Input'); | ||
const Tracing = require('./Tracing'); | ||
const {helper, debugError, assert} = require('./helper'); | ||
const {Coverage} = require('./Coverage'); | ||
const {Worker} = require('./Worker'); | ||
const {createJSHandle} = require('./JSHandle'); | ||
const {Accessibility} = require('./Accessibility'); | ||
const {TimeoutSettings} = require('./TimeoutSettings'); | ||
const { helper, debugError, assert } = require('./helper'); | ||
const { Coverage } = require('./Coverage'); | ||
const { Worker: PuppeteerWorker } = require('./Worker'); | ||
const { createJSHandle } = require('./JSHandle'); | ||
const { Accessibility } = require('./Accessibility'); | ||
const { TimeoutSettings } = require('./TimeoutSettings'); | ||
const writeFileAsync = helper.promisify(fs.writeFile); | ||
class Page extends EventEmitter { | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Puppeteer.Target} target | ||
* @param {boolean} ignoreHTTPSErrors | ||
* @param {?Puppeteer.Viewport} defaultViewport | ||
* @param {!Puppeteer.TaskQueue} screenshotTaskQueue | ||
* @return {!Promise<!Page>} | ||
*/ | ||
static async create(client, target, ignoreHTTPSErrors, defaultViewport, screenshotTaskQueue) { | ||
const page = new Page(client, target, ignoreHTTPSErrors, screenshotTaskQueue); | ||
await page._initialize(); | ||
if (defaultViewport) | ||
await page.setViewport(defaultViewport); | ||
return page; | ||
} | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Puppeteer.Target} target | ||
* @param {boolean} ignoreHTTPSErrors | ||
* @param {!Puppeteer.TaskQueue} screenshotTaskQueue | ||
*/ | ||
constructor(client, target, ignoreHTTPSErrors, screenshotTaskQueue) { | ||
super(); | ||
this._closed = false; | ||
this._client = client; | ||
this._target = target; | ||
this._keyboard = new Keyboard(client); | ||
this._mouse = new Mouse(client, this._keyboard); | ||
this._timeoutSettings = new TimeoutSettings(); | ||
this._touchscreen = new Touchscreen(client, this._keyboard); | ||
this._accessibility = new Accessibility(client); | ||
/** @type {!FrameManager} */ | ||
this._frameManager = new FrameManager(client, this, ignoreHTTPSErrors, this._timeoutSettings); | ||
this._emulationManager = new EmulationManager(client); | ||
this._tracing = new Tracing(client); | ||
/** @type {!Map<string, Function>} */ | ||
this._pageBindings = new Map(); | ||
this._coverage = new Coverage(client); | ||
this._javascriptEnabled = true; | ||
/** @type {?Puppeteer.Viewport} */ | ||
this._viewport = null; | ||
this._screenshotTaskQueue = screenshotTaskQueue; | ||
/** @type {!Map<string, Worker>} */ | ||
this._workers = new Map(); | ||
client.on('Target.attachedToTarget', event => { | ||
if (event.targetInfo.type !== 'worker') { | ||
// If we don't detach from service workers, they will never die. | ||
client.send('Target.detachFromTarget', { | ||
sessionId: event.sessionId | ||
}).catch(debugError); | ||
return; | ||
} | ||
const session = Connection.fromSession(client).session(event.sessionId); | ||
const worker = new Worker(session, event.targetInfo.url, this._addConsoleMessage.bind(this), this._handleException.bind(this)); | ||
this._workers.set(event.sessionId, worker); | ||
this.emit(Events.Page.WorkerCreated, worker); | ||
}); | ||
client.on('Target.detachedFromTarget', event => { | ||
const worker = this._workers.get(event.sessionId); | ||
if (!worker) | ||
return; | ||
this.emit(Events.Page.WorkerDestroyed, worker); | ||
this._workers.delete(event.sessionId); | ||
}); | ||
this._frameManager.on(Events.FrameManager.FrameAttached, event => this.emit(Events.Page.FrameAttached, event)); | ||
this._frameManager.on(Events.FrameManager.FrameDetached, event => this.emit(Events.Page.FrameDetached, event)); | ||
this._frameManager.on(Events.FrameManager.FrameNavigated, event => this.emit(Events.Page.FrameNavigated, event)); | ||
const networkManager = this._frameManager.networkManager(); | ||
networkManager.on(Events.NetworkManager.Request, event => this.emit(Events.Page.Request, event)); | ||
networkManager.on(Events.NetworkManager.Response, event => this.emit(Events.Page.Response, event)); | ||
networkManager.on(Events.NetworkManager.RequestFailed, event => this.emit(Events.Page.RequestFailed, event)); | ||
networkManager.on(Events.NetworkManager.RequestFinished, event => this.emit(Events.Page.RequestFinished, event)); | ||
this._fileChooserInterceptors = new Set(); | ||
client.on('Page.domContentEventFired', event => this.emit(Events.Page.DOMContentLoaded)); | ||
client.on('Page.loadEventFired', event => this.emit(Events.Page.Load)); | ||
client.on('Runtime.consoleAPICalled', event => this._onConsoleAPI(event)); | ||
client.on('Runtime.bindingCalled', event => this._onBindingCalled(event)); | ||
client.on('Page.javascriptDialogOpening', event => this._onDialog(event)); | ||
client.on('Runtime.exceptionThrown', exception => this._handleException(exception.exceptionDetails)); | ||
client.on('Inspector.targetCrashed', event => this._onTargetCrashed()); | ||
client.on('Performance.metrics', event => this._emitMetrics(event)); | ||
client.on('Log.entryAdded', event => this._onLogEntryAdded(event)); | ||
client.on('Page.fileChooserOpened', event => this._onFileChooser(event)); | ||
this._target._isClosedPromise.then(() => { | ||
this.emit(Events.Page.Close); | ||
this._closed = true; | ||
}); | ||
} | ||
async _initialize() { | ||
await Promise.all([ | ||
this._frameManager.initialize(), | ||
this._client.send('Target.setAutoAttach', {autoAttach: true, waitForDebuggerOnStart: false, flatten: true}), | ||
this._client.send('Performance.enable', {}), | ||
this._client.send('Log.enable', {}), | ||
]); | ||
} | ||
/** | ||
* @param {!Protocol.Page.fileChooserOpenedPayload} event | ||
*/ | ||
async _onFileChooser(event) { | ||
if (!this._fileChooserInterceptors.size) | ||
return; | ||
const frame = this._frameManager.frame(event.frameId); | ||
const context = await frame.executionContext(); | ||
const element = await context._adoptBackendNodeId(event.backendNodeId); | ||
const interceptors = Array.from(this._fileChooserInterceptors); | ||
this._fileChooserInterceptors.clear(); | ||
const fileChooser = new FileChooser(this._client, element, event); | ||
for (const interceptor of interceptors) | ||
interceptor.call(null, fileChooser); | ||
} | ||
/** | ||
* @param {!{timeout?: number}=} options | ||
* @return !Promise<!FileChooser>} | ||
*/ | ||
async waitForFileChooser(options = {}) { | ||
if (!this._fileChooserInterceptors.size) | ||
await this._client.send('Page.setInterceptFileChooserDialog', {enabled: true}); | ||
const { | ||
timeout = this._timeoutSettings.timeout(), | ||
} = options; | ||
let callback; | ||
const promise = new Promise(x => callback = x); | ||
this._fileChooserInterceptors.add(callback); | ||
return helper.waitWithTimeout(promise, 'waiting for file chooser', timeout).catch(e => { | ||
this._fileChooserInterceptors.delete(callback); | ||
throw e; | ||
}); | ||
} | ||
/** | ||
* @param {!{longitude: number, latitude: number, accuracy: (number|undefined)}} options | ||
*/ | ||
async setGeolocation(options) { | ||
const { longitude, latitude, accuracy = 0} = options; | ||
if (longitude < -180 || longitude > 180) | ||
throw new Error(`Invalid longitude "${longitude}": precondition -180 <= LONGITUDE <= 180 failed.`); | ||
if (latitude < -90 || latitude > 90) | ||
throw new Error(`Invalid latitude "${latitude}": precondition -90 <= LATITUDE <= 90 failed.`); | ||
if (accuracy < 0) | ||
throw new Error(`Invalid accuracy "${accuracy}": precondition 0 <= ACCURACY failed.`); | ||
await this._client.send('Emulation.setGeolocationOverride', {longitude, latitude, accuracy}); | ||
} | ||
/** | ||
* @return {!Puppeteer.Target} | ||
*/ | ||
target() { | ||
return this._target; | ||
} | ||
/** | ||
* @return {!Puppeteer.Browser} | ||
*/ | ||
browser() { | ||
return this._target.browser(); | ||
} | ||
/** | ||
* @return {!Puppeteer.BrowserContext} | ||
*/ | ||
browserContext() { | ||
return this._target.browserContext(); | ||
} | ||
_onTargetCrashed() { | ||
this.emit('error', new Error('Page crashed!')); | ||
} | ||
/** | ||
* @param {!Protocol.Log.entryAddedPayload} event | ||
*/ | ||
_onLogEntryAdded(event) { | ||
const {level, text, args, source, url, lineNumber} = event.entry; | ||
if (args) | ||
args.map(arg => helper.releaseObject(this._client, arg)); | ||
if (source !== 'worker') | ||
this.emit(Events.Page.Console, new ConsoleMessage(level, text, [], {url, lineNumber})); | ||
} | ||
/** | ||
* @return {!Puppeteer.Frame} | ||
*/ | ||
mainFrame() { | ||
return this._frameManager.mainFrame(); | ||
} | ||
/** | ||
* @return {!Keyboard} | ||
*/ | ||
get keyboard() { | ||
return this._keyboard; | ||
} | ||
/** | ||
* @return {!Touchscreen} | ||
*/ | ||
get touchscreen() { | ||
return this._touchscreen; | ||
} | ||
/** | ||
* @return {!Coverage} | ||
*/ | ||
get coverage() { | ||
return this._coverage; | ||
} | ||
/** | ||
* @return {!Tracing} | ||
*/ | ||
get tracing() { | ||
return this._tracing; | ||
} | ||
/** | ||
* @return {!Accessibility} | ||
*/ | ||
get accessibility() { | ||
return this._accessibility; | ||
} | ||
/** | ||
* @return {!Array<Puppeteer.Frame>} | ||
*/ | ||
frames() { | ||
return this._frameManager.frames(); | ||
} | ||
/** | ||
* @return {!Array<!Worker>} | ||
*/ | ||
workers() { | ||
return Array.from(this._workers.values()); | ||
} | ||
/** | ||
* @param {boolean} value | ||
*/ | ||
async setRequestInterception(value) { | ||
return this._frameManager.networkManager().setRequestInterception(value); | ||
} | ||
/** | ||
* @param {boolean} enabled | ||
*/ | ||
setOfflineMode(enabled) { | ||
return this._frameManager.networkManager().setOfflineMode(enabled); | ||
} | ||
/** | ||
* @param {number} timeout | ||
*/ | ||
setDefaultNavigationTimeout(timeout) { | ||
this._timeoutSettings.setDefaultNavigationTimeout(timeout); | ||
} | ||
/** | ||
* @param {number} timeout | ||
*/ | ||
setDefaultTimeout(timeout) { | ||
this._timeoutSettings.setDefaultTimeout(timeout); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
async $(selector) { | ||
return this.mainFrame().$(selector); | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
async evaluateHandle(pageFunction, ...args) { | ||
const context = await this.mainFrame().executionContext(); | ||
return context.evaluateHandle(pageFunction, ...args); | ||
} | ||
/** | ||
* @param {!Puppeteer.JSHandle} prototypeHandle | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
async queryObjects(prototypeHandle) { | ||
const context = await this.mainFrame().executionContext(); | ||
return context.queryObjects(prototypeHandle); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<(!Object|undefined)>} | ||
*/ | ||
async $eval(selector, pageFunction, ...args) { | ||
return 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 this.mainFrame().$$eval(selector, pageFunction, ...args); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @return {!Promise<!Array<!Puppeteer.ElementHandle>>} | ||
*/ | ||
async $$(selector) { | ||
return this.mainFrame().$$(selector); | ||
} | ||
/** | ||
* @param {string} expression | ||
* @return {!Promise<!Array<!Puppeteer.ElementHandle>>} | ||
*/ | ||
async $x(expression) { | ||
return this.mainFrame().$x(expression); | ||
} | ||
/** | ||
* @param {!Array<string>} urls | ||
* @return {!Promise<!Array<Network.Cookie>>} | ||
*/ | ||
async cookies(...urls) { | ||
return (await this._client.send('Network.getCookies', { | ||
urls: urls.length ? urls : [this.url()] | ||
})).cookies; | ||
} | ||
/** | ||
* @param {Array<Protocol.Network.deleteCookiesParameters>} cookies | ||
*/ | ||
async deleteCookie(...cookies) { | ||
const pageURL = this.url(); | ||
for (const cookie of cookies) { | ||
const item = Object.assign({}, cookie); | ||
if (!cookie.url && pageURL.startsWith('http')) | ||
item.url = pageURL; | ||
await this._client.send('Network.deleteCookies', item); | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Puppeteer.Target} target | ||
* @param {boolean} ignoreHTTPSErrors | ||
* @param {?Puppeteer.Viewport} defaultViewport | ||
* @param {!Puppeteer.TaskQueue} screenshotTaskQueue | ||
* @return {!Promise<!Page>} | ||
*/ | ||
static async create(client, target, ignoreHTTPSErrors, defaultViewport, screenshotTaskQueue) { | ||
const page = new Page(client, target, ignoreHTTPSErrors, screenshotTaskQueue); | ||
await page._initialize(); | ||
if (defaultViewport) | ||
await page.setViewport(defaultViewport); | ||
return page; | ||
} | ||
} | ||
/** | ||
* @param {Array<Network.CookieParam>} cookies | ||
*/ | ||
async setCookie(...cookies) { | ||
const pageURL = this.url(); | ||
const startsWithHTTP = pageURL.startsWith('http'); | ||
const items = cookies.map(cookie => { | ||
const item = Object.assign({}, cookie); | ||
if (!item.url && startsWithHTTP) | ||
item.url = pageURL; | ||
assert(item.url !== 'about:blank', `Blank page can not have cookie "${item.name}"`); | ||
assert(!String.prototype.startsWith.call(item.url || '', 'data:'), `Data URL page can not have cookie "${item.name}"`); | ||
return item; | ||
}); | ||
await this.deleteCookie(...items); | ||
if (items.length) | ||
await this._client.send('Network.setCookies', { cookies: items }); | ||
} | ||
/** | ||
* @param {!{url?: string, path?: string, content?: string, type?: string}} options | ||
* @return {!Promise<!Puppeteer.ElementHandle>} | ||
*/ | ||
async addScriptTag(options) { | ||
return this.mainFrame().addScriptTag(options); | ||
} | ||
/** | ||
* @param {!{url?: string, path?: string, content?: string}} options | ||
* @return {!Promise<!Puppeteer.ElementHandle>} | ||
*/ | ||
async addStyleTag(options) { | ||
return this.mainFrame().addStyleTag(options); | ||
} | ||
/** | ||
* @param {string} name | ||
* @param {Function} puppeteerFunction | ||
*/ | ||
async exposeFunction(name, puppeteerFunction) { | ||
if (this._pageBindings.has(name)) | ||
throw new Error(`Failed to add page binding with name ${name}: window['${name}'] already exists!`); | ||
this._pageBindings.set(name, puppeteerFunction); | ||
const expression = helper.evaluationString(addPageBinding, name); | ||
await this._client.send('Runtime.addBinding', {name: name}); | ||
await this._client.send('Page.addScriptToEvaluateOnNewDocument', {source: expression}); | ||
await Promise.all(this.frames().map(frame => frame.evaluate(expression).catch(debugError))); | ||
function addPageBinding(bindingName) { | ||
const binding = window[bindingName]; | ||
window[bindingName] = (...args) => { | ||
const me = window[bindingName]; | ||
let callbacks = me['callbacks']; | ||
if (!callbacks) { | ||
callbacks = new Map(); | ||
me['callbacks'] = callbacks; | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
* @param {!Puppeteer.Target} target | ||
* @param {boolean} ignoreHTTPSErrors | ||
* @param {!Puppeteer.TaskQueue} screenshotTaskQueue | ||
*/ | ||
constructor(client, target, ignoreHTTPSErrors, screenshotTaskQueue) { | ||
super(); | ||
this._closed = false; | ||
this._client = client; | ||
this._target = target; | ||
this._keyboard = new Keyboard(client); | ||
this._mouse = new Mouse(client, this._keyboard); | ||
this._timeoutSettings = new TimeoutSettings(); | ||
this._touchscreen = new Touchscreen(client, this._keyboard); | ||
this._accessibility = new Accessibility(client); | ||
/** @type {!FrameManager} */ | ||
this._frameManager = new FrameManager(client, this, ignoreHTTPSErrors, this._timeoutSettings); | ||
this._emulationManager = new EmulationManager(client); | ||
this._tracing = new Tracing(client); | ||
/** @type {!Map<string, Function>} */ | ||
this._pageBindings = new Map(); | ||
this._coverage = new Coverage(client); | ||
this._javascriptEnabled = true; | ||
/** @type {?Puppeteer.Viewport} */ | ||
this._viewport = null; | ||
this._screenshotTaskQueue = screenshotTaskQueue; | ||
/** @type {!Map<string, PuppeteerWorker>} */ | ||
this._workers = new Map(); | ||
client.on('Target.attachedToTarget', event => { | ||
if (event.targetInfo.type !== 'worker') { | ||
// If we don't detach from service workers, they will never die. | ||
client.send('Target.detachFromTarget', { | ||
sessionId: event.sessionId | ||
}).catch(debugError); | ||
return; | ||
} | ||
const session = Connection.fromSession(client).session(event.sessionId); | ||
const worker = new PuppeteerWorker(session, event.targetInfo.url, this._addConsoleMessage.bind(this), this._handleException.bind(this)); | ||
this._workers.set(event.sessionId, worker); | ||
this.emit(Events.Page.WorkerCreated, worker); | ||
}); | ||
client.on('Target.detachedFromTarget', event => { | ||
const worker = this._workers.get(event.sessionId); | ||
if (!worker) | ||
return; | ||
this.emit(Events.Page.WorkerDestroyed, worker); | ||
this._workers.delete(event.sessionId); | ||
}); | ||
this._frameManager.on(Events.FrameManager.FrameAttached, event => this.emit(Events.Page.FrameAttached, event)); | ||
this._frameManager.on(Events.FrameManager.FrameDetached, event => this.emit(Events.Page.FrameDetached, event)); | ||
this._frameManager.on(Events.FrameManager.FrameNavigated, event => this.emit(Events.Page.FrameNavigated, event)); | ||
const networkManager = this._frameManager.networkManager(); | ||
networkManager.on(Events.NetworkManager.Request, event => this.emit(Events.Page.Request, event)); | ||
networkManager.on(Events.NetworkManager.Response, event => this.emit(Events.Page.Response, event)); | ||
networkManager.on(Events.NetworkManager.RequestFailed, event => this.emit(Events.Page.RequestFailed, event)); | ||
networkManager.on(Events.NetworkManager.RequestFinished, event => this.emit(Events.Page.RequestFinished, event)); | ||
this._fileChooserInterceptors = new Set(); | ||
client.on('Page.domContentEventFired', event => this.emit(Events.Page.DOMContentLoaded)); | ||
client.on('Page.loadEventFired', event => this.emit(Events.Page.Load)); | ||
client.on('Runtime.consoleAPICalled', event => this._onConsoleAPI(event)); | ||
client.on('Runtime.bindingCalled', event => this._onBindingCalled(event)); | ||
client.on('Page.javascriptDialogOpening', event => this._onDialog(event)); | ||
client.on('Runtime.exceptionThrown', exception => this._handleException(exception.exceptionDetails)); | ||
client.on('Inspector.targetCrashed', event => this._onTargetCrashed()); | ||
client.on('Performance.metrics', event => this._emitMetrics(event)); | ||
client.on('Log.entryAdded', event => this._onLogEntryAdded(event)); | ||
client.on('Page.fileChooserOpened', event => this._onFileChooser(event)); | ||
this._target._isClosedPromise.then(() => { | ||
this.emit(Events.Page.Close); | ||
this._closed = true; | ||
}); | ||
} | ||
async _initialize() { | ||
await Promise.all([ | ||
this._frameManager.initialize(), | ||
this._client.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true }), | ||
this._client.send('Performance.enable', {}), | ||
this._client.send('Log.enable', {}), | ||
]); | ||
} | ||
/** | ||
* @param {!Protocol.Page.fileChooserOpenedPayload} event | ||
*/ | ||
async _onFileChooser(event) { | ||
if (!this._fileChooserInterceptors.size) | ||
return; | ||
const frame = this._frameManager.frame(event.frameId); | ||
const context = await frame.executionContext(); | ||
const element = await context._adoptBackendNodeId(event.backendNodeId); | ||
const interceptors = Array.from(this._fileChooserInterceptors); | ||
this._fileChooserInterceptors.clear(); | ||
const fileChooser = new FileChooser(this._client, element, event); | ||
for (const interceptor of interceptors) | ||
interceptor.call(null, fileChooser); | ||
} | ||
/** | ||
* @param {!{timeout?: number}=} options | ||
* @return !Promise<!FileChooser>} | ||
*/ | ||
async waitForFileChooser(options = {}) { | ||
if (!this._fileChooserInterceptors.size) | ||
await this._client.send('Page.setInterceptFileChooserDialog', { enabled: true }); | ||
const { timeout = this._timeoutSettings.timeout(), } = options; | ||
let callback; | ||
const promise = new Promise(x => callback = x); | ||
this._fileChooserInterceptors.add(callback); | ||
return helper.waitWithTimeout(promise, 'waiting for file chooser', timeout).catch(e => { | ||
this._fileChooserInterceptors.delete(callback); | ||
throw e; | ||
}); | ||
} | ||
/** | ||
* @param {!{longitude: number, latitude: number, accuracy: (number|undefined)}} options | ||
*/ | ||
async setGeolocation(options) { | ||
const { longitude, latitude, accuracy = 0 } = options; | ||
if (longitude < -180 || longitude > 180) | ||
throw new Error(`Invalid longitude "${longitude}": precondition -180 <= LONGITUDE <= 180 failed.`); | ||
if (latitude < -90 || latitude > 90) | ||
throw new Error(`Invalid latitude "${latitude}": precondition -90 <= LATITUDE <= 90 failed.`); | ||
if (accuracy < 0) | ||
throw new Error(`Invalid accuracy "${accuracy}": precondition 0 <= ACCURACY failed.`); | ||
await this._client.send('Emulation.setGeolocationOverride', { longitude, latitude, accuracy }); | ||
} | ||
/** | ||
* @return {!Puppeteer.Target} | ||
*/ | ||
target() { | ||
return this._target; | ||
} | ||
/** | ||
* @return {!Puppeteer.Browser} | ||
*/ | ||
browser() { | ||
return this._target.browser(); | ||
} | ||
/** | ||
* @return {!Puppeteer.BrowserContext} | ||
*/ | ||
browserContext() { | ||
return this._target.browserContext(); | ||
} | ||
_onTargetCrashed() { | ||
this.emit('error', new Error('Page crashed!')); | ||
} | ||
/** | ||
* @param {!Protocol.Log.entryAddedPayload} event | ||
*/ | ||
_onLogEntryAdded(event) { | ||
const { level, text, args, source, url, lineNumber } = event.entry; | ||
if (args) | ||
args.map(arg => helper.releaseObject(this._client, arg)); | ||
if (source !== 'worker') | ||
this.emit(Events.Page.Console, new ConsoleMessage(level, text, [], { url, lineNumber })); | ||
} | ||
/** | ||
* @return {!Puppeteer.Frame} | ||
*/ | ||
mainFrame() { | ||
return this._frameManager.mainFrame(); | ||
} | ||
/** | ||
* @return {!Keyboard} | ||
*/ | ||
get keyboard() { | ||
return this._keyboard; | ||
} | ||
/** | ||
* @return {!Touchscreen} | ||
*/ | ||
get touchscreen() { | ||
return this._touchscreen; | ||
} | ||
/** | ||
* @return {!Coverage} | ||
*/ | ||
get coverage() { | ||
return this._coverage; | ||
} | ||
/** | ||
* @return {!Tracing} | ||
*/ | ||
get tracing() { | ||
return this._tracing; | ||
} | ||
/** | ||
* @return {!Accessibility} | ||
*/ | ||
get accessibility() { | ||
return this._accessibility; | ||
} | ||
/** | ||
* @return {!Array<Puppeteer.Frame>} | ||
*/ | ||
frames() { | ||
return this._frameManager.frames(); | ||
} | ||
/** | ||
* @return {!Array<!PuppeteerWorker>} | ||
*/ | ||
workers() { | ||
return Array.from(this._workers.values()); | ||
} | ||
/** | ||
* @param {boolean} value | ||
*/ | ||
async setRequestInterception(value) { | ||
return this._frameManager.networkManager().setRequestInterception(value); | ||
} | ||
/** | ||
* @param {boolean} enabled | ||
*/ | ||
setOfflineMode(enabled) { | ||
return this._frameManager.networkManager().setOfflineMode(enabled); | ||
} | ||
/** | ||
* @param {number} timeout | ||
*/ | ||
setDefaultNavigationTimeout(timeout) { | ||
this._timeoutSettings.setDefaultNavigationTimeout(timeout); | ||
} | ||
/** | ||
* @param {number} timeout | ||
*/ | ||
setDefaultTimeout(timeout) { | ||
this._timeoutSettings.setDefaultTimeout(timeout); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
async $(selector) { | ||
return this.mainFrame().$(selector); | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
async evaluateHandle(pageFunction, ...args) { | ||
const context = await this.mainFrame().executionContext(); | ||
return context.evaluateHandle(pageFunction, ...args); | ||
} | ||
/** | ||
* @param {!Puppeteer.JSHandle} prototypeHandle | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
async queryObjects(prototypeHandle) { | ||
const context = await this.mainFrame().executionContext(); | ||
return context.queryObjects(prototypeHandle); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<(!Object|undefined)>} | ||
*/ | ||
async $eval(selector, pageFunction, ...args) { | ||
return 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 this.mainFrame().$$eval(selector, pageFunction, ...args); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @return {!Promise<!Array<!Puppeteer.ElementHandle>>} | ||
*/ | ||
async $$(selector) { | ||
return this.mainFrame().$$(selector); | ||
} | ||
/** | ||
* @param {string} expression | ||
* @return {!Promise<!Array<!Puppeteer.ElementHandle>>} | ||
*/ | ||
async $x(expression) { | ||
return this.mainFrame().$x(expression); | ||
} | ||
/** | ||
* @param {!Array<string>} urls | ||
* @return {!Promise<!Array<Network.Cookie>>} | ||
*/ | ||
async cookies(...urls) { | ||
const originalCookies = (await this._client.send('Network.getCookies', { | ||
urls: urls.length ? urls : [this.url()] | ||
})).cookies; | ||
const unsupportedCookieAttributes = ['priority']; | ||
const filterUnsupportedAttributes = cookie => { | ||
for (const attr of unsupportedCookieAttributes) | ||
delete cookie[attr]; | ||
return cookie; | ||
}; | ||
return originalCookies.map(filterUnsupportedAttributes); | ||
} | ||
/** | ||
* @param {Array<Protocol.Network.deleteCookiesParameters>} cookies | ||
*/ | ||
async deleteCookie(...cookies) { | ||
const pageURL = this.url(); | ||
for (const cookie of cookies) { | ||
const item = Object.assign({}, cookie); | ||
if (!cookie.url && pageURL.startsWith('http')) | ||
item.url = pageURL; | ||
await this._client.send('Network.deleteCookies', item); | ||
} | ||
const seq = (me['lastSeq'] || 0) + 1; | ||
me['lastSeq'] = seq; | ||
const promise = new Promise((resolve, reject) => callbacks.set(seq, {resolve, reject})); | ||
binding(JSON.stringify({name: bindingName, seq, args})); | ||
return promise; | ||
}; | ||
} | ||
} | ||
/** | ||
* @param {?{username: string, password: string}} credentials | ||
*/ | ||
async authenticate(credentials) { | ||
return this._frameManager.networkManager().authenticate(credentials); | ||
} | ||
/** | ||
* @param {!Object<string, string>} headers | ||
*/ | ||
async setExtraHTTPHeaders(headers) { | ||
return this._frameManager.networkManager().setExtraHTTPHeaders(headers); | ||
} | ||
/** | ||
* @param {string} userAgent | ||
*/ | ||
async setUserAgent(userAgent) { | ||
return this._frameManager.networkManager().setUserAgent(userAgent); | ||
} | ||
/** | ||
* @return {!Promise<!Metrics>} | ||
*/ | ||
async metrics() { | ||
const response = await this._client.send('Performance.getMetrics'); | ||
return this._buildMetricsObject(response.metrics); | ||
} | ||
/** | ||
* @param {!Protocol.Performance.metricsPayload} event | ||
*/ | ||
_emitMetrics(event) { | ||
this.emit(Events.Page.Metrics, { | ||
title: event.title, | ||
metrics: this._buildMetricsObject(event.metrics) | ||
}); | ||
} | ||
/** | ||
* @param {?Array<!Protocol.Performance.Metric>} metrics | ||
* @return {!Metrics} | ||
*/ | ||
_buildMetricsObject(metrics) { | ||
const result = {}; | ||
for (const metric of metrics || []) { | ||
if (supportedMetrics.has(metric.name)) | ||
result[metric.name] = metric.value; | ||
/** | ||
* @param {Array<Network.CookieParam>} cookies | ||
*/ | ||
async setCookie(...cookies) { | ||
const pageURL = this.url(); | ||
const startsWithHTTP = pageURL.startsWith('http'); | ||
const items = cookies.map(cookie => { | ||
const item = Object.assign({}, cookie); | ||
if (!item.url && startsWithHTTP) | ||
item.url = pageURL; | ||
assert(item.url !== 'about:blank', `Blank page can not have cookie "${item.name}"`); | ||
assert(!String.prototype.startsWith.call(item.url || '', 'data:'), `Data URL page can not have cookie "${item.name}"`); | ||
return item; | ||
}); | ||
await this.deleteCookie(...items); | ||
if (items.length) | ||
await this._client.send('Network.setCookies', { cookies: items }); | ||
} | ||
return result; | ||
} | ||
/** | ||
* @param {!Protocol.Runtime.ExceptionDetails} exceptionDetails | ||
*/ | ||
_handleException(exceptionDetails) { | ||
const message = helper.getExceptionMessage(exceptionDetails); | ||
const err = new Error(message); | ||
err.stack = ''; // Don't report clientside error with a node stack attached | ||
this.emit(Events.Page.PageError, err); | ||
} | ||
/** | ||
* @param {!Protocol.Runtime.consoleAPICalledPayload} event | ||
*/ | ||
async _onConsoleAPI(event) { | ||
if (event.executionContextId === 0) { | ||
// DevTools protocol stores the last 1000 console messages. These | ||
// messages are always reported even for removed execution contexts. In | ||
// this case, they are marked with executionContextId = 0 and are | ||
// reported upon enabling Runtime agent. | ||
// | ||
// Ignore these messages since: | ||
// - there's no execution context we can use to operate with message | ||
// arguments | ||
// - these messages are reported before Puppeteer clients can subscribe | ||
// to the 'console' | ||
// page event. | ||
// | ||
// @see https://github.com/puppeteer/puppeteer/issues/3865 | ||
return; | ||
/** | ||
* @param {!{url?: string, path?: string, content?: string, type?: string}} options | ||
* @return {!Promise<!Puppeteer.ElementHandle>} | ||
*/ | ||
async addScriptTag(options) { | ||
return this.mainFrame().addScriptTag(options); | ||
} | ||
const context = this._frameManager.executionContextById(event.executionContextId); | ||
const values = event.args.map(arg => createJSHandle(context, arg)); | ||
this._addConsoleMessage(event.type, values, event.stackTrace); | ||
} | ||
/** | ||
* @param {!Protocol.Runtime.bindingCalledPayload} event | ||
*/ | ||
async _onBindingCalled(event) { | ||
const {name, seq, args} = JSON.parse(event.payload); | ||
let expression = null; | ||
try { | ||
const result = await this._pageBindings.get(name)(...args); | ||
expression = helper.evaluationString(deliverResult, name, seq, result); | ||
} catch (error) { | ||
if (error instanceof Error) | ||
expression = helper.evaluationString(deliverError, name, seq, error.message, error.stack); | ||
else | ||
expression = helper.evaluationString(deliverErrorValue, name, seq, error); | ||
/** | ||
* @param {!{url?: string, path?: string, content?: string}} options | ||
* @return {!Promise<!Puppeteer.ElementHandle>} | ||
*/ | ||
async addStyleTag(options) { | ||
return this.mainFrame().addStyleTag(options); | ||
} | ||
this._client.send('Runtime.evaluate', { expression, contextId: event.executionContextId }).catch(debugError); | ||
/** | ||
* @param {string} name | ||
* @param {number} seq | ||
* @param {*} result | ||
* @param {Function} puppeteerFunction | ||
*/ | ||
function deliverResult(name, seq, result) { | ||
window[name]['callbacks'].get(seq).resolve(result); | ||
window[name]['callbacks'].delete(seq); | ||
async exposeFunction(name, puppeteerFunction) { | ||
if (this._pageBindings.has(name)) | ||
throw new Error(`Failed to add page binding with name ${name}: window['${name}'] already exists!`); | ||
this._pageBindings.set(name, puppeteerFunction); | ||
const expression = helper.evaluationString(addPageBinding, name); | ||
await this._client.send('Runtime.addBinding', { name: name }); | ||
await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source: expression }); | ||
await Promise.all(this.frames().map(frame => frame.evaluate(expression).catch(debugError))); | ||
function addPageBinding(bindingName) { | ||
const win = /** @type * */ (window); | ||
const binding = /** @type function(string):* */ (win[bindingName]); | ||
win[bindingName] = (...args) => { | ||
const me = window[bindingName]; | ||
let callbacks = me['callbacks']; | ||
if (!callbacks) { | ||
callbacks = new Map(); | ||
me['callbacks'] = callbacks; | ||
} | ||
const seq = (me['lastSeq'] || 0) + 1; | ||
me['lastSeq'] = seq; | ||
const promise = new Promise((resolve, reject) => callbacks.set(seq, { resolve, reject })); | ||
binding(JSON.stringify({ name: bindingName, seq, args })); | ||
return promise; | ||
}; | ||
} | ||
} | ||
/** | ||
* @param {string} name | ||
* @param {number} seq | ||
* @param {string} message | ||
* @param {string} stack | ||
* @param {?{username: string, password: string}} credentials | ||
*/ | ||
function deliverError(name, seq, message, stack) { | ||
const error = new Error(message); | ||
error.stack = stack; | ||
window[name]['callbacks'].get(seq).reject(error); | ||
window[name]['callbacks'].delete(seq); | ||
async authenticate(credentials) { | ||
return this._frameManager.networkManager().authenticate(credentials); | ||
} | ||
/** | ||
* @param {string} name | ||
* @param {number} seq | ||
* @param {*} value | ||
* @param {!Object<string, string>} headers | ||
*/ | ||
function deliverErrorValue(name, seq, value) { | ||
window[name]['callbacks'].get(seq).reject(value); | ||
window[name]['callbacks'].delete(seq); | ||
async setExtraHTTPHeaders(headers) { | ||
return this._frameManager.networkManager().setExtraHTTPHeaders(headers); | ||
} | ||
} | ||
/** | ||
* @param {string} type | ||
* @param {!Array<!Puppeteer.JSHandle>} args | ||
* @param {Protocol.Runtime.StackTrace=} stackTrace | ||
*/ | ||
_addConsoleMessage(type, args, stackTrace) { | ||
if (!this.listenerCount(Events.Page.Console)) { | ||
args.forEach(arg => arg.dispose()); | ||
return; | ||
/** | ||
* @param {string} userAgent | ||
*/ | ||
async setUserAgent(userAgent) { | ||
return this._frameManager.networkManager().setUserAgent(userAgent); | ||
} | ||
const textTokens = []; | ||
for (const arg of args) { | ||
const remoteObject = arg._remoteObject; | ||
if (remoteObject.objectId) | ||
textTokens.push(arg.toString()); | ||
else | ||
textTokens.push(helper.valueFromRemoteObject(remoteObject)); | ||
/** | ||
* @return {!Promise<!Metrics>} | ||
*/ | ||
async metrics() { | ||
const response = await this._client.send('Performance.getMetrics'); | ||
return this._buildMetricsObject(response.metrics); | ||
} | ||
const location = stackTrace && stackTrace.callFrames.length ? { | ||
url: stackTrace.callFrames[0].url, | ||
lineNumber: stackTrace.callFrames[0].lineNumber, | ||
columnNumber: stackTrace.callFrames[0].columnNumber, | ||
} : {}; | ||
const message = new ConsoleMessage(type, textTokens.join(' '), args, location); | ||
this.emit(Events.Page.Console, message); | ||
} | ||
_onDialog(event) { | ||
let dialogType = null; | ||
if (event.type === 'alert') | ||
dialogType = Dialog.Type.Alert; | ||
else if (event.type === 'confirm') | ||
dialogType = Dialog.Type.Confirm; | ||
else if (event.type === 'prompt') | ||
dialogType = Dialog.Type.Prompt; | ||
else if (event.type === 'beforeunload') | ||
dialogType = Dialog.Type.BeforeUnload; | ||
assert(dialogType, 'Unknown javascript dialog type: ' + event.type); | ||
const dialog = new Dialog(this._client, dialogType, event.message, event.defaultPrompt); | ||
this.emit(Events.Page.Dialog, dialog); | ||
} | ||
/** | ||
* @return {!string} | ||
*/ | ||
url() { | ||
return this.mainFrame().url(); | ||
} | ||
/** | ||
* @return {!Promise<string>} | ||
*/ | ||
async content() { | ||
return await this._frameManager.mainFrame().content(); | ||
} | ||
/** | ||
* @param {string} html | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
*/ | ||
async setContent(html, options) { | ||
await this._frameManager.mainFrame().setContent(html, options); | ||
} | ||
/** | ||
* @param {string} url | ||
* @param {!{referer?: string, timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async goto(url, options) { | ||
return await this._frameManager.mainFrame().goto(url, options); | ||
} | ||
/** | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async reload(options) { | ||
const [response] = await Promise.all([ | ||
this.waitForNavigation(options), | ||
this._client.send('Page.reload') | ||
]); | ||
return response; | ||
} | ||
/** | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async waitForNavigation(options = {}) { | ||
return await this._frameManager.mainFrame().waitForNavigation(options); | ||
} | ||
_sessionClosePromise() { | ||
if (!this._disconnectPromise) | ||
this._disconnectPromise = new Promise(fulfill => this._client.once(Events.CDPSession.Disconnected, () => fulfill(new Error('Target closed')))); | ||
return this._disconnectPromise; | ||
} | ||
/** | ||
* @param {(string|Function)} urlOrPredicate | ||
* @param {!{timeout?: number}=} options | ||
* @return {!Promise<!Puppeteer.Request>} | ||
*/ | ||
async waitForRequest(urlOrPredicate, options = {}) { | ||
const { | ||
timeout = this._timeoutSettings.timeout(), | ||
} = options; | ||
return helper.waitForEvent(this._frameManager.networkManager(), Events.NetworkManager.Request, request => { | ||
if (helper.isString(urlOrPredicate)) | ||
return (urlOrPredicate === request.url()); | ||
if (typeof urlOrPredicate === 'function') | ||
return !!(urlOrPredicate(request)); | ||
return false; | ||
}, timeout, this._sessionClosePromise()); | ||
} | ||
/** | ||
* @param {(string|Function)} urlOrPredicate | ||
* @param {!{timeout?: number}=} options | ||
* @return {!Promise<!Puppeteer.Response>} | ||
*/ | ||
async waitForResponse(urlOrPredicate, options = {}) { | ||
const { | ||
timeout = this._timeoutSettings.timeout(), | ||
} = options; | ||
return helper.waitForEvent(this._frameManager.networkManager(), Events.NetworkManager.Response, response => { | ||
if (helper.isString(urlOrPredicate)) | ||
return (urlOrPredicate === response.url()); | ||
if (typeof urlOrPredicate === 'function') | ||
return !!(urlOrPredicate(response)); | ||
return false; | ||
}, timeout, this._sessionClosePromise()); | ||
} | ||
/** | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async goBack(options) { | ||
return this._go(-1, options); | ||
} | ||
/** | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async goForward(options) { | ||
return this._go(+1, options); | ||
} | ||
/** | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async _go(delta, options) { | ||
const history = await this._client.send('Page.getNavigationHistory'); | ||
const entry = history.entries[history.currentIndex + delta]; | ||
if (!entry) | ||
return null; | ||
const [response] = await Promise.all([ | ||
this.waitForNavigation(options), | ||
this._client.send('Page.navigateToHistoryEntry', {entryId: entry.id}), | ||
]); | ||
return response; | ||
} | ||
async bringToFront() { | ||
await this._client.send('Page.bringToFront'); | ||
} | ||
/** | ||
* @param {!{viewport: !Puppeteer.Viewport, userAgent: string}} options | ||
*/ | ||
async emulate(options) { | ||
await Promise.all([ | ||
this.setViewport(options.viewport), | ||
this.setUserAgent(options.userAgent) | ||
]); | ||
} | ||
/** | ||
* @param {boolean} enabled | ||
*/ | ||
async setJavaScriptEnabled(enabled) { | ||
if (this._javascriptEnabled === enabled) | ||
return; | ||
this._javascriptEnabled = enabled; | ||
await this._client.send('Emulation.setScriptExecutionDisabled', { value: !enabled }); | ||
} | ||
/** | ||
* @param {boolean} enabled | ||
*/ | ||
async setBypassCSP(enabled) { | ||
await this._client.send('Page.setBypassCSP', { enabled }); | ||
} | ||
/** | ||
* @param {?string} type | ||
*/ | ||
async emulateMediaType(type) { | ||
assert(type === 'screen' || type === 'print' || type === null, 'Unsupported media type: ' + type); | ||
await this._client.send('Emulation.setEmulatedMedia', {media: type || ''}); | ||
} | ||
/** | ||
* @param {?Array<MediaFeature>} features | ||
*/ | ||
async emulateMediaFeatures(features) { | ||
if (features === null) | ||
await this._client.send('Emulation.setEmulatedMedia', {features: null}); | ||
if (Array.isArray(features)) { | ||
features.every(mediaFeature => { | ||
const name = mediaFeature.name; | ||
assert(/^prefers-(?:color-scheme|reduced-motion)$/.test(name), 'Unsupported media feature: ' + name); | ||
return true; | ||
}); | ||
await this._client.send('Emulation.setEmulatedMedia', {features: features}); | ||
/** | ||
* @param {!Protocol.Performance.metricsPayload} event | ||
*/ | ||
_emitMetrics(event) { | ||
this.emit(Events.Page.Metrics, { | ||
title: event.title, | ||
metrics: this._buildMetricsObject(event.metrics) | ||
}); | ||
} | ||
} | ||
/** | ||
* @param {?string} timezoneId | ||
*/ | ||
async emulateTimezone(timezoneId) { | ||
try { | ||
await this._client.send('Emulation.setTimezoneOverride', {timezoneId: timezoneId || ''}); | ||
} catch (exception) { | ||
if (exception.message.includes('Invalid timezone')) | ||
throw new Error(`Invalid timezone ID: ${timezoneId}`); | ||
throw exception; | ||
/** | ||
* @param {?Array<!Protocol.Performance.Metric>} metrics | ||
* @return {!Metrics} | ||
*/ | ||
_buildMetricsObject(metrics) { | ||
const result = {}; | ||
for (const metric of metrics || []) { | ||
if (supportedMetrics.has(metric.name)) | ||
result[metric.name] = metric.value; | ||
} | ||
return result; | ||
} | ||
} | ||
/** | ||
* @param {!Puppeteer.Viewport} viewport | ||
*/ | ||
async setViewport(viewport) { | ||
const needsReload = await this._emulationManager.emulateViewport(viewport); | ||
this._viewport = viewport; | ||
if (needsReload) | ||
await this.reload(); | ||
} | ||
/** | ||
* @return {?Puppeteer.Viewport} | ||
*/ | ||
viewport() { | ||
return this._viewport; | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<*>} | ||
*/ | ||
async evaluate(pageFunction, ...args) { | ||
return this._frameManager.mainFrame().evaluate(pageFunction, ...args); | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
*/ | ||
async evaluateOnNewDocument(pageFunction, ...args) { | ||
const source = helper.evaluationString(pageFunction, ...args); | ||
await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source }); | ||
} | ||
/** | ||
* @param {boolean} enabled | ||
*/ | ||
async setCacheEnabled(enabled = true) { | ||
await this._frameManager.networkManager().setCacheEnabled(enabled); | ||
} | ||
/** | ||
* @param {!ScreenshotOptions=} options | ||
* @return {!Promise<!Buffer|!String>} | ||
*/ | ||
async screenshot(options = {}) { | ||
let screenshotType = null; | ||
// 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) { | ||
assert(options.type === 'png' || options.type === 'jpeg', 'Unknown options.type value: ' + options.type); | ||
screenshotType = options.type; | ||
} else if (options.path) { | ||
const mimeType = mime.getType(options.path); | ||
if (mimeType === 'image/png') | ||
screenshotType = 'png'; | ||
else if (mimeType === 'image/jpeg') | ||
screenshotType = 'jpeg'; | ||
assert(screenshotType, 'Unsupported screenshot mime type: ' + mimeType); | ||
/** | ||
* @param {!Protocol.Runtime.ExceptionDetails} exceptionDetails | ||
*/ | ||
_handleException(exceptionDetails) { | ||
const message = helper.getExceptionMessage(exceptionDetails); | ||
const err = new Error(message); | ||
err.stack = ''; // Don't report clientside error with a node stack attached | ||
this.emit(Events.Page.PageError, err); | ||
} | ||
if (!screenshotType) | ||
screenshotType = 'png'; | ||
if (options.quality) { | ||
assert(screenshotType === 'jpeg', 'options.quality is unsupported for the ' + screenshotType + ' screenshots'); | ||
assert(typeof options.quality === 'number', 'Expected options.quality to be a number but found ' + (typeof options.quality)); | ||
assert(Number.isInteger(options.quality), 'Expected options.quality to be an integer'); | ||
assert(options.quality >= 0 && options.quality <= 100, 'Expected options.quality to be between 0 and 100 (inclusive), got ' + options.quality); | ||
/** | ||
* @param {!Protocol.Runtime.consoleAPICalledPayload} event | ||
*/ | ||
async _onConsoleAPI(event) { | ||
if (event.executionContextId === 0) { | ||
// DevTools protocol stores the last 1000 console messages. These | ||
// messages are always reported even for removed execution contexts. In | ||
// this case, they are marked with executionContextId = 0 and are | ||
// reported upon enabling Runtime agent. | ||
// | ||
// Ignore these messages since: | ||
// - there's no execution context we can use to operate with message | ||
// arguments | ||
// - these messages are reported before Puppeteer clients can subscribe | ||
// to the 'console' | ||
// page event. | ||
// | ||
// @see https://github.com/puppeteer/puppeteer/issues/3865 | ||
return; | ||
} | ||
const context = this._frameManager.executionContextById(event.executionContextId); | ||
const values = event.args.map(arg => createJSHandle(context, arg)); | ||
this._addConsoleMessage(event.type, values, event.stackTrace); | ||
} | ||
assert(!options.clip || !options.fullPage, 'options.clip and options.fullPage are exclusive'); | ||
if (options.clip) { | ||
assert(typeof options.clip.x === 'number', 'Expected options.clip.x to be a number but found ' + (typeof options.clip.x)); | ||
assert(typeof options.clip.y === 'number', 'Expected options.clip.y to be a number but found ' + (typeof options.clip.y)); | ||
assert(typeof options.clip.width === 'number', 'Expected options.clip.width to be a number but found ' + (typeof options.clip.width)); | ||
assert(typeof options.clip.height === 'number', 'Expected options.clip.height to be a number but found ' + (typeof options.clip.height)); | ||
assert(options.clip.width !== 0, 'Expected options.clip.width not to be 0.'); | ||
assert(options.clip.height !== 0, 'Expected options.clip.height not to be 0.'); | ||
/** | ||
* @param {!Protocol.Runtime.bindingCalledPayload} event | ||
*/ | ||
async _onBindingCalled(event) { | ||
const { name, seq, args } = JSON.parse(event.payload); | ||
let expression = null; | ||
try { | ||
const result = await this._pageBindings.get(name)(...args); | ||
expression = helper.evaluationString(deliverResult, name, seq, result); | ||
} | ||
catch (error) { | ||
if (error instanceof Error) | ||
expression = helper.evaluationString(deliverError, name, seq, error.message, error.stack); | ||
else | ||
expression = helper.evaluationString(deliverErrorValue, name, seq, error); | ||
} | ||
this._client.send('Runtime.evaluate', { expression, contextId: event.executionContextId }).catch(debugError); | ||
/** | ||
* @param {string} name | ||
* @param {number} seq | ||
* @param {*} result | ||
*/ | ||
function deliverResult(name, seq, result) { | ||
window[name]['callbacks'].get(seq).resolve(result); | ||
window[name]['callbacks'].delete(seq); | ||
} | ||
/** | ||
* @param {string} name | ||
* @param {number} seq | ||
* @param {string} message | ||
* @param {string} stack | ||
*/ | ||
function deliverError(name, seq, message, stack) { | ||
const error = new Error(message); | ||
error.stack = stack; | ||
window[name]['callbacks'].get(seq).reject(error); | ||
window[name]['callbacks'].delete(seq); | ||
} | ||
/** | ||
* @param {string} name | ||
* @param {number} seq | ||
* @param {*} value | ||
*/ | ||
function deliverErrorValue(name, seq, value) { | ||
window[name]['callbacks'].get(seq).reject(value); | ||
window[name]['callbacks'].delete(seq); | ||
} | ||
} | ||
return this._screenshotTaskQueue.postTask(this._screenshotTask.bind(this, screenshotType, options)); | ||
} | ||
/** | ||
* @param {"png"|"jpeg"} format | ||
* @param {!ScreenshotOptions=} options | ||
* @return {!Promise<!Buffer|!String>} | ||
*/ | ||
async _screenshotTask(format, options) { | ||
await this._client.send('Target.activateTarget', {targetId: this._target._targetId}); | ||
let clip = options.clip ? processClip(options.clip) : undefined; | ||
if (options.fullPage) { | ||
const metrics = await this._client.send('Page.getLayoutMetrics'); | ||
const width = Math.ceil(metrics.contentSize.width); | ||
const height = Math.ceil(metrics.contentSize.height); | ||
// Overwrite clip for full page at all times. | ||
clip = { x: 0, y: 0, width, height, scale: 1 }; | ||
const { | ||
isMobile = false, | ||
deviceScaleFactor = 1, | ||
isLandscape = false | ||
} = this._viewport || {}; | ||
/** @type {!Protocol.Emulation.ScreenOrientation} */ | ||
const screenOrientation = isLandscape ? { angle: 90, type: 'landscapePrimary' } : { angle: 0, type: 'portraitPrimary' }; | ||
await this._client.send('Emulation.setDeviceMetricsOverride', { mobile: isMobile, width, height, deviceScaleFactor, screenOrientation }); | ||
/** | ||
* @param {string} type | ||
* @param {!Array<!Puppeteer.JSHandle>} args | ||
* @param {Protocol.Runtime.StackTrace=} stackTrace | ||
*/ | ||
_addConsoleMessage(type, args, stackTrace) { | ||
if (!this.listenerCount(Events.Page.Console)) { | ||
args.forEach(arg => arg.dispose()); | ||
return; | ||
} | ||
const textTokens = []; | ||
for (const arg of args) { | ||
const remoteObject = arg._remoteObject; | ||
if (remoteObject.objectId) | ||
textTokens.push(arg.toString()); | ||
else | ||
textTokens.push(helper.valueFromRemoteObject(remoteObject)); | ||
} | ||
const location = stackTrace && stackTrace.callFrames.length ? { | ||
url: stackTrace.callFrames[0].url, | ||
lineNumber: stackTrace.callFrames[0].lineNumber, | ||
columnNumber: stackTrace.callFrames[0].columnNumber, | ||
} : {}; | ||
const message = new ConsoleMessage(type, textTokens.join(' '), args, location); | ||
this.emit(Events.Page.Console, message); | ||
} | ||
const shouldSetDefaultBackground = options.omitBackground && format === 'png'; | ||
if (shouldSetDefaultBackground) | ||
await this._client.send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } }); | ||
const result = await this._client.send('Page.captureScreenshot', { format, quality: options.quality, clip }); | ||
if (shouldSetDefaultBackground) | ||
await this._client.send('Emulation.setDefaultBackgroundColorOverride'); | ||
if (options.fullPage && this._viewport) | ||
await this.setViewport(this._viewport); | ||
const buffer = options.encoding === 'base64' ? result.data : Buffer.from(result.data, 'base64'); | ||
if (options.path) | ||
await writeFileAsync(options.path, buffer); | ||
return buffer; | ||
function processClip(clip) { | ||
const x = Math.round(clip.x); | ||
const y = Math.round(clip.y); | ||
const width = Math.round(clip.width + clip.x - x); | ||
const height = Math.round(clip.height + clip.y - y); | ||
return {x, y, width, height, scale: 1}; | ||
_onDialog(event) { | ||
let dialogType = null; | ||
if (event.type === 'alert') | ||
dialogType = Dialog.Type.Alert; | ||
else if (event.type === 'confirm') | ||
dialogType = Dialog.Type.Confirm; | ||
else if (event.type === 'prompt') | ||
dialogType = Dialog.Type.Prompt; | ||
else if (event.type === 'beforeunload') | ||
dialogType = Dialog.Type.BeforeUnload; | ||
assert(dialogType, 'Unknown javascript dialog type: ' + event.type); | ||
const dialog = new Dialog(this._client, dialogType, event.message, event.defaultPrompt); | ||
this.emit(Events.Page.Dialog, dialog); | ||
} | ||
} | ||
/** | ||
* @param {!PDFOptions=} options | ||
* @return {!Promise<!Buffer>} | ||
*/ | ||
async pdf(options = {}) { | ||
const { | ||
scale = 1, | ||
displayHeaderFooter = false, | ||
headerTemplate = '', | ||
footerTemplate = '', | ||
printBackground = false, | ||
landscape = false, | ||
pageRanges = '', | ||
preferCSSPageSize = false, | ||
margin = {}, | ||
path = null | ||
} = options; | ||
let paperWidth = 8.5; | ||
let paperHeight = 11; | ||
if (options.format) { | ||
const format = Page.PaperFormats[options.format.toLowerCase()]; | ||
assert(format, 'Unknown paper format: ' + options.format); | ||
paperWidth = format.width; | ||
paperHeight = format.height; | ||
} else { | ||
paperWidth = convertPrintParameterToInches(options.width) || paperWidth; | ||
paperHeight = convertPrintParameterToInches(options.height) || paperHeight; | ||
/** | ||
* @return {!string} | ||
*/ | ||
url() { | ||
return this.mainFrame().url(); | ||
} | ||
const marginTop = convertPrintParameterToInches(margin.top) || 0; | ||
const marginLeft = convertPrintParameterToInches(margin.left) || 0; | ||
const marginBottom = convertPrintParameterToInches(margin.bottom) || 0; | ||
const marginRight = convertPrintParameterToInches(margin.right) || 0; | ||
const result = await this._client.send('Page.printToPDF', { | ||
transferMode: 'ReturnAsStream', | ||
landscape, | ||
displayHeaderFooter, | ||
headerTemplate, | ||
footerTemplate, | ||
printBackground, | ||
scale, | ||
paperWidth, | ||
paperHeight, | ||
marginTop, | ||
marginBottom, | ||
marginLeft, | ||
marginRight, | ||
pageRanges, | ||
preferCSSPageSize | ||
}); | ||
return await helper.readProtocolStream(this._client, result.stream, path); | ||
} | ||
/** | ||
* @return {!Promise<string>} | ||
*/ | ||
async title() { | ||
return this.mainFrame().title(); | ||
} | ||
/** | ||
* @param {!{runBeforeUnload: (boolean|undefined)}=} options | ||
*/ | ||
async close(options = {runBeforeUnload: undefined}) { | ||
assert(!!this._client._connection, 'Protocol error: Connection closed. Most likely the page has been closed.'); | ||
const runBeforeUnload = !!options.runBeforeUnload; | ||
if (runBeforeUnload) { | ||
await this._client.send('Page.close'); | ||
} else { | ||
await this._client._connection.send('Target.closeTarget', { targetId: this._target._targetId }); | ||
await this._target._isClosedPromise; | ||
/** | ||
* @return {!Promise<string>} | ||
*/ | ||
async content() { | ||
return await this._frameManager.mainFrame().content(); | ||
} | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
isClosed() { | ||
return this._closed; | ||
} | ||
/** | ||
* @return {!Mouse} | ||
*/ | ||
get mouse() { | ||
return this._mouse; | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {!{delay?: number, button?: "left"|"right"|"middle", clickCount?: number}=} options | ||
*/ | ||
click(selector, options = {}) { | ||
return this.mainFrame().click(selector, options); | ||
} | ||
/** | ||
* @param {string} selector | ||
*/ | ||
focus(selector) { | ||
return this.mainFrame().focus(selector); | ||
} | ||
/** | ||
* @param {string} selector | ||
*/ | ||
hover(selector) { | ||
return this.mainFrame().hover(selector); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {!Array<string>} values | ||
* @return {!Promise<!Array<string>>} | ||
*/ | ||
select(selector, ...values) { | ||
return this.mainFrame().select(selector, ...values); | ||
} | ||
/** | ||
* @param {string} selector | ||
*/ | ||
tap(selector) { | ||
return this.mainFrame().tap(selector); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {string} text | ||
* @param {{delay: (number|undefined)}=} options | ||
*/ | ||
type(selector, text, options) { | ||
return this.mainFrame().type(selector, text, options); | ||
} | ||
/** | ||
* @param {(string|number|Function)} selectorOrFunctionOrTimeout | ||
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number, polling?: string|number}=} options | ||
* @param {!Array<*>} args | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
waitFor(selectorOrFunctionOrTimeout, options = {}, ...args) { | ||
return this.mainFrame().waitFor(selectorOrFunctionOrTimeout, options, ...args); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
waitForSelector(selector, options = {}) { | ||
return this.mainFrame().waitForSelector(selector, options); | ||
} | ||
/** | ||
* @param {string} xpath | ||
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
waitForXPath(xpath, options = {}) { | ||
return this.mainFrame().waitForXPath(xpath, options); | ||
} | ||
/** | ||
* @param {Function} pageFunction | ||
* @param {!{polling?: string|number, timeout?: number}=} options | ||
* @param {!Array<*>} args | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
waitForFunction(pageFunction, options = {}, ...args) { | ||
return this.mainFrame().waitForFunction(pageFunction, options, ...args); | ||
} | ||
/** | ||
* @param {string} html | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
*/ | ||
async setContent(html, options) { | ||
await this._frameManager.mainFrame().setContent(html, options); | ||
} | ||
/** | ||
* @param {string} url | ||
* @param {!{referer?: string, timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async goto(url, options) { | ||
return await this._frameManager.mainFrame().goto(url, options); | ||
} | ||
/** | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async reload(options) { | ||
const result = await Promise.all([ | ||
this.waitForNavigation(options), | ||
this._client.send('Page.reload') | ||
]); | ||
const response = /** @type Puppeteer.Response */ (result[0]); | ||
return response; | ||
} | ||
/** | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async waitForNavigation(options = {}) { | ||
return await this._frameManager.mainFrame().waitForNavigation(options); | ||
} | ||
_sessionClosePromise() { | ||
if (!this._disconnectPromise) | ||
this._disconnectPromise = new Promise(fulfill => this._client.once(Events.CDPSession.Disconnected, () => fulfill(new Error('Target closed')))); | ||
return this._disconnectPromise; | ||
} | ||
/** | ||
* @param {(string|Function)} urlOrPredicate | ||
* @param {!{timeout?: number}=} options | ||
* @return {!Promise<!Puppeteer.Request>} | ||
*/ | ||
async waitForRequest(urlOrPredicate, options = {}) { | ||
const { timeout = this._timeoutSettings.timeout(), } = options; | ||
return helper.waitForEvent(this._frameManager.networkManager(), Events.NetworkManager.Request, request => { | ||
if (helper.isString(urlOrPredicate)) | ||
return (urlOrPredicate === request.url()); | ||
if (typeof urlOrPredicate === 'function') | ||
return !!(urlOrPredicate(request)); | ||
return false; | ||
}, timeout, this._sessionClosePromise()); | ||
} | ||
/** | ||
* @param {(string|Function)} urlOrPredicate | ||
* @param {!{timeout?: number}=} options | ||
* @return {!Promise<!Puppeteer.Response>} | ||
*/ | ||
async waitForResponse(urlOrPredicate, options = {}) { | ||
const { timeout = this._timeoutSettings.timeout(), } = options; | ||
return helper.waitForEvent(this._frameManager.networkManager(), Events.NetworkManager.Response, response => { | ||
if (helper.isString(urlOrPredicate)) | ||
return (urlOrPredicate === response.url()); | ||
if (typeof urlOrPredicate === 'function') | ||
return !!(urlOrPredicate(response)); | ||
return false; | ||
}, timeout, this._sessionClosePromise()); | ||
} | ||
/** | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async goBack(options) { | ||
return this._go(-1, options); | ||
} | ||
/** | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async goForward(options) { | ||
return this._go(+1, options); | ||
} | ||
/** | ||
* @param {!{timeout?: number, waitUntil?: string|!Array<string>}=} options | ||
* @return {!Promise<?Puppeteer.Response>} | ||
*/ | ||
async _go(delta, options) { | ||
const history = await this._client.send('Page.getNavigationHistory'); | ||
const entry = history.entries[history.currentIndex + delta]; | ||
if (!entry) | ||
return null; | ||
const result = await Promise.all([ | ||
this.waitForNavigation(options), | ||
this._client.send('Page.navigateToHistoryEntry', { entryId: entry.id }), | ||
]); | ||
const response = /** @type Puppeteer.Response */ (result[0]); | ||
return response; | ||
} | ||
async bringToFront() { | ||
await this._client.send('Page.bringToFront'); | ||
} | ||
/** | ||
* @param {!{viewport: !Puppeteer.Viewport, userAgent: string}} options | ||
*/ | ||
async emulate(options) { | ||
await Promise.all([ | ||
this.setViewport(options.viewport), | ||
this.setUserAgent(options.userAgent) | ||
]); | ||
} | ||
/** | ||
* @param {boolean} enabled | ||
*/ | ||
async setJavaScriptEnabled(enabled) { | ||
if (this._javascriptEnabled === enabled) | ||
return; | ||
this._javascriptEnabled = enabled; | ||
await this._client.send('Emulation.setScriptExecutionDisabled', { value: !enabled }); | ||
} | ||
/** | ||
* @param {boolean} enabled | ||
*/ | ||
async setBypassCSP(enabled) { | ||
await this._client.send('Page.setBypassCSP', { enabled }); | ||
} | ||
/** | ||
* @param {?string} type | ||
*/ | ||
async emulateMediaType(type) { | ||
assert(type === 'screen' || type === 'print' || type === null, 'Unsupported media type: ' + type); | ||
await this._client.send('Emulation.setEmulatedMedia', { media: type || '' }); | ||
} | ||
/** | ||
* @param {?Array<MediaFeature>} features | ||
*/ | ||
async emulateMediaFeatures(features) { | ||
if (features === null) | ||
await this._client.send('Emulation.setEmulatedMedia', { features: null }); | ||
if (Array.isArray(features)) { | ||
features.every(mediaFeature => { | ||
const name = mediaFeature.name; | ||
assert(/^prefers-(?:color-scheme|reduced-motion)$/.test(name), 'Unsupported media feature: ' + name); | ||
return true; | ||
}); | ||
await this._client.send('Emulation.setEmulatedMedia', { features: features }); | ||
} | ||
} | ||
/** | ||
* @param {?string} timezoneId | ||
*/ | ||
async emulateTimezone(timezoneId) { | ||
try { | ||
await this._client.send('Emulation.setTimezoneOverride', { timezoneId: timezoneId || '' }); | ||
} | ||
catch (exception) { | ||
if (exception.message.includes('Invalid timezone')) | ||
throw new Error(`Invalid timezone ID: ${timezoneId}`); | ||
throw exception; | ||
} | ||
} | ||
/** | ||
* @param {!Puppeteer.Viewport} viewport | ||
*/ | ||
async setViewport(viewport) { | ||
const needsReload = await this._emulationManager.emulateViewport(viewport); | ||
this._viewport = viewport; | ||
if (needsReload) | ||
await this.reload(); | ||
} | ||
/** | ||
* @return {?Puppeteer.Viewport} | ||
*/ | ||
viewport() { | ||
return this._viewport; | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<*>} | ||
*/ | ||
async evaluate(pageFunction, ...args) { | ||
return this._frameManager.mainFrame().evaluate(pageFunction, ...args); | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
*/ | ||
async evaluateOnNewDocument(pageFunction, ...args) { | ||
const source = helper.evaluationString(pageFunction, ...args); | ||
await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source }); | ||
} | ||
/** | ||
* @param {boolean} enabled | ||
*/ | ||
async setCacheEnabled(enabled = true) { | ||
await this._frameManager.networkManager().setCacheEnabled(enabled); | ||
} | ||
/** | ||
* @param {!ScreenshotOptions=} options | ||
* @return {!Promise<!Buffer|!String>} | ||
*/ | ||
async screenshot(options = {}) { | ||
let screenshotType = null; | ||
// 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) { | ||
assert(options.type === 'png' || options.type === 'jpeg', 'Unknown options.type value: ' + options.type); | ||
screenshotType = options.type; | ||
} | ||
else if (options.path) { | ||
const mimeType = mime.getType(options.path); | ||
if (mimeType === 'image/png') | ||
screenshotType = 'png'; | ||
else if (mimeType === 'image/jpeg') | ||
screenshotType = 'jpeg'; | ||
assert(screenshotType, 'Unsupported screenshot mime type: ' + mimeType); | ||
} | ||
if (!screenshotType) | ||
screenshotType = 'png'; | ||
if (options.quality) { | ||
assert(screenshotType === 'jpeg', 'options.quality is unsupported for the ' + screenshotType + ' screenshots'); | ||
assert(typeof options.quality === 'number', 'Expected options.quality to be a number but found ' + (typeof options.quality)); | ||
assert(Number.isInteger(options.quality), 'Expected options.quality to be an integer'); | ||
assert(options.quality >= 0 && options.quality <= 100, 'Expected options.quality to be between 0 and 100 (inclusive), got ' + options.quality); | ||
} | ||
assert(!options.clip || !options.fullPage, 'options.clip and options.fullPage are exclusive'); | ||
if (options.clip) { | ||
assert(typeof options.clip.x === 'number', 'Expected options.clip.x to be a number but found ' + (typeof options.clip.x)); | ||
assert(typeof options.clip.y === 'number', 'Expected options.clip.y to be a number but found ' + (typeof options.clip.y)); | ||
assert(typeof options.clip.width === 'number', 'Expected options.clip.width to be a number but found ' + (typeof options.clip.width)); | ||
assert(typeof options.clip.height === 'number', 'Expected options.clip.height to be a number but found ' + (typeof options.clip.height)); | ||
assert(options.clip.width !== 0, 'Expected options.clip.width not to be 0.'); | ||
assert(options.clip.height !== 0, 'Expected options.clip.height not to be 0.'); | ||
} | ||
return this._screenshotTaskQueue.postTask(this._screenshotTask.bind(this, screenshotType, options)); | ||
} | ||
/** | ||
* @param {"png"|"jpeg"} format | ||
* @param {!ScreenshotOptions=} options | ||
* @return {!Promise<!Buffer|!String>} | ||
*/ | ||
async _screenshotTask(format, options) { | ||
await this._client.send('Target.activateTarget', { targetId: this._target._targetId }); | ||
let clip = options.clip ? processClip(options.clip) : undefined; | ||
if (options.fullPage) { | ||
const metrics = await this._client.send('Page.getLayoutMetrics'); | ||
const width = Math.ceil(metrics.contentSize.width); | ||
const height = Math.ceil(metrics.contentSize.height); | ||
// Overwrite clip for full page at all times. | ||
clip = { x: 0, y: 0, width, height, scale: 1 }; | ||
const { isMobile = false, deviceScaleFactor = 1, isLandscape = false } = this._viewport || {}; | ||
/** @type {!Protocol.Emulation.ScreenOrientation} */ | ||
const screenOrientation = isLandscape ? { angle: 90, type: 'landscapePrimary' } : { angle: 0, type: 'portraitPrimary' }; | ||
await this._client.send('Emulation.setDeviceMetricsOverride', { mobile: isMobile, width, height, deviceScaleFactor, screenOrientation }); | ||
} | ||
const shouldSetDefaultBackground = options.omitBackground && format === 'png'; | ||
if (shouldSetDefaultBackground) | ||
await this._client.send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } }); | ||
const result = await this._client.send('Page.captureScreenshot', { format, quality: options.quality, clip }); | ||
if (shouldSetDefaultBackground) | ||
await this._client.send('Emulation.setDefaultBackgroundColorOverride'); | ||
if (options.fullPage && this._viewport) | ||
await this.setViewport(this._viewport); | ||
const buffer = options.encoding === 'base64' ? result.data : Buffer.from(result.data, 'base64'); | ||
if (options.path) | ||
await writeFileAsync(options.path, buffer); | ||
return buffer; | ||
function processClip(clip) { | ||
const x = Math.round(clip.x); | ||
const y = Math.round(clip.y); | ||
const width = Math.round(clip.width + clip.x - x); | ||
const height = Math.round(clip.height + clip.y - y); | ||
return { x, y, width, height, scale: 1 }; | ||
} | ||
} | ||
/** | ||
* @param {!PDFOptions=} options | ||
* @return {!Promise<!Buffer>} | ||
*/ | ||
async pdf(options = {}) { | ||
const { scale = 1, displayHeaderFooter = false, headerTemplate = '', footerTemplate = '', printBackground = false, landscape = false, pageRanges = '', preferCSSPageSize = false, margin = {}, path = null } = options; | ||
let paperWidth = 8.5; | ||
let paperHeight = 11; | ||
if (options.format) { | ||
const format = Page.PaperFormats[options.format.toLowerCase()]; | ||
assert(format, 'Unknown paper format: ' + options.format); | ||
paperWidth = format.width; | ||
paperHeight = format.height; | ||
} | ||
else { | ||
paperWidth = convertPrintParameterToInches(options.width) || paperWidth; | ||
paperHeight = convertPrintParameterToInches(options.height) || paperHeight; | ||
} | ||
const marginTop = convertPrintParameterToInches(margin.top) || 0; | ||
const marginLeft = convertPrintParameterToInches(margin.left) || 0; | ||
const marginBottom = convertPrintParameterToInches(margin.bottom) || 0; | ||
const marginRight = convertPrintParameterToInches(margin.right) || 0; | ||
const result = await this._client.send('Page.printToPDF', { | ||
transferMode: 'ReturnAsStream', | ||
landscape, | ||
displayHeaderFooter, | ||
headerTemplate, | ||
footerTemplate, | ||
printBackground, | ||
scale, | ||
paperWidth, | ||
paperHeight, | ||
marginTop, | ||
marginBottom, | ||
marginLeft, | ||
marginRight, | ||
pageRanges, | ||
preferCSSPageSize | ||
}); | ||
return await helper.readProtocolStream(this._client, result.stream, path); | ||
} | ||
/** | ||
* @return {!Promise<string>} | ||
*/ | ||
async title() { | ||
return this.mainFrame().title(); | ||
} | ||
/** | ||
* @param {!{runBeforeUnload: (boolean|undefined)}=} options | ||
*/ | ||
async close(options = { runBeforeUnload: undefined }) { | ||
assert(!!this._client._connection, 'Protocol error: Connection closed. Most likely the page has been closed.'); | ||
const runBeforeUnload = !!options.runBeforeUnload; | ||
if (runBeforeUnload) { | ||
await this._client.send('Page.close'); | ||
} | ||
else { | ||
await this._client._connection.send('Target.closeTarget', { targetId: this._target._targetId }); | ||
await this._target._isClosedPromise; | ||
} | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
isClosed() { | ||
return this._closed; | ||
} | ||
/** | ||
* @return {!Mouse} | ||
*/ | ||
get mouse() { | ||
return this._mouse; | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {!{delay?: number, button?: "left"|"right"|"middle", clickCount?: number}=} options | ||
*/ | ||
click(selector, options = {}) { | ||
return this.mainFrame().click(selector, options); | ||
} | ||
/** | ||
* @param {string} selector | ||
*/ | ||
focus(selector) { | ||
return this.mainFrame().focus(selector); | ||
} | ||
/** | ||
* @param {string} selector | ||
*/ | ||
hover(selector) { | ||
return this.mainFrame().hover(selector); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {!Array<string>} values | ||
* @return {!Promise<!Array<string>>} | ||
*/ | ||
select(selector, ...values) { | ||
return this.mainFrame().select(selector, ...values); | ||
} | ||
/** | ||
* @param {string} selector | ||
*/ | ||
tap(selector) { | ||
return this.mainFrame().tap(selector); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {string} text | ||
* @param {{delay: (number|undefined)}=} options | ||
*/ | ||
type(selector, text, options) { | ||
return this.mainFrame().type(selector, text, options); | ||
} | ||
/** | ||
* @param {(string|number|Function)} selectorOrFunctionOrTimeout | ||
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number, polling?: string|number}=} options | ||
* @param {!Array<*>} args | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
waitFor(selectorOrFunctionOrTimeout, options = {}, ...args) { | ||
return this.mainFrame().waitFor(selectorOrFunctionOrTimeout, options, ...args); | ||
} | ||
/** | ||
* @param {string} selector | ||
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
waitForSelector(selector, options = {}) { | ||
return this.mainFrame().waitForSelector(selector, options); | ||
} | ||
/** | ||
* @param {string} xpath | ||
* @param {!{visible?: boolean, hidden?: boolean, timeout?: number}=} options | ||
* @return {!Promise<?Puppeteer.ElementHandle>} | ||
*/ | ||
waitForXPath(xpath, options = {}) { | ||
return this.mainFrame().waitForXPath(xpath, options); | ||
} | ||
/** | ||
* @param {Function} pageFunction | ||
* @param {!{polling?: string|number, timeout?: number}=} options | ||
* @param {!Array<*>} args | ||
* @return {!Promise<!Puppeteer.JSHandle>} | ||
*/ | ||
waitForFunction(pageFunction, options = {}, ...args) { | ||
return this.mainFrame().waitForFunction(pageFunction, options, ...args); | ||
} | ||
} | ||
// Expose alias for deprecated method. | ||
Page.prototype.emulateMedia = Page.prototype.emulateMediaType; | ||
/** | ||
@@ -1165,3 +1044,2 @@ * @typedef {Object} PDFOptions | ||
*/ | ||
/** | ||
@@ -1183,3 +1061,2 @@ * @typedef {Object} Metrics | ||
*/ | ||
/** | ||
@@ -1195,3 +1072,2 @@ * @typedef {Object} ScreenshotOptions | ||
*/ | ||
/** | ||
@@ -1202,42 +1078,38 @@ * @typedef {Object} MediaFeature | ||
*/ | ||
/** @type {!Set<string>} */ | ||
const supportedMetrics = new Set([ | ||
'Timestamp', | ||
'Documents', | ||
'Frames', | ||
'JSEventListeners', | ||
'Nodes', | ||
'LayoutCount', | ||
'RecalcStyleCount', | ||
'LayoutDuration', | ||
'RecalcStyleDuration', | ||
'ScriptDuration', | ||
'TaskDuration', | ||
'JSHeapUsedSize', | ||
'JSHeapTotalSize', | ||
'Timestamp', | ||
'Documents', | ||
'Frames', | ||
'JSEventListeners', | ||
'Nodes', | ||
'LayoutCount', | ||
'RecalcStyleCount', | ||
'LayoutDuration', | ||
'RecalcStyleDuration', | ||
'ScriptDuration', | ||
'TaskDuration', | ||
'JSHeapUsedSize', | ||
'JSHeapTotalSize', | ||
]); | ||
/** @enum {!{width: number, height: number}} */ | ||
Page.PaperFormats = { | ||
letter: {width: 8.5, height: 11}, | ||
legal: {width: 8.5, height: 14}, | ||
tabloid: {width: 11, height: 17}, | ||
ledger: {width: 17, height: 11}, | ||
a0: {width: 33.1, height: 46.8 }, | ||
a1: {width: 23.4, height: 33.1 }, | ||
a2: {width: 16.54, height: 23.4 }, | ||
a3: {width: 11.7, height: 16.54 }, | ||
a4: {width: 8.27, height: 11.7 }, | ||
a5: {width: 5.83, height: 8.27 }, | ||
a6: {width: 4.13, height: 5.83 }, | ||
letter: { width: 8.5, height: 11 }, | ||
legal: { width: 8.5, height: 14 }, | ||
tabloid: { width: 11, height: 17 }, | ||
ledger: { width: 17, height: 11 }, | ||
a0: { width: 33.1, height: 46.8 }, | ||
a1: { width: 23.4, height: 33.1 }, | ||
a2: { width: 16.54, height: 23.4 }, | ||
a3: { width: 11.7, height: 16.54 }, | ||
a4: { width: 8.27, height: 11.7 }, | ||
a5: { width: 5.83, height: 8.27 }, | ||
a6: { width: 4.13, height: 5.83 }, | ||
}; | ||
const unitToPixels = { | ||
'px': 1, | ||
'in': 96, | ||
'cm': 37.8, | ||
'mm': 3.78 | ||
'px': 1, | ||
'in': 96, | ||
'cm': 37.8, | ||
'mm': 3.78 | ||
}; | ||
/** | ||
@@ -1248,29 +1120,31 @@ * @param {(string|number|undefined)} parameter | ||
function convertPrintParameterToInches(parameter) { | ||
if (typeof parameter === 'undefined') | ||
return undefined; | ||
let pixels; | ||
if (helper.isNumber(parameter)) { | ||
// Treat numbers as pixel values to be aligned with phantom's paperSize. | ||
pixels = /** @type {number} */ (parameter); | ||
} else if (helper.isString(parameter)) { | ||
const text = /** @type {string} */ (parameter); | ||
let unit = text.substring(text.length - 2).toLowerCase(); | ||
let valueText = ''; | ||
if (unitToPixels.hasOwnProperty(unit)) { | ||
valueText = text.substring(0, text.length - 2); | ||
} else { | ||
// In case of unknown unit try to parse the whole parameter as number of pixels. | ||
// This is consistent with phantom's paperSize behavior. | ||
unit = 'px'; | ||
valueText = text; | ||
if (typeof parameter === 'undefined') | ||
return undefined; | ||
let pixels; | ||
if (helper.isNumber(parameter)) { | ||
// Treat numbers as pixel values to be aligned with phantom's paperSize. | ||
pixels = /** @type {number} */ (parameter); | ||
} | ||
const value = Number(valueText); | ||
assert(!isNaN(value), 'Failed to parse parameter value: ' + text); | ||
pixels = value * unitToPixels[unit]; | ||
} else { | ||
throw new Error('page.pdf() Cannot handle parameter type: ' + (typeof parameter)); | ||
} | ||
return pixels / 96; | ||
else if (helper.isString(parameter)) { | ||
const text = /** @type {string} */ (parameter); | ||
let unit = text.substring(text.length - 2).toLowerCase(); | ||
let valueText = ''; | ||
if (unitToPixels.hasOwnProperty(unit)) { | ||
valueText = text.substring(0, text.length - 2); | ||
} | ||
else { | ||
// In case of unknown unit try to parse the whole parameter as number of pixels. | ||
// This is consistent with phantom's paperSize behavior. | ||
unit = 'px'; | ||
valueText = text; | ||
} | ||
const value = Number(valueText); | ||
assert(!isNaN(value), 'Failed to parse parameter value: ' + text); | ||
pixels = value * unitToPixels[unit]; | ||
} | ||
else { | ||
throw new Error('page.pdf() Cannot handle parameter type: ' + (typeof parameter)); | ||
} | ||
return pixels / 96; | ||
} | ||
/** | ||
@@ -1289,4 +1163,2 @@ * @typedef {Object} Network.Cookie | ||
*/ | ||
/** | ||
@@ -1304,3 +1176,2 @@ * @typedef {Object} Network.CookieParam | ||
*/ | ||
/** | ||
@@ -1312,85 +1183,75 @@ * @typedef {Object} ConsoleMessage.Location | ||
*/ | ||
class ConsoleMessage { | ||
/** | ||
* @param {string} type | ||
* @param {string} text | ||
* @param {!Array<!Puppeteer.JSHandle>} args | ||
* @param {ConsoleMessage.Location} location | ||
*/ | ||
constructor(type, text, args, location = {}) { | ||
this._type = type; | ||
this._text = text; | ||
this._args = args; | ||
this._location = location; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
type() { | ||
return this._type; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
text() { | ||
return this._text; | ||
} | ||
/** | ||
* @return {!Array<!Puppeteer.JSHandle>} | ||
*/ | ||
args() { | ||
return this._args; | ||
} | ||
/** | ||
* @return {Object} | ||
*/ | ||
location() { | ||
return this._location; | ||
} | ||
/** | ||
* @param {string} type | ||
* @param {string} text | ||
* @param {!Array<!Puppeteer.JSHandle>} args | ||
* @param {ConsoleMessage.Location} location | ||
*/ | ||
constructor(type, text, args, location = {}) { | ||
this._type = type; | ||
this._text = text; | ||
this._args = args; | ||
this._location = location; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
type() { | ||
return this._type; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
text() { | ||
return this._text; | ||
} | ||
/** | ||
* @return {!Array<!Puppeteer.JSHandle>} | ||
*/ | ||
args() { | ||
return this._args; | ||
} | ||
/** | ||
* @return {Object} | ||
*/ | ||
location() { | ||
return this._location; | ||
} | ||
} | ||
class FileChooser { | ||
/** | ||
* @param {Puppeteer.CDPSession} client | ||
* @param {Puppeteer.ElementHandle} element | ||
* @param {!Protocol.Page.fileChooserOpenedPayload} event | ||
*/ | ||
constructor(client, element, event) { | ||
this._client = client; | ||
this._element = element; | ||
this._multiple = event.mode !== 'selectSingle'; | ||
this._handled = false; | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
isMultiple() { | ||
return this._multiple; | ||
} | ||
/** | ||
* @param {!Array<string>} filePaths | ||
* @return {!Promise} | ||
*/ | ||
async accept(filePaths) { | ||
assert(!this._handled, 'Cannot accept FileChooser which is already handled!'); | ||
this._handled = true; | ||
await this._element.uploadFile(...filePaths); | ||
} | ||
/** | ||
* @return {!Promise} | ||
*/ | ||
async cancel() { | ||
assert(!this._handled, 'Cannot cancel FileChooser which is already handled!'); | ||
this._handled = true; | ||
} | ||
/** | ||
* @param {Puppeteer.CDPSession} client | ||
* @param {Puppeteer.ElementHandle} element | ||
* @param {!Protocol.Page.fileChooserOpenedPayload} event | ||
*/ | ||
constructor(client, element, event) { | ||
this._client = client; | ||
this._element = element; | ||
this._multiple = event.mode !== 'selectSingle'; | ||
this._handled = false; | ||
} | ||
/** | ||
* @return {boolean} | ||
*/ | ||
isMultiple() { | ||
return this._multiple; | ||
} | ||
/** | ||
* @param {!Array<string>} filePaths | ||
* @return {!Promise} | ||
*/ | ||
async accept(filePaths) { | ||
assert(!this._handled, 'Cannot accept FileChooser which is already handled!'); | ||
this._handled = true; | ||
await this._element.uploadFile(...filePaths); | ||
} | ||
/** | ||
* @return {!Promise} | ||
*/ | ||
async cancel() { | ||
assert(!this._handled, 'Cannot cancel FileChooser which is already handled!'); | ||
this._handled = true; | ||
} | ||
} | ||
module.exports = {Page, ConsoleMessage, FileChooser}; | ||
module.exports = { Page, ConsoleMessage, FileChooser }; |
@@ -16,4 +16,3 @@ /** | ||
*/ | ||
const {helper, debugError} = require('./helper'); | ||
const { helper, debugError } = require('./helper'); | ||
/** | ||
@@ -23,60 +22,55 @@ * @implements {!Puppeteer.ConnectionTransport} | ||
class PipeTransport { | ||
/** | ||
* @param {!NodeJS.WritableStream} pipeWrite | ||
* @param {!NodeJS.ReadableStream} pipeRead | ||
*/ | ||
constructor(pipeWrite, pipeRead) { | ||
this._pipeWrite = pipeWrite; | ||
this._pendingMessage = ''; | ||
this._eventListeners = [ | ||
helper.addEventListener(pipeRead, 'data', buffer => this._dispatch(buffer)), | ||
helper.addEventListener(pipeRead, 'close', () => { | ||
if (this.onclose) | ||
this.onclose.call(null); | ||
}), | ||
helper.addEventListener(pipeRead, 'error', debugError), | ||
helper.addEventListener(pipeWrite, 'error', debugError), | ||
]; | ||
this.onmessage = null; | ||
this.onclose = null; | ||
} | ||
/** | ||
* @param {string} message | ||
*/ | ||
send(message) { | ||
this._pipeWrite.write(message); | ||
this._pipeWrite.write('\0'); | ||
} | ||
/** | ||
* @param {!Buffer} buffer | ||
*/ | ||
_dispatch(buffer) { | ||
let end = buffer.indexOf('\0'); | ||
if (end === -1) { | ||
this._pendingMessage += buffer.toString(); | ||
return; | ||
/** | ||
* @param {!NodeJS.WritableStream} pipeWrite | ||
* @param {!NodeJS.ReadableStream} pipeRead | ||
*/ | ||
constructor(pipeWrite, pipeRead) { | ||
this._pipeWrite = pipeWrite; | ||
this._pendingMessage = ''; | ||
this._eventListeners = [ | ||
helper.addEventListener(pipeRead, 'data', buffer => this._dispatch(buffer)), | ||
helper.addEventListener(pipeRead, 'close', () => { | ||
if (this.onclose) | ||
this.onclose.call(null); | ||
}), | ||
helper.addEventListener(pipeRead, 'error', debugError), | ||
helper.addEventListener(pipeWrite, 'error', debugError), | ||
]; | ||
this.onmessage = null; | ||
this.onclose = null; | ||
} | ||
const message = this._pendingMessage + buffer.toString(undefined, 0, end); | ||
if (this.onmessage) | ||
this.onmessage.call(null, message); | ||
let start = end + 1; | ||
end = buffer.indexOf('\0', start); | ||
while (end !== -1) { | ||
if (this.onmessage) | ||
this.onmessage.call(null, buffer.toString(undefined, start, end)); | ||
start = end + 1; | ||
end = buffer.indexOf('\0', start); | ||
/** | ||
* @param {string} message | ||
*/ | ||
send(message) { | ||
this._pipeWrite.write(message); | ||
this._pipeWrite.write('\0'); | ||
} | ||
this._pendingMessage = buffer.toString(undefined, start); | ||
} | ||
close() { | ||
this._pipeWrite = null; | ||
helper.removeEventListeners(this._eventListeners); | ||
} | ||
/** | ||
* @param {!Buffer} buffer | ||
*/ | ||
_dispatch(buffer) { | ||
let end = buffer.indexOf('\0'); | ||
if (end === -1) { | ||
this._pendingMessage += buffer.toString(); | ||
return; | ||
} | ||
const message = this._pendingMessage + buffer.toString(undefined, 0, end); | ||
if (this.onmessage) | ||
this.onmessage.call(null, message); | ||
let start = end + 1; | ||
end = buffer.indexOf('\0', start); | ||
while (end !== -1) { | ||
if (this.onmessage) | ||
this.onmessage.call(null, buffer.toString(undefined, start, end)); | ||
start = end + 1; | ||
end = buffer.indexOf('\0', start); | ||
} | ||
this._pendingMessage = buffer.toString(undefined, start); | ||
} | ||
close() { | ||
this._pipeWrite = null; | ||
helper.removeEventListeners(this._eventListeners); | ||
} | ||
} | ||
module.exports = PipeTransport; |
@@ -20,87 +20,106 @@ /** | ||
const DeviceDescriptors = require('./DeviceDescriptors'); | ||
module.exports = class { | ||
/** | ||
* @param {string} projectRoot | ||
* @param {string} preferredRevision | ||
* @param {boolean} isPuppeteerCore | ||
*/ | ||
constructor(projectRoot, preferredRevision, isPuppeteerCore) { | ||
this._projectRoot = projectRoot; | ||
this._preferredRevision = preferredRevision; | ||
this._isPuppeteerCore = isPuppeteerCore; | ||
} | ||
/** | ||
* @param {!(Launcher.LaunchOptions & Launcher.ChromeArgOptions & Launcher.BrowserOptions & {product?: string, extraPrefsFirefox?: !object})=} options | ||
* @return {!Promise<!Puppeteer.Browser>} | ||
*/ | ||
launch(options) { | ||
if (!this._productName && options) | ||
this._productName = options.product; | ||
return this._launcher.launch(options); | ||
} | ||
/** | ||
* @param {!(Launcher.BrowserOptions & {browserWSEndpoint?: string, browserURL?: string, transport?: !Puppeteer.ConnectionTransport})} options | ||
* @return {!Promise<!Puppeteer.Browser>} | ||
*/ | ||
connect(options) { | ||
return this._launcher.connect(options); | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
executablePath() { | ||
return this._launcher.executablePath(); | ||
} | ||
/** | ||
* @return {!Puppeteer.ProductLauncher} | ||
*/ | ||
get _launcher() { | ||
if (!this._lazyLauncher) | ||
this._lazyLauncher = Launcher(this._projectRoot, this._preferredRevision, this._isPuppeteerCore, this._productName); | ||
return this._lazyLauncher; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
get product() { | ||
return this._launcher.product; | ||
} | ||
/** | ||
* @return {Object} | ||
*/ | ||
get devices() { | ||
return DeviceDescriptors; | ||
} | ||
/** | ||
* @return {Object} | ||
*/ | ||
get errors() { | ||
return Errors; | ||
} | ||
/** | ||
* @param {!Launcher.ChromeArgOptions=} options | ||
* @return {!Array<string>} | ||
*/ | ||
defaultArgs(options) { | ||
return this._launcher.defaultArgs(options); | ||
} | ||
/** | ||
* @param {!BrowserFetcher.Options=} options | ||
* @return {!BrowserFetcher} | ||
*/ | ||
createBrowserFetcher(options) { | ||
return new BrowserFetcher(this._projectRoot, options); | ||
} | ||
/** | ||
* @param {string} projectRoot | ||
* @param {string} preferredRevision | ||
* @param {boolean} isPuppeteerCore | ||
* @param {string} productName | ||
*/ | ||
constructor(projectRoot, preferredRevision, isPuppeteerCore, productName) { | ||
this._projectRoot = projectRoot; | ||
this._preferredRevision = preferredRevision; | ||
this._isPuppeteerCore = isPuppeteerCore; | ||
// track changes to Launcher configuration via options or environment variables | ||
this.__productName = productName; | ||
} | ||
/** | ||
* @param {!(Launcher.LaunchOptions & Launcher.ChromeArgOptions & Launcher.BrowserOptions & {product?: string, extraPrefsFirefox?: !object})=} options | ||
* @return {!Promise<!Puppeteer.Browser>} | ||
*/ | ||
launch(options = {}) { | ||
if (options.product) | ||
this._productName = options.product; | ||
return this._launcher.launch(options); | ||
} | ||
/** | ||
* @param {!(Launcher.BrowserOptions & {browserWSEndpoint?: string, browserURL?: string, transport?: !Puppeteer.ConnectionTransport}) & {product?: string}=} options | ||
* @return {!Promise<!Puppeteer.Browser>} | ||
*/ | ||
connect(options) { | ||
if (options.product) | ||
this._productName = options.product; | ||
return this._launcher.connect(options); | ||
} | ||
/** | ||
* @param {string} name | ||
*/ | ||
set _productName(name) { | ||
if (this.__productName !== name) | ||
this._changedProduct = true; | ||
this.__productName = name; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
get _productName() { | ||
return this.__productName; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
executablePath() { | ||
return this._launcher.executablePath(); | ||
} | ||
/** | ||
* @return {!Puppeteer.ProductLauncher} | ||
*/ | ||
get _launcher() { | ||
if (!this._lazyLauncher || this._lazyLauncher.product !== this._productName || this._changedProduct) { | ||
// @ts-ignore | ||
const packageJson = require('../package.json'); | ||
switch (this._productName) { | ||
case 'firefox': | ||
this._preferredRevision = packageJson.puppeteer.firefox_revision; | ||
break; | ||
case 'chrome': | ||
default: | ||
this._preferredRevision = packageJson.puppeteer.chromium_revision; | ||
} | ||
this._changedProduct = false; | ||
this._lazyLauncher = Launcher(this._projectRoot, this._preferredRevision, this._isPuppeteerCore, this._productName); | ||
} | ||
return this._lazyLauncher; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
get product() { | ||
return this._launcher.product; | ||
} | ||
/** | ||
* @return {Object} | ||
*/ | ||
get devices() { | ||
return DeviceDescriptors; | ||
} | ||
/** | ||
* @return {Object} | ||
*/ | ||
get errors() { | ||
return Errors; | ||
} | ||
/** | ||
* @param {!Launcher.ChromeArgOptions=} options | ||
* @return {!Array<string>} | ||
*/ | ||
defaultArgs(options) { | ||
return this._launcher.defaultArgs(options); | ||
} | ||
/** | ||
* @param {!BrowserFetcher.Options=} options | ||
* @return {!BrowserFetcher} | ||
*/ | ||
createBrowserFetcher(options) { | ||
return new BrowserFetcher(this._projectRoot, options); | ||
} | ||
}; | ||
@@ -16,134 +16,121 @@ /** | ||
*/ | ||
const {Events} = require('./Events'); | ||
const {Page} = require('./Page'); | ||
const {Worker} = require('./Worker'); | ||
const { Events } = require('./Events'); | ||
const { Page } = require('./Page'); | ||
const { Worker: PuppeteerWorker } = require('./Worker'); | ||
class Target { | ||
/** | ||
* @param {!Protocol.Target.TargetInfo} targetInfo | ||
* @param {!Puppeteer.BrowserContext} browserContext | ||
* @param {!function():!Promise<!Puppeteer.CDPSession>} sessionFactory | ||
* @param {boolean} ignoreHTTPSErrors | ||
* @param {?Puppeteer.Viewport} defaultViewport | ||
* @param {!Puppeteer.TaskQueue} screenshotTaskQueue | ||
*/ | ||
constructor(targetInfo, browserContext, sessionFactory, ignoreHTTPSErrors, defaultViewport, screenshotTaskQueue) { | ||
this._targetInfo = targetInfo; | ||
this._browserContext = browserContext; | ||
this._targetId = targetInfo.targetId; | ||
this._sessionFactory = sessionFactory; | ||
this._ignoreHTTPSErrors = ignoreHTTPSErrors; | ||
this._defaultViewport = defaultViewport; | ||
this._screenshotTaskQueue = screenshotTaskQueue; | ||
/** @type {?Promise<!Puppeteer.Page>} */ | ||
this._pagePromise = null; | ||
/** @type {?Promise<!Worker>} */ | ||
this._workerPromise = null; | ||
this._initializedPromise = new Promise(fulfill => this._initializedCallback = fulfill).then(async success => { | ||
if (!success) | ||
return false; | ||
const opener = this.opener(); | ||
if (!opener || !opener._pagePromise || this.type() !== 'page') | ||
return true; | ||
const openerPage = await opener._pagePromise; | ||
if (!openerPage.listenerCount(Events.Page.Popup)) | ||
return true; | ||
const popupPage = await this.page(); | ||
openerPage.emit(Events.Page.Popup, popupPage); | ||
return true; | ||
}); | ||
this._isClosedPromise = new Promise(fulfill => this._closedCallback = fulfill); | ||
this._isInitialized = this._targetInfo.type !== 'page' || this._targetInfo.url !== ''; | ||
if (this._isInitialized) | ||
this._initializedCallback(true); | ||
} | ||
/** | ||
* @return {!Promise<!Puppeteer.CDPSession>} | ||
*/ | ||
createCDPSession() { | ||
return this._sessionFactory(); | ||
} | ||
/** | ||
* @return {!Promise<?Page>} | ||
*/ | ||
async page() { | ||
if ((this._targetInfo.type === 'page' || this._targetInfo.type === 'background_page') && !this._pagePromise) { | ||
this._pagePromise = this._sessionFactory() | ||
.then(client => Page.create(client, this, this._ignoreHTTPSErrors, this._defaultViewport, this._screenshotTaskQueue)); | ||
/** | ||
* @param {!Protocol.Target.TargetInfo} targetInfo | ||
* @param {!Puppeteer.BrowserContext} browserContext | ||
* @param {!function():!Promise<!Puppeteer.CDPSession>} sessionFactory | ||
* @param {boolean} ignoreHTTPSErrors | ||
* @param {?Puppeteer.Viewport} defaultViewport | ||
* @param {!Puppeteer.TaskQueue} screenshotTaskQueue | ||
*/ | ||
constructor(targetInfo, browserContext, sessionFactory, ignoreHTTPSErrors, defaultViewport, screenshotTaskQueue) { | ||
this._targetInfo = targetInfo; | ||
this._browserContext = browserContext; | ||
this._targetId = targetInfo.targetId; | ||
this._sessionFactory = sessionFactory; | ||
this._ignoreHTTPSErrors = ignoreHTTPSErrors; | ||
this._defaultViewport = defaultViewport; | ||
this._screenshotTaskQueue = screenshotTaskQueue; | ||
/** @type {?Promise<!Puppeteer.Page>} */ | ||
this._pagePromise = null; | ||
/** @type {?Promise<!PuppeteerWorker>} */ | ||
this._workerPromise = null; | ||
this._initializedPromise = new Promise(fulfill => this._initializedCallback = fulfill).then(async (success) => { | ||
if (!success) | ||
return false; | ||
const opener = this.opener(); | ||
if (!opener || !opener._pagePromise || this.type() !== 'page') | ||
return true; | ||
const openerPage = await opener._pagePromise; | ||
if (!openerPage.listenerCount(Events.Page.Popup)) | ||
return true; | ||
const popupPage = await this.page(); | ||
openerPage.emit(Events.Page.Popup, popupPage); | ||
return true; | ||
}); | ||
this._isClosedPromise = new Promise(fulfill => this._closedCallback = fulfill); | ||
this._isInitialized = this._targetInfo.type !== 'page' || this._targetInfo.url !== ''; | ||
if (this._isInitialized) | ||
this._initializedCallback(true); | ||
} | ||
return this._pagePromise; | ||
} | ||
/** | ||
* @return {!Promise<?Worker>} | ||
*/ | ||
async worker() { | ||
if (this._targetInfo.type !== 'service_worker' && this._targetInfo.type !== 'shared_worker') | ||
return null; | ||
if (!this._workerPromise) { | ||
// TODO(einbinder): Make workers send their console logs. | ||
this._workerPromise = this._sessionFactory() | ||
.then(client => new Worker(client, this._targetInfo.url, () => {} /* consoleAPICalled */, () => {} /* exceptionThrown */)); | ||
/** | ||
* @return {!Promise<!Puppeteer.CDPSession>} | ||
*/ | ||
createCDPSession() { | ||
return this._sessionFactory(); | ||
} | ||
return this._workerPromise; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
url() { | ||
return this._targetInfo.url; | ||
} | ||
/** | ||
* @return {"page"|"background_page"|"service_worker"|"shared_worker"|"other"|"browser"} | ||
*/ | ||
type() { | ||
const type = this._targetInfo.type; | ||
if (type === 'page' || type === 'background_page' || type === 'service_worker' || type === 'shared_worker' || type === 'browser') | ||
return type; | ||
return 'other'; | ||
} | ||
/** | ||
* @return {!Puppeteer.Browser} | ||
*/ | ||
browser() { | ||
return this._browserContext.browser(); | ||
} | ||
/** | ||
* @return {!Puppeteer.BrowserContext} | ||
*/ | ||
browserContext() { | ||
return this._browserContext; | ||
} | ||
/** | ||
* @return {?Puppeteer.Target} | ||
*/ | ||
opener() { | ||
const { openerId } = this._targetInfo; | ||
if (!openerId) | ||
return null; | ||
return this.browser()._targets.get(openerId); | ||
} | ||
/** | ||
* @param {!Protocol.Target.TargetInfo} targetInfo | ||
*/ | ||
_targetInfoChanged(targetInfo) { | ||
this._targetInfo = targetInfo; | ||
if (!this._isInitialized && (this._targetInfo.type !== 'page' || this._targetInfo.url !== '')) { | ||
this._isInitialized = true; | ||
this._initializedCallback(true); | ||
return; | ||
/** | ||
* @return {!Promise<?Page>} | ||
*/ | ||
async page() { | ||
if ((this._targetInfo.type === 'page' || this._targetInfo.type === 'background_page') && !this._pagePromise) { | ||
this._pagePromise = this._sessionFactory() | ||
.then(client => Page.create(client, this, this._ignoreHTTPSErrors, this._defaultViewport, this._screenshotTaskQueue)); | ||
} | ||
return this._pagePromise; | ||
} | ||
} | ||
/** | ||
* @return {!Promise<?PuppeteerWorker>} | ||
*/ | ||
async worker() { | ||
if (this._targetInfo.type !== 'service_worker' && this._targetInfo.type !== 'shared_worker') | ||
return null; | ||
if (!this._workerPromise) { | ||
// TODO(einbinder): Make workers send their console logs. | ||
this._workerPromise = this._sessionFactory() | ||
.then(client => new PuppeteerWorker(client, this._targetInfo.url, () => { } /* consoleAPICalled */, () => { } /* exceptionThrown */)); | ||
} | ||
return this._workerPromise; | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
url() { | ||
return this._targetInfo.url; | ||
} | ||
/** | ||
* @return {"page"|"background_page"|"service_worker"|"shared_worker"|"other"|"browser"} | ||
*/ | ||
type() { | ||
const type = this._targetInfo.type; | ||
if (type === 'page' || type === 'background_page' || type === 'service_worker' || type === 'shared_worker' || type === 'browser') | ||
return type; | ||
return 'other'; | ||
} | ||
/** | ||
* @return {!Puppeteer.Browser} | ||
*/ | ||
browser() { | ||
return this._browserContext.browser(); | ||
} | ||
/** | ||
* @return {!Puppeteer.BrowserContext} | ||
*/ | ||
browserContext() { | ||
return this._browserContext; | ||
} | ||
/** | ||
* @return {?Puppeteer.Target} | ||
*/ | ||
opener() { | ||
const { openerId } = this._targetInfo; | ||
if (!openerId) | ||
return null; | ||
return this.browser()._targets.get(openerId); | ||
} | ||
/** | ||
* @param {!Protocol.Target.TargetInfo} targetInfo | ||
*/ | ||
_targetInfoChanged(targetInfo) { | ||
this._targetInfo = targetInfo; | ||
if (!this._isInitialized && (this._targetInfo.type !== 'page' || this._targetInfo.url !== '')) { | ||
this._isInitialized = true; | ||
this._initializedCallback(true); | ||
return; | ||
} | ||
} | ||
} | ||
module.exports = {Target}; | ||
module.exports = { Target }; |
class TaskQueue { | ||
constructor() { | ||
this._chain = Promise.resolve(); | ||
} | ||
/** | ||
* @param {Function} task | ||
* @return {!Promise} | ||
*/ | ||
postTask(task) { | ||
const result = this._chain.then(task); | ||
this._chain = result.catch(() => {}); | ||
return result; | ||
} | ||
constructor() { | ||
this._chain = Promise.resolve(); | ||
} | ||
/** | ||
* @param {Function} task | ||
* @return {!Promise} | ||
*/ | ||
postTask(task) { | ||
const result = this._chain.then(task); | ||
this._chain = result.catch(() => { }); | ||
return result; | ||
} | ||
} | ||
module.exports = {TaskQueue}; | ||
module.exports = { TaskQueue }; |
@@ -16,43 +16,36 @@ /** | ||
*/ | ||
const DEFAULT_TIMEOUT = 30000; | ||
class TimeoutSettings { | ||
constructor() { | ||
this._defaultTimeout = null; | ||
this._defaultNavigationTimeout = null; | ||
} | ||
/** | ||
* @param {number} timeout | ||
*/ | ||
setDefaultTimeout(timeout) { | ||
this._defaultTimeout = timeout; | ||
} | ||
/** | ||
* @param {number} timeout | ||
*/ | ||
setDefaultNavigationTimeout(timeout) { | ||
this._defaultNavigationTimeout = timeout; | ||
} | ||
/** | ||
* @return {number} | ||
*/ | ||
navigationTimeout() { | ||
if (this._defaultNavigationTimeout !== null) | ||
return this._defaultNavigationTimeout; | ||
if (this._defaultTimeout !== null) | ||
return this._defaultTimeout; | ||
return DEFAULT_TIMEOUT; | ||
} | ||
timeout() { | ||
if (this._defaultTimeout !== null) | ||
return this._defaultTimeout; | ||
return DEFAULT_TIMEOUT; | ||
} | ||
constructor() { | ||
this._defaultTimeout = null; | ||
this._defaultNavigationTimeout = null; | ||
} | ||
/** | ||
* @param {number} timeout | ||
*/ | ||
setDefaultTimeout(timeout) { | ||
this._defaultTimeout = timeout; | ||
} | ||
/** | ||
* @param {number} timeout | ||
*/ | ||
setDefaultNavigationTimeout(timeout) { | ||
this._defaultNavigationTimeout = timeout; | ||
} | ||
/** | ||
* @return {number} | ||
*/ | ||
navigationTimeout() { | ||
if (this._defaultNavigationTimeout !== null) | ||
return this._defaultNavigationTimeout; | ||
if (this._defaultTimeout !== null) | ||
return this._defaultTimeout; | ||
return DEFAULT_TIMEOUT; | ||
} | ||
timeout() { | ||
if (this._defaultTimeout !== null) | ||
return this._defaultTimeout; | ||
return DEFAULT_TIMEOUT; | ||
} | ||
} | ||
module.exports = {TimeoutSettings}; | ||
module.exports = { TimeoutSettings }; |
@@ -16,58 +16,47 @@ /** | ||
*/ | ||
const {helper, assert} = require('./helper'); | ||
const { helper, assert } = require('./helper'); | ||
class Tracing { | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
*/ | ||
constructor(client) { | ||
this._client = client; | ||
this._recording = false; | ||
this._path = ''; | ||
} | ||
/** | ||
* @param {!{path?: string, screenshots?: boolean, categories?: !Array<string>}} options | ||
*/ | ||
async start(options = {}) { | ||
assert(!this._recording, 'Cannot start recording trace while already recording trace.'); | ||
const defaultCategories = [ | ||
'-*', 'devtools.timeline', 'v8.execute', 'disabled-by-default-devtools.timeline', | ||
'disabled-by-default-devtools.timeline.frame', 'toplevel', | ||
'blink.console', 'blink.user_timing', 'latencyInfo', 'disabled-by-default-devtools.timeline.stack', | ||
'disabled-by-default-v8.cpu_profiler', 'disabled-by-default-v8.cpu_profiler.hires' | ||
]; | ||
const { | ||
path = null, | ||
screenshots = false, | ||
categories = defaultCategories, | ||
} = options; | ||
if (screenshots) | ||
categories.push('disabled-by-default-devtools.screenshot'); | ||
this._path = path; | ||
this._recording = true; | ||
await this._client.send('Tracing.start', { | ||
transferMode: 'ReturnAsStream', | ||
categories: categories.join(',') | ||
}); | ||
} | ||
/** | ||
* @return {!Promise<!Buffer>} | ||
*/ | ||
async stop() { | ||
let fulfill; | ||
const contentPromise = new Promise(x => fulfill = x); | ||
this._client.once('Tracing.tracingComplete', event => { | ||
helper.readProtocolStream(this._client, event.stream, this._path).then(fulfill); | ||
}); | ||
await this._client.send('Tracing.end'); | ||
this._recording = false; | ||
return contentPromise; | ||
} | ||
/** | ||
* @param {!Puppeteer.CDPSession} client | ||
*/ | ||
constructor(client) { | ||
this._client = client; | ||
this._recording = false; | ||
this._path = ''; | ||
} | ||
/** | ||
* @param {!{path?: string, screenshots?: boolean, categories?: !Array<string>}} options | ||
*/ | ||
async start(options = {}) { | ||
assert(!this._recording, 'Cannot start recording trace while already recording trace.'); | ||
const defaultCategories = [ | ||
'-*', 'devtools.timeline', 'v8.execute', 'disabled-by-default-devtools.timeline', | ||
'disabled-by-default-devtools.timeline.frame', 'toplevel', | ||
'blink.console', 'blink.user_timing', 'latencyInfo', 'disabled-by-default-devtools.timeline.stack', | ||
'disabled-by-default-v8.cpu_profiler', 'disabled-by-default-v8.cpu_profiler.hires' | ||
]; | ||
const { path = null, screenshots = false, categories = defaultCategories, } = options; | ||
if (screenshots) | ||
categories.push('disabled-by-default-devtools.screenshot'); | ||
this._path = path; | ||
this._recording = true; | ||
await this._client.send('Tracing.start', { | ||
transferMode: 'ReturnAsStream', | ||
categories: categories.join(',') | ||
}); | ||
} | ||
/** | ||
* @return {!Promise<!Buffer>} | ||
*/ | ||
async stop() { | ||
let fulfill; | ||
const contentPromise = new Promise(x => fulfill = x); | ||
this._client.once('Tracing.tracingComplete', event => { | ||
helper.readProtocolStream(this._client, event.stream, this._path).then(fulfill); | ||
}); | ||
await this._client.send('Tracing.end'); | ||
this._recording = false; | ||
return contentPromise; | ||
} | ||
} | ||
module.exports = Tracing; |
@@ -16,3 +16,2 @@ /** | ||
*/ | ||
/** | ||
@@ -29,3 +28,2 @@ * @typedef {Object} KeyDefinition | ||
*/ | ||
/** | ||
@@ -35,257 +33,257 @@ * @type {Object<string, KeyDefinition>} | ||
module.exports = { | ||
'0': {'keyCode': 48, 'key': '0', 'code': 'Digit0'}, | ||
'1': {'keyCode': 49, 'key': '1', 'code': 'Digit1'}, | ||
'2': {'keyCode': 50, 'key': '2', 'code': 'Digit2'}, | ||
'3': {'keyCode': 51, 'key': '3', 'code': 'Digit3'}, | ||
'4': {'keyCode': 52, 'key': '4', 'code': 'Digit4'}, | ||
'5': {'keyCode': 53, 'key': '5', 'code': 'Digit5'}, | ||
'6': {'keyCode': 54, 'key': '6', 'code': 'Digit6'}, | ||
'7': {'keyCode': 55, 'key': '7', 'code': 'Digit7'}, | ||
'8': {'keyCode': 56, 'key': '8', 'code': 'Digit8'}, | ||
'9': {'keyCode': 57, 'key': '9', 'code': 'Digit9'}, | ||
'Power': {'key': 'Power', 'code': 'Power'}, | ||
'Eject': {'key': 'Eject', 'code': 'Eject'}, | ||
'Abort': {'keyCode': 3, 'code': 'Abort', 'key': 'Cancel'}, | ||
'Help': {'keyCode': 6, 'code': 'Help', 'key': 'Help'}, | ||
'Backspace': {'keyCode': 8, 'code': 'Backspace', 'key': 'Backspace'}, | ||
'Tab': {'keyCode': 9, 'code': 'Tab', 'key': 'Tab'}, | ||
'Numpad5': {'keyCode': 12, 'shiftKeyCode': 101, 'key': 'Clear', 'code': 'Numpad5', 'shiftKey': '5', 'location': 3}, | ||
'NumpadEnter': {'keyCode': 13, 'code': 'NumpadEnter', 'key': 'Enter', 'text': '\r', 'location': 3}, | ||
'Enter': {'keyCode': 13, 'code': 'Enter', 'key': 'Enter', 'text': '\r'}, | ||
'\r': {'keyCode': 13, 'code': 'Enter', 'key': 'Enter', 'text': '\r'}, | ||
'\n': {'keyCode': 13, 'code': 'Enter', 'key': 'Enter', 'text': '\r'}, | ||
'ShiftLeft': {'keyCode': 16, 'code': 'ShiftLeft', 'key': 'Shift', 'location': 1}, | ||
'ShiftRight': {'keyCode': 16, 'code': 'ShiftRight', 'key': 'Shift', 'location': 2}, | ||
'ControlLeft': {'keyCode': 17, 'code': 'ControlLeft', 'key': 'Control', 'location': 1}, | ||
'ControlRight': {'keyCode': 17, 'code': 'ControlRight', 'key': 'Control', 'location': 2}, | ||
'AltLeft': {'keyCode': 18, 'code': 'AltLeft', 'key': 'Alt', 'location': 1}, | ||
'AltRight': {'keyCode': 18, 'code': 'AltRight', 'key': 'Alt', 'location': 2}, | ||
'Pause': {'keyCode': 19, 'code': 'Pause', 'key': 'Pause'}, | ||
'CapsLock': {'keyCode': 20, 'code': 'CapsLock', 'key': 'CapsLock'}, | ||
'Escape': {'keyCode': 27, 'code': 'Escape', 'key': 'Escape'}, | ||
'Convert': {'keyCode': 28, 'code': 'Convert', 'key': 'Convert'}, | ||
'NonConvert': {'keyCode': 29, 'code': 'NonConvert', 'key': 'NonConvert'}, | ||
'Space': {'keyCode': 32, 'code': 'Space', 'key': ' '}, | ||
'Numpad9': {'keyCode': 33, 'shiftKeyCode': 105, 'key': 'PageUp', 'code': 'Numpad9', 'shiftKey': '9', 'location': 3}, | ||
'PageUp': {'keyCode': 33, 'code': 'PageUp', 'key': 'PageUp'}, | ||
'Numpad3': {'keyCode': 34, 'shiftKeyCode': 99, 'key': 'PageDown', 'code': 'Numpad3', 'shiftKey': '3', 'location': 3}, | ||
'PageDown': {'keyCode': 34, 'code': 'PageDown', 'key': 'PageDown'}, | ||
'End': {'keyCode': 35, 'code': 'End', 'key': 'End'}, | ||
'Numpad1': {'keyCode': 35, 'shiftKeyCode': 97, 'key': 'End', 'code': 'Numpad1', 'shiftKey': '1', 'location': 3}, | ||
'Home': {'keyCode': 36, 'code': 'Home', 'key': 'Home'}, | ||
'Numpad7': {'keyCode': 36, 'shiftKeyCode': 103, 'key': 'Home', 'code': 'Numpad7', 'shiftKey': '7', 'location': 3}, | ||
'ArrowLeft': {'keyCode': 37, 'code': 'ArrowLeft', 'key': 'ArrowLeft'}, | ||
'Numpad4': {'keyCode': 37, 'shiftKeyCode': 100, 'key': 'ArrowLeft', 'code': 'Numpad4', 'shiftKey': '4', 'location': 3}, | ||
'Numpad8': {'keyCode': 38, 'shiftKeyCode': 104, 'key': 'ArrowUp', 'code': 'Numpad8', 'shiftKey': '8', 'location': 3}, | ||
'ArrowUp': {'keyCode': 38, 'code': 'ArrowUp', 'key': 'ArrowUp'}, | ||
'ArrowRight': {'keyCode': 39, 'code': 'ArrowRight', 'key': 'ArrowRight'}, | ||
'Numpad6': {'keyCode': 39, 'shiftKeyCode': 102, 'key': 'ArrowRight', 'code': 'Numpad6', 'shiftKey': '6', 'location': 3}, | ||
'Numpad2': {'keyCode': 40, 'shiftKeyCode': 98, 'key': 'ArrowDown', 'code': 'Numpad2', 'shiftKey': '2', 'location': 3}, | ||
'ArrowDown': {'keyCode': 40, 'code': 'ArrowDown', 'key': 'ArrowDown'}, | ||
'Select': {'keyCode': 41, 'code': 'Select', 'key': 'Select'}, | ||
'Open': {'keyCode': 43, 'code': 'Open', 'key': 'Execute'}, | ||
'PrintScreen': {'keyCode': 44, 'code': 'PrintScreen', 'key': 'PrintScreen'}, | ||
'Insert': {'keyCode': 45, 'code': 'Insert', 'key': 'Insert'}, | ||
'Numpad0': {'keyCode': 45, 'shiftKeyCode': 96, 'key': 'Insert', 'code': 'Numpad0', 'shiftKey': '0', 'location': 3}, | ||
'Delete': {'keyCode': 46, 'code': 'Delete', 'key': 'Delete'}, | ||
'NumpadDecimal': {'keyCode': 46, 'shiftKeyCode': 110, 'code': 'NumpadDecimal', 'key': '\u0000', 'shiftKey': '.', 'location': 3}, | ||
'Digit0': {'keyCode': 48, 'code': 'Digit0', 'shiftKey': ')', 'key': '0'}, | ||
'Digit1': {'keyCode': 49, 'code': 'Digit1', 'shiftKey': '!', 'key': '1'}, | ||
'Digit2': {'keyCode': 50, 'code': 'Digit2', 'shiftKey': '@', 'key': '2'}, | ||
'Digit3': {'keyCode': 51, 'code': 'Digit3', 'shiftKey': '#', 'key': '3'}, | ||
'Digit4': {'keyCode': 52, 'code': 'Digit4', 'shiftKey': '$', 'key': '4'}, | ||
'Digit5': {'keyCode': 53, 'code': 'Digit5', 'shiftKey': '%', 'key': '5'}, | ||
'Digit6': {'keyCode': 54, 'code': 'Digit6', 'shiftKey': '^', 'key': '6'}, | ||
'Digit7': {'keyCode': 55, 'code': 'Digit7', 'shiftKey': '&', 'key': '7'}, | ||
'Digit8': {'keyCode': 56, 'code': 'Digit8', 'shiftKey': '*', 'key': '8'}, | ||
'Digit9': {'keyCode': 57, 'code': 'Digit9', 'shiftKey': '\(', 'key': '9'}, | ||
'KeyA': {'keyCode': 65, 'code': 'KeyA', 'shiftKey': 'A', 'key': 'a'}, | ||
'KeyB': {'keyCode': 66, 'code': 'KeyB', 'shiftKey': 'B', 'key': 'b'}, | ||
'KeyC': {'keyCode': 67, 'code': 'KeyC', 'shiftKey': 'C', 'key': 'c'}, | ||
'KeyD': {'keyCode': 68, 'code': 'KeyD', 'shiftKey': 'D', 'key': 'd'}, | ||
'KeyE': {'keyCode': 69, 'code': 'KeyE', 'shiftKey': 'E', 'key': 'e'}, | ||
'KeyF': {'keyCode': 70, 'code': 'KeyF', 'shiftKey': 'F', 'key': 'f'}, | ||
'KeyG': {'keyCode': 71, 'code': 'KeyG', 'shiftKey': 'G', 'key': 'g'}, | ||
'KeyH': {'keyCode': 72, 'code': 'KeyH', 'shiftKey': 'H', 'key': 'h'}, | ||
'KeyI': {'keyCode': 73, 'code': 'KeyI', 'shiftKey': 'I', 'key': 'i'}, | ||
'KeyJ': {'keyCode': 74, 'code': 'KeyJ', 'shiftKey': 'J', 'key': 'j'}, | ||
'KeyK': {'keyCode': 75, 'code': 'KeyK', 'shiftKey': 'K', 'key': 'k'}, | ||
'KeyL': {'keyCode': 76, 'code': 'KeyL', 'shiftKey': 'L', 'key': 'l'}, | ||
'KeyM': {'keyCode': 77, 'code': 'KeyM', 'shiftKey': 'M', 'key': 'm'}, | ||
'KeyN': {'keyCode': 78, 'code': 'KeyN', 'shiftKey': 'N', 'key': 'n'}, | ||
'KeyO': {'keyCode': 79, 'code': 'KeyO', 'shiftKey': 'O', 'key': 'o'}, | ||
'KeyP': {'keyCode': 80, 'code': 'KeyP', 'shiftKey': 'P', 'key': 'p'}, | ||
'KeyQ': {'keyCode': 81, 'code': 'KeyQ', 'shiftKey': 'Q', 'key': 'q'}, | ||
'KeyR': {'keyCode': 82, 'code': 'KeyR', 'shiftKey': 'R', 'key': 'r'}, | ||
'KeyS': {'keyCode': 83, 'code': 'KeyS', 'shiftKey': 'S', 'key': 's'}, | ||
'KeyT': {'keyCode': 84, 'code': 'KeyT', 'shiftKey': 'T', 'key': 't'}, | ||
'KeyU': {'keyCode': 85, 'code': 'KeyU', 'shiftKey': 'U', 'key': 'u'}, | ||
'KeyV': {'keyCode': 86, 'code': 'KeyV', 'shiftKey': 'V', 'key': 'v'}, | ||
'KeyW': {'keyCode': 87, 'code': 'KeyW', 'shiftKey': 'W', 'key': 'w'}, | ||
'KeyX': {'keyCode': 88, 'code': 'KeyX', 'shiftKey': 'X', 'key': 'x'}, | ||
'KeyY': {'keyCode': 89, 'code': 'KeyY', 'shiftKey': 'Y', 'key': 'y'}, | ||
'KeyZ': {'keyCode': 90, 'code': 'KeyZ', 'shiftKey': 'Z', 'key': 'z'}, | ||
'MetaLeft': {'keyCode': 91, 'code': 'MetaLeft', 'key': 'Meta', 'location': 1}, | ||
'MetaRight': {'keyCode': 92, 'code': 'MetaRight', 'key': 'Meta', 'location': 2}, | ||
'ContextMenu': {'keyCode': 93, 'code': 'ContextMenu', 'key': 'ContextMenu'}, | ||
'NumpadMultiply': {'keyCode': 106, 'code': 'NumpadMultiply', 'key': '*', 'location': 3}, | ||
'NumpadAdd': {'keyCode': 107, 'code': 'NumpadAdd', 'key': '+', 'location': 3}, | ||
'NumpadSubtract': {'keyCode': 109, 'code': 'NumpadSubtract', 'key': '-', 'location': 3}, | ||
'NumpadDivide': {'keyCode': 111, 'code': 'NumpadDivide', 'key': '/', 'location': 3}, | ||
'F1': {'keyCode': 112, 'code': 'F1', 'key': 'F1'}, | ||
'F2': {'keyCode': 113, 'code': 'F2', 'key': 'F2'}, | ||
'F3': {'keyCode': 114, 'code': 'F3', 'key': 'F3'}, | ||
'F4': {'keyCode': 115, 'code': 'F4', 'key': 'F4'}, | ||
'F5': {'keyCode': 116, 'code': 'F5', 'key': 'F5'}, | ||
'F6': {'keyCode': 117, 'code': 'F6', 'key': 'F6'}, | ||
'F7': {'keyCode': 118, 'code': 'F7', 'key': 'F7'}, | ||
'F8': {'keyCode': 119, 'code': 'F8', 'key': 'F8'}, | ||
'F9': {'keyCode': 120, 'code': 'F9', 'key': 'F9'}, | ||
'F10': {'keyCode': 121, 'code': 'F10', 'key': 'F10'}, | ||
'F11': {'keyCode': 122, 'code': 'F11', 'key': 'F11'}, | ||
'F12': {'keyCode': 123, 'code': 'F12', 'key': 'F12'}, | ||
'F13': {'keyCode': 124, 'code': 'F13', 'key': 'F13'}, | ||
'F14': {'keyCode': 125, 'code': 'F14', 'key': 'F14'}, | ||
'F15': {'keyCode': 126, 'code': 'F15', 'key': 'F15'}, | ||
'F16': {'keyCode': 127, 'code': 'F16', 'key': 'F16'}, | ||
'F17': {'keyCode': 128, 'code': 'F17', 'key': 'F17'}, | ||
'F18': {'keyCode': 129, 'code': 'F18', 'key': 'F18'}, | ||
'F19': {'keyCode': 130, 'code': 'F19', 'key': 'F19'}, | ||
'F20': {'keyCode': 131, 'code': 'F20', 'key': 'F20'}, | ||
'F21': {'keyCode': 132, 'code': 'F21', 'key': 'F21'}, | ||
'F22': {'keyCode': 133, 'code': 'F22', 'key': 'F22'}, | ||
'F23': {'keyCode': 134, 'code': 'F23', 'key': 'F23'}, | ||
'F24': {'keyCode': 135, 'code': 'F24', 'key': 'F24'}, | ||
'NumLock': {'keyCode': 144, 'code': 'NumLock', 'key': 'NumLock'}, | ||
'ScrollLock': {'keyCode': 145, 'code': 'ScrollLock', 'key': 'ScrollLock'}, | ||
'AudioVolumeMute': {'keyCode': 173, 'code': 'AudioVolumeMute', 'key': 'AudioVolumeMute'}, | ||
'AudioVolumeDown': {'keyCode': 174, 'code': 'AudioVolumeDown', 'key': 'AudioVolumeDown'}, | ||
'AudioVolumeUp': {'keyCode': 175, 'code': 'AudioVolumeUp', 'key': 'AudioVolumeUp'}, | ||
'MediaTrackNext': {'keyCode': 176, 'code': 'MediaTrackNext', 'key': 'MediaTrackNext'}, | ||
'MediaTrackPrevious': {'keyCode': 177, 'code': 'MediaTrackPrevious', 'key': 'MediaTrackPrevious'}, | ||
'MediaStop': {'keyCode': 178, 'code': 'MediaStop', 'key': 'MediaStop'}, | ||
'MediaPlayPause': {'keyCode': 179, 'code': 'MediaPlayPause', 'key': 'MediaPlayPause'}, | ||
'Semicolon': {'keyCode': 186, 'code': 'Semicolon', 'shiftKey': ':', 'key': ';'}, | ||
'Equal': {'keyCode': 187, 'code': 'Equal', 'shiftKey': '+', 'key': '='}, | ||
'NumpadEqual': {'keyCode': 187, 'code': 'NumpadEqual', 'key': '=', 'location': 3}, | ||
'Comma': {'keyCode': 188, 'code': 'Comma', 'shiftKey': '\<', 'key': ','}, | ||
'Minus': {'keyCode': 189, 'code': 'Minus', 'shiftKey': '_', 'key': '-'}, | ||
'Period': {'keyCode': 190, 'code': 'Period', 'shiftKey': '>', 'key': '.'}, | ||
'Slash': {'keyCode': 191, 'code': 'Slash', 'shiftKey': '?', 'key': '/'}, | ||
'Backquote': {'keyCode': 192, 'code': 'Backquote', 'shiftKey': '~', 'key': '`'}, | ||
'BracketLeft': {'keyCode': 219, 'code': 'BracketLeft', 'shiftKey': '{', 'key': '['}, | ||
'Backslash': {'keyCode': 220, 'code': 'Backslash', 'shiftKey': '|', 'key': '\\'}, | ||
'BracketRight': {'keyCode': 221, 'code': 'BracketRight', 'shiftKey': '}', 'key': ']'}, | ||
'Quote': {'keyCode': 222, 'code': 'Quote', 'shiftKey': '"', 'key': '\''}, | ||
'AltGraph': {'keyCode': 225, 'code': 'AltGraph', 'key': 'AltGraph'}, | ||
'Props': {'keyCode': 247, 'code': 'Props', 'key': 'CrSel'}, | ||
'Cancel': {'keyCode': 3, 'key': 'Cancel', 'code': 'Abort'}, | ||
'Clear': {'keyCode': 12, 'key': 'Clear', 'code': 'Numpad5', 'location': 3}, | ||
'Shift': {'keyCode': 16, 'key': 'Shift', 'code': 'ShiftLeft', 'location': 1}, | ||
'Control': {'keyCode': 17, 'key': 'Control', 'code': 'ControlLeft', 'location': 1}, | ||
'Alt': {'keyCode': 18, 'key': 'Alt', 'code': 'AltLeft', 'location': 1}, | ||
'Accept': {'keyCode': 30, 'key': 'Accept'}, | ||
'ModeChange': {'keyCode': 31, 'key': 'ModeChange'}, | ||
' ': {'keyCode': 32, 'key': ' ', 'code': 'Space'}, | ||
'Print': {'keyCode': 42, 'key': 'Print'}, | ||
'Execute': {'keyCode': 43, 'key': 'Execute', 'code': 'Open'}, | ||
'\u0000': {'keyCode': 46, 'key': '\u0000', 'code': 'NumpadDecimal', 'location': 3}, | ||
'a': {'keyCode': 65, 'key': 'a', 'code': 'KeyA'}, | ||
'b': {'keyCode': 66, 'key': 'b', 'code': 'KeyB'}, | ||
'c': {'keyCode': 67, 'key': 'c', 'code': 'KeyC'}, | ||
'd': {'keyCode': 68, 'key': 'd', 'code': 'KeyD'}, | ||
'e': {'keyCode': 69, 'key': 'e', 'code': 'KeyE'}, | ||
'f': {'keyCode': 70, 'key': 'f', 'code': 'KeyF'}, | ||
'g': {'keyCode': 71, 'key': 'g', 'code': 'KeyG'}, | ||
'h': {'keyCode': 72, 'key': 'h', 'code': 'KeyH'}, | ||
'i': {'keyCode': 73, 'key': 'i', 'code': 'KeyI'}, | ||
'j': {'keyCode': 74, 'key': 'j', 'code': 'KeyJ'}, | ||
'k': {'keyCode': 75, 'key': 'k', 'code': 'KeyK'}, | ||
'l': {'keyCode': 76, 'key': 'l', 'code': 'KeyL'}, | ||
'm': {'keyCode': 77, 'key': 'm', 'code': 'KeyM'}, | ||
'n': {'keyCode': 78, 'key': 'n', 'code': 'KeyN'}, | ||
'o': {'keyCode': 79, 'key': 'o', 'code': 'KeyO'}, | ||
'p': {'keyCode': 80, 'key': 'p', 'code': 'KeyP'}, | ||
'q': {'keyCode': 81, 'key': 'q', 'code': 'KeyQ'}, | ||
'r': {'keyCode': 82, 'key': 'r', 'code': 'KeyR'}, | ||
's': {'keyCode': 83, 'key': 's', 'code': 'KeyS'}, | ||
't': {'keyCode': 84, 'key': 't', 'code': 'KeyT'}, | ||
'u': {'keyCode': 85, 'key': 'u', 'code': 'KeyU'}, | ||
'v': {'keyCode': 86, 'key': 'v', 'code': 'KeyV'}, | ||
'w': {'keyCode': 87, 'key': 'w', 'code': 'KeyW'}, | ||
'x': {'keyCode': 88, 'key': 'x', 'code': 'KeyX'}, | ||
'y': {'keyCode': 89, 'key': 'y', 'code': 'KeyY'}, | ||
'z': {'keyCode': 90, 'key': 'z', 'code': 'KeyZ'}, | ||
'Meta': {'keyCode': 91, 'key': 'Meta', 'code': 'MetaLeft', 'location': 1}, | ||
'*': {'keyCode': 106, 'key': '*', 'code': 'NumpadMultiply', 'location': 3}, | ||
'+': {'keyCode': 107, 'key': '+', 'code': 'NumpadAdd', 'location': 3}, | ||
'-': {'keyCode': 109, 'key': '-', 'code': 'NumpadSubtract', 'location': 3}, | ||
'/': {'keyCode': 111, 'key': '/', 'code': 'NumpadDivide', 'location': 3}, | ||
';': {'keyCode': 186, 'key': ';', 'code': 'Semicolon'}, | ||
'=': {'keyCode': 187, 'key': '=', 'code': 'Equal'}, | ||
',': {'keyCode': 188, 'key': ',', 'code': 'Comma'}, | ||
'.': {'keyCode': 190, 'key': '.', 'code': 'Period'}, | ||
'`': {'keyCode': 192, 'key': '`', 'code': 'Backquote'}, | ||
'[': {'keyCode': 219, 'key': '[', 'code': 'BracketLeft'}, | ||
'\\': {'keyCode': 220, 'key': '\\', 'code': 'Backslash'}, | ||
']': {'keyCode': 221, 'key': ']', 'code': 'BracketRight'}, | ||
'\'': {'keyCode': 222, 'key': '\'', 'code': 'Quote'}, | ||
'Attn': {'keyCode': 246, 'key': 'Attn'}, | ||
'CrSel': {'keyCode': 247, 'key': 'CrSel', 'code': 'Props'}, | ||
'ExSel': {'keyCode': 248, 'key': 'ExSel'}, | ||
'EraseEof': {'keyCode': 249, 'key': 'EraseEof'}, | ||
'Play': {'keyCode': 250, 'key': 'Play'}, | ||
'ZoomOut': {'keyCode': 251, 'key': 'ZoomOut'}, | ||
')': {'keyCode': 48, 'key': ')', 'code': 'Digit0'}, | ||
'!': {'keyCode': 49, 'key': '!', 'code': 'Digit1'}, | ||
'@': {'keyCode': 50, 'key': '@', 'code': 'Digit2'}, | ||
'#': {'keyCode': 51, 'key': '#', 'code': 'Digit3'}, | ||
'$': {'keyCode': 52, 'key': '$', 'code': 'Digit4'}, | ||
'%': {'keyCode': 53, 'key': '%', 'code': 'Digit5'}, | ||
'^': {'keyCode': 54, 'key': '^', 'code': 'Digit6'}, | ||
'&': {'keyCode': 55, 'key': '&', 'code': 'Digit7'}, | ||
'(': {'keyCode': 57, 'key': '\(', 'code': 'Digit9'}, | ||
'A': {'keyCode': 65, 'key': 'A', 'code': 'KeyA'}, | ||
'B': {'keyCode': 66, 'key': 'B', 'code': 'KeyB'}, | ||
'C': {'keyCode': 67, 'key': 'C', 'code': 'KeyC'}, | ||
'D': {'keyCode': 68, 'key': 'D', 'code': 'KeyD'}, | ||
'E': {'keyCode': 69, 'key': 'E', 'code': 'KeyE'}, | ||
'F': {'keyCode': 70, 'key': 'F', 'code': 'KeyF'}, | ||
'G': {'keyCode': 71, 'key': 'G', 'code': 'KeyG'}, | ||
'H': {'keyCode': 72, 'key': 'H', 'code': 'KeyH'}, | ||
'I': {'keyCode': 73, 'key': 'I', 'code': 'KeyI'}, | ||
'J': {'keyCode': 74, 'key': 'J', 'code': 'KeyJ'}, | ||
'K': {'keyCode': 75, 'key': 'K', 'code': 'KeyK'}, | ||
'L': {'keyCode': 76, 'key': 'L', 'code': 'KeyL'}, | ||
'M': {'keyCode': 77, 'key': 'M', 'code': 'KeyM'}, | ||
'N': {'keyCode': 78, 'key': 'N', 'code': 'KeyN'}, | ||
'O': {'keyCode': 79, 'key': 'O', 'code': 'KeyO'}, | ||
'P': {'keyCode': 80, 'key': 'P', 'code': 'KeyP'}, | ||
'Q': {'keyCode': 81, 'key': 'Q', 'code': 'KeyQ'}, | ||
'R': {'keyCode': 82, 'key': 'R', 'code': 'KeyR'}, | ||
'S': {'keyCode': 83, 'key': 'S', 'code': 'KeyS'}, | ||
'T': {'keyCode': 84, 'key': 'T', 'code': 'KeyT'}, | ||
'U': {'keyCode': 85, 'key': 'U', 'code': 'KeyU'}, | ||
'V': {'keyCode': 86, 'key': 'V', 'code': 'KeyV'}, | ||
'W': {'keyCode': 87, 'key': 'W', 'code': 'KeyW'}, | ||
'X': {'keyCode': 88, 'key': 'X', 'code': 'KeyX'}, | ||
'Y': {'keyCode': 89, 'key': 'Y', 'code': 'KeyY'}, | ||
'Z': {'keyCode': 90, 'key': 'Z', 'code': 'KeyZ'}, | ||
':': {'keyCode': 186, 'key': ':', 'code': 'Semicolon'}, | ||
'<': {'keyCode': 188, 'key': '\<', 'code': 'Comma'}, | ||
'_': {'keyCode': 189, 'key': '_', 'code': 'Minus'}, | ||
'>': {'keyCode': 190, 'key': '>', 'code': 'Period'}, | ||
'?': {'keyCode': 191, 'key': '?', 'code': 'Slash'}, | ||
'~': {'keyCode': 192, 'key': '~', 'code': 'Backquote'}, | ||
'{': {'keyCode': 219, 'key': '{', 'code': 'BracketLeft'}, | ||
'|': {'keyCode': 220, 'key': '|', 'code': 'Backslash'}, | ||
'}': {'keyCode': 221, 'key': '}', 'code': 'BracketRight'}, | ||
'"': {'keyCode': 222, 'key': '"', 'code': 'Quote'}, | ||
'SoftLeft': {'key': 'SoftLeft', 'code': 'SoftLeft', 'location': 4}, | ||
'SoftRight': {'key': 'SoftRight', 'code': 'SoftRight', 'location': 4}, | ||
'Camera': {'keyCode': 44, 'key': 'Camera', 'code': 'Camera', 'location': 4}, | ||
'Call': {'key': 'Call', 'code': 'Call', 'location': 4}, | ||
'EndCall': {'keyCode': 95, 'key': 'EndCall', 'code': 'EndCall', 'location': 4}, | ||
'VolumeDown': {'keyCode': 182, 'key': 'VolumeDown', 'code': 'VolumeDown', 'location': 4}, | ||
'VolumeUp': {'keyCode': 183, 'key': 'VolumeUp', 'code': 'VolumeUp', 'location': 4}, | ||
}; | ||
'0': { 'keyCode': 48, 'key': '0', 'code': 'Digit0' }, | ||
'1': { 'keyCode': 49, 'key': '1', 'code': 'Digit1' }, | ||
'2': { 'keyCode': 50, 'key': '2', 'code': 'Digit2' }, | ||
'3': { 'keyCode': 51, 'key': '3', 'code': 'Digit3' }, | ||
'4': { 'keyCode': 52, 'key': '4', 'code': 'Digit4' }, | ||
'5': { 'keyCode': 53, 'key': '5', 'code': 'Digit5' }, | ||
'6': { 'keyCode': 54, 'key': '6', 'code': 'Digit6' }, | ||
'7': { 'keyCode': 55, 'key': '7', 'code': 'Digit7' }, | ||
'8': { 'keyCode': 56, 'key': '8', 'code': 'Digit8' }, | ||
'9': { 'keyCode': 57, 'key': '9', 'code': 'Digit9' }, | ||
'Power': { 'key': 'Power', 'code': 'Power' }, | ||
'Eject': { 'key': 'Eject', 'code': 'Eject' }, | ||
'Abort': { 'keyCode': 3, 'code': 'Abort', 'key': 'Cancel' }, | ||
'Help': { 'keyCode': 6, 'code': 'Help', 'key': 'Help' }, | ||
'Backspace': { 'keyCode': 8, 'code': 'Backspace', 'key': 'Backspace' }, | ||
'Tab': { 'keyCode': 9, 'code': 'Tab', 'key': 'Tab' }, | ||
'Numpad5': { 'keyCode': 12, 'shiftKeyCode': 101, 'key': 'Clear', 'code': 'Numpad5', 'shiftKey': '5', 'location': 3 }, | ||
'NumpadEnter': { 'keyCode': 13, 'code': 'NumpadEnter', 'key': 'Enter', 'text': '\r', 'location': 3 }, | ||
'Enter': { 'keyCode': 13, 'code': 'Enter', 'key': 'Enter', 'text': '\r' }, | ||
'\r': { 'keyCode': 13, 'code': 'Enter', 'key': 'Enter', 'text': '\r' }, | ||
'\n': { 'keyCode': 13, 'code': 'Enter', 'key': 'Enter', 'text': '\r' }, | ||
'ShiftLeft': { 'keyCode': 16, 'code': 'ShiftLeft', 'key': 'Shift', 'location': 1 }, | ||
'ShiftRight': { 'keyCode': 16, 'code': 'ShiftRight', 'key': 'Shift', 'location': 2 }, | ||
'ControlLeft': { 'keyCode': 17, 'code': 'ControlLeft', 'key': 'Control', 'location': 1 }, | ||
'ControlRight': { 'keyCode': 17, 'code': 'ControlRight', 'key': 'Control', 'location': 2 }, | ||
'AltLeft': { 'keyCode': 18, 'code': 'AltLeft', 'key': 'Alt', 'location': 1 }, | ||
'AltRight': { 'keyCode': 18, 'code': 'AltRight', 'key': 'Alt', 'location': 2 }, | ||
'Pause': { 'keyCode': 19, 'code': 'Pause', 'key': 'Pause' }, | ||
'CapsLock': { 'keyCode': 20, 'code': 'CapsLock', 'key': 'CapsLock' }, | ||
'Escape': { 'keyCode': 27, 'code': 'Escape', 'key': 'Escape' }, | ||
'Convert': { 'keyCode': 28, 'code': 'Convert', 'key': 'Convert' }, | ||
'NonConvert': { 'keyCode': 29, 'code': 'NonConvert', 'key': 'NonConvert' }, | ||
'Space': { 'keyCode': 32, 'code': 'Space', 'key': ' ' }, | ||
'Numpad9': { 'keyCode': 33, 'shiftKeyCode': 105, 'key': 'PageUp', 'code': 'Numpad9', 'shiftKey': '9', 'location': 3 }, | ||
'PageUp': { 'keyCode': 33, 'code': 'PageUp', 'key': 'PageUp' }, | ||
'Numpad3': { 'keyCode': 34, 'shiftKeyCode': 99, 'key': 'PageDown', 'code': 'Numpad3', 'shiftKey': '3', 'location': 3 }, | ||
'PageDown': { 'keyCode': 34, 'code': 'PageDown', 'key': 'PageDown' }, | ||
'End': { 'keyCode': 35, 'code': 'End', 'key': 'End' }, | ||
'Numpad1': { 'keyCode': 35, 'shiftKeyCode': 97, 'key': 'End', 'code': 'Numpad1', 'shiftKey': '1', 'location': 3 }, | ||
'Home': { 'keyCode': 36, 'code': 'Home', 'key': 'Home' }, | ||
'Numpad7': { 'keyCode': 36, 'shiftKeyCode': 103, 'key': 'Home', 'code': 'Numpad7', 'shiftKey': '7', 'location': 3 }, | ||
'ArrowLeft': { 'keyCode': 37, 'code': 'ArrowLeft', 'key': 'ArrowLeft' }, | ||
'Numpad4': { 'keyCode': 37, 'shiftKeyCode': 100, 'key': 'ArrowLeft', 'code': 'Numpad4', 'shiftKey': '4', 'location': 3 }, | ||
'Numpad8': { 'keyCode': 38, 'shiftKeyCode': 104, 'key': 'ArrowUp', 'code': 'Numpad8', 'shiftKey': '8', 'location': 3 }, | ||
'ArrowUp': { 'keyCode': 38, 'code': 'ArrowUp', 'key': 'ArrowUp' }, | ||
'ArrowRight': { 'keyCode': 39, 'code': 'ArrowRight', 'key': 'ArrowRight' }, | ||
'Numpad6': { 'keyCode': 39, 'shiftKeyCode': 102, 'key': 'ArrowRight', 'code': 'Numpad6', 'shiftKey': '6', 'location': 3 }, | ||
'Numpad2': { 'keyCode': 40, 'shiftKeyCode': 98, 'key': 'ArrowDown', 'code': 'Numpad2', 'shiftKey': '2', 'location': 3 }, | ||
'ArrowDown': { 'keyCode': 40, 'code': 'ArrowDown', 'key': 'ArrowDown' }, | ||
'Select': { 'keyCode': 41, 'code': 'Select', 'key': 'Select' }, | ||
'Open': { 'keyCode': 43, 'code': 'Open', 'key': 'Execute' }, | ||
'PrintScreen': { 'keyCode': 44, 'code': 'PrintScreen', 'key': 'PrintScreen' }, | ||
'Insert': { 'keyCode': 45, 'code': 'Insert', 'key': 'Insert' }, | ||
'Numpad0': { 'keyCode': 45, 'shiftKeyCode': 96, 'key': 'Insert', 'code': 'Numpad0', 'shiftKey': '0', 'location': 3 }, | ||
'Delete': { 'keyCode': 46, 'code': 'Delete', 'key': 'Delete' }, | ||
'NumpadDecimal': { 'keyCode': 46, 'shiftKeyCode': 110, 'code': 'NumpadDecimal', 'key': '\u0000', 'shiftKey': '.', 'location': 3 }, | ||
'Digit0': { 'keyCode': 48, 'code': 'Digit0', 'shiftKey': ')', 'key': '0' }, | ||
'Digit1': { 'keyCode': 49, 'code': 'Digit1', 'shiftKey': '!', 'key': '1' }, | ||
'Digit2': { 'keyCode': 50, 'code': 'Digit2', 'shiftKey': '@', 'key': '2' }, | ||
'Digit3': { 'keyCode': 51, 'code': 'Digit3', 'shiftKey': '#', 'key': '3' }, | ||
'Digit4': { 'keyCode': 52, 'code': 'Digit4', 'shiftKey': '$', 'key': '4' }, | ||
'Digit5': { 'keyCode': 53, 'code': 'Digit5', 'shiftKey': '%', 'key': '5' }, | ||
'Digit6': { 'keyCode': 54, 'code': 'Digit6', 'shiftKey': '^', 'key': '6' }, | ||
'Digit7': { 'keyCode': 55, 'code': 'Digit7', 'shiftKey': '&', 'key': '7' }, | ||
'Digit8': { 'keyCode': 56, 'code': 'Digit8', 'shiftKey': '*', 'key': '8' }, | ||
'Digit9': { 'keyCode': 57, 'code': 'Digit9', 'shiftKey': '\(', 'key': '9' }, | ||
'KeyA': { 'keyCode': 65, 'code': 'KeyA', 'shiftKey': 'A', 'key': 'a' }, | ||
'KeyB': { 'keyCode': 66, 'code': 'KeyB', 'shiftKey': 'B', 'key': 'b' }, | ||
'KeyC': { 'keyCode': 67, 'code': 'KeyC', 'shiftKey': 'C', 'key': 'c' }, | ||
'KeyD': { 'keyCode': 68, 'code': 'KeyD', 'shiftKey': 'D', 'key': 'd' }, | ||
'KeyE': { 'keyCode': 69, 'code': 'KeyE', 'shiftKey': 'E', 'key': 'e' }, | ||
'KeyF': { 'keyCode': 70, 'code': 'KeyF', 'shiftKey': 'F', 'key': 'f' }, | ||
'KeyG': { 'keyCode': 71, 'code': 'KeyG', 'shiftKey': 'G', 'key': 'g' }, | ||
'KeyH': { 'keyCode': 72, 'code': 'KeyH', 'shiftKey': 'H', 'key': 'h' }, | ||
'KeyI': { 'keyCode': 73, 'code': 'KeyI', 'shiftKey': 'I', 'key': 'i' }, | ||
'KeyJ': { 'keyCode': 74, 'code': 'KeyJ', 'shiftKey': 'J', 'key': 'j' }, | ||
'KeyK': { 'keyCode': 75, 'code': 'KeyK', 'shiftKey': 'K', 'key': 'k' }, | ||
'KeyL': { 'keyCode': 76, 'code': 'KeyL', 'shiftKey': 'L', 'key': 'l' }, | ||
'KeyM': { 'keyCode': 77, 'code': 'KeyM', 'shiftKey': 'M', 'key': 'm' }, | ||
'KeyN': { 'keyCode': 78, 'code': 'KeyN', 'shiftKey': 'N', 'key': 'n' }, | ||
'KeyO': { 'keyCode': 79, 'code': 'KeyO', 'shiftKey': 'O', 'key': 'o' }, | ||
'KeyP': { 'keyCode': 80, 'code': 'KeyP', 'shiftKey': 'P', 'key': 'p' }, | ||
'KeyQ': { 'keyCode': 81, 'code': 'KeyQ', 'shiftKey': 'Q', 'key': 'q' }, | ||
'KeyR': { 'keyCode': 82, 'code': 'KeyR', 'shiftKey': 'R', 'key': 'r' }, | ||
'KeyS': { 'keyCode': 83, 'code': 'KeyS', 'shiftKey': 'S', 'key': 's' }, | ||
'KeyT': { 'keyCode': 84, 'code': 'KeyT', 'shiftKey': 'T', 'key': 't' }, | ||
'KeyU': { 'keyCode': 85, 'code': 'KeyU', 'shiftKey': 'U', 'key': 'u' }, | ||
'KeyV': { 'keyCode': 86, 'code': 'KeyV', 'shiftKey': 'V', 'key': 'v' }, | ||
'KeyW': { 'keyCode': 87, 'code': 'KeyW', 'shiftKey': 'W', 'key': 'w' }, | ||
'KeyX': { 'keyCode': 88, 'code': 'KeyX', 'shiftKey': 'X', 'key': 'x' }, | ||
'KeyY': { 'keyCode': 89, 'code': 'KeyY', 'shiftKey': 'Y', 'key': 'y' }, | ||
'KeyZ': { 'keyCode': 90, 'code': 'KeyZ', 'shiftKey': 'Z', 'key': 'z' }, | ||
'MetaLeft': { 'keyCode': 91, 'code': 'MetaLeft', 'key': 'Meta', 'location': 1 }, | ||
'MetaRight': { 'keyCode': 92, 'code': 'MetaRight', 'key': 'Meta', 'location': 2 }, | ||
'ContextMenu': { 'keyCode': 93, 'code': 'ContextMenu', 'key': 'ContextMenu' }, | ||
'NumpadMultiply': { 'keyCode': 106, 'code': 'NumpadMultiply', 'key': '*', 'location': 3 }, | ||
'NumpadAdd': { 'keyCode': 107, 'code': 'NumpadAdd', 'key': '+', 'location': 3 }, | ||
'NumpadSubtract': { 'keyCode': 109, 'code': 'NumpadSubtract', 'key': '-', 'location': 3 }, | ||
'NumpadDivide': { 'keyCode': 111, 'code': 'NumpadDivide', 'key': '/', 'location': 3 }, | ||
'F1': { 'keyCode': 112, 'code': 'F1', 'key': 'F1' }, | ||
'F2': { 'keyCode': 113, 'code': 'F2', 'key': 'F2' }, | ||
'F3': { 'keyCode': 114, 'code': 'F3', 'key': 'F3' }, | ||
'F4': { 'keyCode': 115, 'code': 'F4', 'key': 'F4' }, | ||
'F5': { 'keyCode': 116, 'code': 'F5', 'key': 'F5' }, | ||
'F6': { 'keyCode': 117, 'code': 'F6', 'key': 'F6' }, | ||
'F7': { 'keyCode': 118, 'code': 'F7', 'key': 'F7' }, | ||
'F8': { 'keyCode': 119, 'code': 'F8', 'key': 'F8' }, | ||
'F9': { 'keyCode': 120, 'code': 'F9', 'key': 'F9' }, | ||
'F10': { 'keyCode': 121, 'code': 'F10', 'key': 'F10' }, | ||
'F11': { 'keyCode': 122, 'code': 'F11', 'key': 'F11' }, | ||
'F12': { 'keyCode': 123, 'code': 'F12', 'key': 'F12' }, | ||
'F13': { 'keyCode': 124, 'code': 'F13', 'key': 'F13' }, | ||
'F14': { 'keyCode': 125, 'code': 'F14', 'key': 'F14' }, | ||
'F15': { 'keyCode': 126, 'code': 'F15', 'key': 'F15' }, | ||
'F16': { 'keyCode': 127, 'code': 'F16', 'key': 'F16' }, | ||
'F17': { 'keyCode': 128, 'code': 'F17', 'key': 'F17' }, | ||
'F18': { 'keyCode': 129, 'code': 'F18', 'key': 'F18' }, | ||
'F19': { 'keyCode': 130, 'code': 'F19', 'key': 'F19' }, | ||
'F20': { 'keyCode': 131, 'code': 'F20', 'key': 'F20' }, | ||
'F21': { 'keyCode': 132, 'code': 'F21', 'key': 'F21' }, | ||
'F22': { 'keyCode': 133, 'code': 'F22', 'key': 'F22' }, | ||
'F23': { 'keyCode': 134, 'code': 'F23', 'key': 'F23' }, | ||
'F24': { 'keyCode': 135, 'code': 'F24', 'key': 'F24' }, | ||
'NumLock': { 'keyCode': 144, 'code': 'NumLock', 'key': 'NumLock' }, | ||
'ScrollLock': { 'keyCode': 145, 'code': 'ScrollLock', 'key': 'ScrollLock' }, | ||
'AudioVolumeMute': { 'keyCode': 173, 'code': 'AudioVolumeMute', 'key': 'AudioVolumeMute' }, | ||
'AudioVolumeDown': { 'keyCode': 174, 'code': 'AudioVolumeDown', 'key': 'AudioVolumeDown' }, | ||
'AudioVolumeUp': { 'keyCode': 175, 'code': 'AudioVolumeUp', 'key': 'AudioVolumeUp' }, | ||
'MediaTrackNext': { 'keyCode': 176, 'code': 'MediaTrackNext', 'key': 'MediaTrackNext' }, | ||
'MediaTrackPrevious': { 'keyCode': 177, 'code': 'MediaTrackPrevious', 'key': 'MediaTrackPrevious' }, | ||
'MediaStop': { 'keyCode': 178, 'code': 'MediaStop', 'key': 'MediaStop' }, | ||
'MediaPlayPause': { 'keyCode': 179, 'code': 'MediaPlayPause', 'key': 'MediaPlayPause' }, | ||
'Semicolon': { 'keyCode': 186, 'code': 'Semicolon', 'shiftKey': ':', 'key': ';' }, | ||
'Equal': { 'keyCode': 187, 'code': 'Equal', 'shiftKey': '+', 'key': '=' }, | ||
'NumpadEqual': { 'keyCode': 187, 'code': 'NumpadEqual', 'key': '=', 'location': 3 }, | ||
'Comma': { 'keyCode': 188, 'code': 'Comma', 'shiftKey': '\<', 'key': ',' }, | ||
'Minus': { 'keyCode': 189, 'code': 'Minus', 'shiftKey': '_', 'key': '-' }, | ||
'Period': { 'keyCode': 190, 'code': 'Period', 'shiftKey': '>', 'key': '.' }, | ||
'Slash': { 'keyCode': 191, 'code': 'Slash', 'shiftKey': '?', 'key': '/' }, | ||
'Backquote': { 'keyCode': 192, 'code': 'Backquote', 'shiftKey': '~', 'key': '`' }, | ||
'BracketLeft': { 'keyCode': 219, 'code': 'BracketLeft', 'shiftKey': '{', 'key': '[' }, | ||
'Backslash': { 'keyCode': 220, 'code': 'Backslash', 'shiftKey': '|', 'key': '\\' }, | ||
'BracketRight': { 'keyCode': 221, 'code': 'BracketRight', 'shiftKey': '}', 'key': ']' }, | ||
'Quote': { 'keyCode': 222, 'code': 'Quote', 'shiftKey': '"', 'key': '\'' }, | ||
'AltGraph': { 'keyCode': 225, 'code': 'AltGraph', 'key': 'AltGraph' }, | ||
'Props': { 'keyCode': 247, 'code': 'Props', 'key': 'CrSel' }, | ||
'Cancel': { 'keyCode': 3, 'key': 'Cancel', 'code': 'Abort' }, | ||
'Clear': { 'keyCode': 12, 'key': 'Clear', 'code': 'Numpad5', 'location': 3 }, | ||
'Shift': { 'keyCode': 16, 'key': 'Shift', 'code': 'ShiftLeft', 'location': 1 }, | ||
'Control': { 'keyCode': 17, 'key': 'Control', 'code': 'ControlLeft', 'location': 1 }, | ||
'Alt': { 'keyCode': 18, 'key': 'Alt', 'code': 'AltLeft', 'location': 1 }, | ||
'Accept': { 'keyCode': 30, 'key': 'Accept' }, | ||
'ModeChange': { 'keyCode': 31, 'key': 'ModeChange' }, | ||
' ': { 'keyCode': 32, 'key': ' ', 'code': 'Space' }, | ||
'Print': { 'keyCode': 42, 'key': 'Print' }, | ||
'Execute': { 'keyCode': 43, 'key': 'Execute', 'code': 'Open' }, | ||
'\u0000': { 'keyCode': 46, 'key': '\u0000', 'code': 'NumpadDecimal', 'location': 3 }, | ||
'a': { 'keyCode': 65, 'key': 'a', 'code': 'KeyA' }, | ||
'b': { 'keyCode': 66, 'key': 'b', 'code': 'KeyB' }, | ||
'c': { 'keyCode': 67, 'key': 'c', 'code': 'KeyC' }, | ||
'd': { 'keyCode': 68, 'key': 'd', 'code': 'KeyD' }, | ||
'e': { 'keyCode': 69, 'key': 'e', 'code': 'KeyE' }, | ||
'f': { 'keyCode': 70, 'key': 'f', 'code': 'KeyF' }, | ||
'g': { 'keyCode': 71, 'key': 'g', 'code': 'KeyG' }, | ||
'h': { 'keyCode': 72, 'key': 'h', 'code': 'KeyH' }, | ||
'i': { 'keyCode': 73, 'key': 'i', 'code': 'KeyI' }, | ||
'j': { 'keyCode': 74, 'key': 'j', 'code': 'KeyJ' }, | ||
'k': { 'keyCode': 75, 'key': 'k', 'code': 'KeyK' }, | ||
'l': { 'keyCode': 76, 'key': 'l', 'code': 'KeyL' }, | ||
'm': { 'keyCode': 77, 'key': 'm', 'code': 'KeyM' }, | ||
'n': { 'keyCode': 78, 'key': 'n', 'code': 'KeyN' }, | ||
'o': { 'keyCode': 79, 'key': 'o', 'code': 'KeyO' }, | ||
'p': { 'keyCode': 80, 'key': 'p', 'code': 'KeyP' }, | ||
'q': { 'keyCode': 81, 'key': 'q', 'code': 'KeyQ' }, | ||
'r': { 'keyCode': 82, 'key': 'r', 'code': 'KeyR' }, | ||
's': { 'keyCode': 83, 'key': 's', 'code': 'KeyS' }, | ||
't': { 'keyCode': 84, 'key': 't', 'code': 'KeyT' }, | ||
'u': { 'keyCode': 85, 'key': 'u', 'code': 'KeyU' }, | ||
'v': { 'keyCode': 86, 'key': 'v', 'code': 'KeyV' }, | ||
'w': { 'keyCode': 87, 'key': 'w', 'code': 'KeyW' }, | ||
'x': { 'keyCode': 88, 'key': 'x', 'code': 'KeyX' }, | ||
'y': { 'keyCode': 89, 'key': 'y', 'code': 'KeyY' }, | ||
'z': { 'keyCode': 90, 'key': 'z', 'code': 'KeyZ' }, | ||
'Meta': { 'keyCode': 91, 'key': 'Meta', 'code': 'MetaLeft', 'location': 1 }, | ||
'*': { 'keyCode': 106, 'key': '*', 'code': 'NumpadMultiply', 'location': 3 }, | ||
'+': { 'keyCode': 107, 'key': '+', 'code': 'NumpadAdd', 'location': 3 }, | ||
'-': { 'keyCode': 109, 'key': '-', 'code': 'NumpadSubtract', 'location': 3 }, | ||
'/': { 'keyCode': 111, 'key': '/', 'code': 'NumpadDivide', 'location': 3 }, | ||
';': { 'keyCode': 186, 'key': ';', 'code': 'Semicolon' }, | ||
'=': { 'keyCode': 187, 'key': '=', 'code': 'Equal' }, | ||
',': { 'keyCode': 188, 'key': ',', 'code': 'Comma' }, | ||
'.': { 'keyCode': 190, 'key': '.', 'code': 'Period' }, | ||
'`': { 'keyCode': 192, 'key': '`', 'code': 'Backquote' }, | ||
'[': { 'keyCode': 219, 'key': '[', 'code': 'BracketLeft' }, | ||
'\\': { 'keyCode': 220, 'key': '\\', 'code': 'Backslash' }, | ||
']': { 'keyCode': 221, 'key': ']', 'code': 'BracketRight' }, | ||
'\'': { 'keyCode': 222, 'key': '\'', 'code': 'Quote' }, | ||
'Attn': { 'keyCode': 246, 'key': 'Attn' }, | ||
'CrSel': { 'keyCode': 247, 'key': 'CrSel', 'code': 'Props' }, | ||
'ExSel': { 'keyCode': 248, 'key': 'ExSel' }, | ||
'EraseEof': { 'keyCode': 249, 'key': 'EraseEof' }, | ||
'Play': { 'keyCode': 250, 'key': 'Play' }, | ||
'ZoomOut': { 'keyCode': 251, 'key': 'ZoomOut' }, | ||
')': { 'keyCode': 48, 'key': ')', 'code': 'Digit0' }, | ||
'!': { 'keyCode': 49, 'key': '!', 'code': 'Digit1' }, | ||
'@': { 'keyCode': 50, 'key': '@', 'code': 'Digit2' }, | ||
'#': { 'keyCode': 51, 'key': '#', 'code': 'Digit3' }, | ||
'$': { 'keyCode': 52, 'key': '$', 'code': 'Digit4' }, | ||
'%': { 'keyCode': 53, 'key': '%', 'code': 'Digit5' }, | ||
'^': { 'keyCode': 54, 'key': '^', 'code': 'Digit6' }, | ||
'&': { 'keyCode': 55, 'key': '&', 'code': 'Digit7' }, | ||
'(': { 'keyCode': 57, 'key': '\(', 'code': 'Digit9' }, | ||
'A': { 'keyCode': 65, 'key': 'A', 'code': 'KeyA' }, | ||
'B': { 'keyCode': 66, 'key': 'B', 'code': 'KeyB' }, | ||
'C': { 'keyCode': 67, 'key': 'C', 'code': 'KeyC' }, | ||
'D': { 'keyCode': 68, 'key': 'D', 'code': 'KeyD' }, | ||
'E': { 'keyCode': 69, 'key': 'E', 'code': 'KeyE' }, | ||
'F': { 'keyCode': 70, 'key': 'F', 'code': 'KeyF' }, | ||
'G': { 'keyCode': 71, 'key': 'G', 'code': 'KeyG' }, | ||
'H': { 'keyCode': 72, 'key': 'H', 'code': 'KeyH' }, | ||
'I': { 'keyCode': 73, 'key': 'I', 'code': 'KeyI' }, | ||
'J': { 'keyCode': 74, 'key': 'J', 'code': 'KeyJ' }, | ||
'K': { 'keyCode': 75, 'key': 'K', 'code': 'KeyK' }, | ||
'L': { 'keyCode': 76, 'key': 'L', 'code': 'KeyL' }, | ||
'M': { 'keyCode': 77, 'key': 'M', 'code': 'KeyM' }, | ||
'N': { 'keyCode': 78, 'key': 'N', 'code': 'KeyN' }, | ||
'O': { 'keyCode': 79, 'key': 'O', 'code': 'KeyO' }, | ||
'P': { 'keyCode': 80, 'key': 'P', 'code': 'KeyP' }, | ||
'Q': { 'keyCode': 81, 'key': 'Q', 'code': 'KeyQ' }, | ||
'R': { 'keyCode': 82, 'key': 'R', 'code': 'KeyR' }, | ||
'S': { 'keyCode': 83, 'key': 'S', 'code': 'KeyS' }, | ||
'T': { 'keyCode': 84, 'key': 'T', 'code': 'KeyT' }, | ||
'U': { 'keyCode': 85, 'key': 'U', 'code': 'KeyU' }, | ||
'V': { 'keyCode': 86, 'key': 'V', 'code': 'KeyV' }, | ||
'W': { 'keyCode': 87, 'key': 'W', 'code': 'KeyW' }, | ||
'X': { 'keyCode': 88, 'key': 'X', 'code': 'KeyX' }, | ||
'Y': { 'keyCode': 89, 'key': 'Y', 'code': 'KeyY' }, | ||
'Z': { 'keyCode': 90, 'key': 'Z', 'code': 'KeyZ' }, | ||
':': { 'keyCode': 186, 'key': ':', 'code': 'Semicolon' }, | ||
'<': { 'keyCode': 188, 'key': '\<', 'code': 'Comma' }, | ||
'_': { 'keyCode': 189, 'key': '_', 'code': 'Minus' }, | ||
'>': { 'keyCode': 190, 'key': '>', 'code': 'Period' }, | ||
'?': { 'keyCode': 191, 'key': '?', 'code': 'Slash' }, | ||
'~': { 'keyCode': 192, 'key': '~', 'code': 'Backquote' }, | ||
'{': { 'keyCode': 219, 'key': '{', 'code': 'BracketLeft' }, | ||
'|': { 'keyCode': 220, 'key': '|', 'code': 'Backslash' }, | ||
'}': { 'keyCode': 221, 'key': '}', 'code': 'BracketRight' }, | ||
'"': { 'keyCode': 222, 'key': '"', 'code': 'Quote' }, | ||
'SoftLeft': { 'key': 'SoftLeft', 'code': 'SoftLeft', 'location': 4 }, | ||
'SoftRight': { 'key': 'SoftRight', 'code': 'SoftRight', 'location': 4 }, | ||
'Camera': { 'keyCode': 44, 'key': 'Camera', 'code': 'Camera', 'location': 4 }, | ||
'Call': { 'key': 'Call', 'code': 'Call', 'location': 4 }, | ||
'EndCall': { 'keyCode': 95, 'key': 'EndCall', 'code': 'EndCall', 'location': 4 }, | ||
'VolumeDown': { 'keyCode': 182, 'key': 'VolumeDown', 'code': 'VolumeDown', 'location': 4 }, | ||
'VolumeUp': { 'keyCode': 183, 'key': 'VolumeUp', 'code': 'VolumeUp', 'location': 4 }, | ||
}; |
@@ -16,4 +16,3 @@ /** | ||
*/ | ||
const WebSocket = require('ws'); | ||
const NodeWebSocket = require('ws'); | ||
/** | ||
@@ -23,48 +22,44 @@ * @implements {!Puppeteer.ConnectionTransport} | ||
class WebSocketTransport { | ||
/** | ||
* @param {string} url | ||
* @return {!Promise<!WebSocketTransport>} | ||
*/ | ||
static create(url) { | ||
return new Promise((resolve, reject) => { | ||
const ws = new WebSocket(url, [], { | ||
perMessageDeflate: false, | ||
maxPayload: 256 * 1024 * 1024, // 256Mb | ||
}); | ||
ws.addEventListener('open', () => resolve(new WebSocketTransport(ws))); | ||
ws.addEventListener('error', reject); | ||
}); | ||
} | ||
/** | ||
* @param {!WebSocket} ws | ||
*/ | ||
constructor(ws) { | ||
this._ws = ws; | ||
this._ws.addEventListener('message', event => { | ||
if (this.onmessage) | ||
this.onmessage.call(null, event.data); | ||
}); | ||
this._ws.addEventListener('close', event => { | ||
if (this.onclose) | ||
this.onclose.call(null); | ||
}); | ||
// Silently ignore all errors - we don't know what to do with them. | ||
this._ws.addEventListener('error', () => {}); | ||
this.onmessage = null; | ||
this.onclose = null; | ||
} | ||
/** | ||
* @param {string} message | ||
*/ | ||
send(message) { | ||
this._ws.send(message); | ||
} | ||
close() { | ||
this._ws.close(); | ||
} | ||
/** | ||
* @param {string} url | ||
* @return {!Promise<!WebSocketTransport>} | ||
*/ | ||
static create(url) { | ||
return new Promise((resolve, reject) => { | ||
const ws = new NodeWebSocket(url, [], { | ||
perMessageDeflate: false, | ||
maxPayload: 256 * 1024 * 1024, | ||
}); | ||
ws.addEventListener('open', () => resolve(new WebSocketTransport(ws))); | ||
ws.addEventListener('error', reject); | ||
}); | ||
} | ||
/** | ||
* @param {!NodeWebSocket} ws | ||
*/ | ||
constructor(ws) { | ||
this._ws = ws; | ||
this._ws.addEventListener('message', event => { | ||
if (this.onmessage) | ||
this.onmessage.call(null, event.data); | ||
}); | ||
this._ws.addEventListener('close', event => { | ||
if (this.onclose) | ||
this.onclose.call(null); | ||
}); | ||
// Silently ignore all errors - we don't know what to do with them. | ||
this._ws.addEventListener('error', () => { }); | ||
this.onmessage = null; | ||
this.onclose = null; | ||
} | ||
/** | ||
* @param {string} message | ||
*/ | ||
send(message) { | ||
this._ws.send(message); | ||
} | ||
close() { | ||
this._ws.close(); | ||
} | ||
} | ||
module.exports = WebSocketTransport; |
@@ -17,65 +17,58 @@ /** | ||
const EventEmitter = require('events'); | ||
const {debugError} = require('./helper'); | ||
const {ExecutionContext} = require('./ExecutionContext'); | ||
const {JSHandle} = require('./JSHandle'); | ||
const { debugError } = require('./helper'); | ||
const { ExecutionContext } = require('./ExecutionContext'); | ||
const { JSHandle } = require('./JSHandle'); | ||
class Worker extends EventEmitter { | ||
/** | ||
* @param {Puppeteer.CDPSession} client | ||
* @param {string} url | ||
* @param {function(string, !Array<!JSHandle>, Protocol.Runtime.StackTrace=):void} consoleAPICalled | ||
* @param {function(!Protocol.Runtime.ExceptionDetails):void} exceptionThrown | ||
*/ | ||
constructor(client, url, consoleAPICalled, exceptionThrown) { | ||
super(); | ||
this._client = client; | ||
this._url = url; | ||
this._executionContextPromise = new Promise(x => this._executionContextCallback = x); | ||
/** @type {function(!Protocol.Runtime.RemoteObject):!JSHandle} */ | ||
let jsHandleFactory; | ||
this._client.once('Runtime.executionContextCreated', async event => { | ||
jsHandleFactory = remoteObject => new JSHandle(executionContext, client, remoteObject); | ||
const executionContext = new ExecutionContext(client, event.context, null); | ||
this._executionContextCallback(executionContext); | ||
}); | ||
// This might fail if the target is closed before we recieve all execution contexts. | ||
this._client.send('Runtime.enable', {}).catch(debugError); | ||
this._client.on('Runtime.consoleAPICalled', event => consoleAPICalled(event.type, event.args.map(jsHandleFactory), event.stackTrace)); | ||
this._client.on('Runtime.exceptionThrown', exception => exceptionThrown(exception.exceptionDetails)); | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
url() { | ||
return this._url; | ||
} | ||
/** | ||
* @return {!Promise<ExecutionContext>} | ||
*/ | ||
async executionContext() { | ||
return this._executionContextPromise; | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<*>} | ||
*/ | ||
async evaluate(pageFunction, ...args) { | ||
return (await this._executionContextPromise).evaluate(pageFunction, ...args); | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<!JSHandle>} | ||
*/ | ||
async evaluateHandle(pageFunction, ...args) { | ||
return (await this._executionContextPromise).evaluateHandle(pageFunction, ...args); | ||
} | ||
/** | ||
* @param {Puppeteer.CDPSession} client | ||
* @param {string} url | ||
* @param {function(string, !Array<!JSHandle>, Protocol.Runtime.StackTrace=):void} consoleAPICalled | ||
* @param {function(!Protocol.Runtime.ExceptionDetails):void} exceptionThrown | ||
*/ | ||
constructor(client, url, consoleAPICalled, exceptionThrown) { | ||
super(); | ||
this._client = client; | ||
this._url = url; | ||
this._executionContextPromise = new Promise(x => this._executionContextCallback = x); | ||
/** @type {function(!Protocol.Runtime.RemoteObject):!JSHandle} */ | ||
let jsHandleFactory; | ||
this._client.once('Runtime.executionContextCreated', async (event) => { | ||
jsHandleFactory = remoteObject => new JSHandle(executionContext, client, remoteObject); | ||
const executionContext = new ExecutionContext(client, event.context, null); | ||
this._executionContextCallback(executionContext); | ||
}); | ||
// This might fail if the target is closed before we recieve all execution contexts. | ||
this._client.send('Runtime.enable', {}).catch(debugError); | ||
this._client.on('Runtime.consoleAPICalled', event => consoleAPICalled(event.type, event.args.map(jsHandleFactory), event.stackTrace)); | ||
this._client.on('Runtime.exceptionThrown', exception => exceptionThrown(exception.exceptionDetails)); | ||
} | ||
/** | ||
* @return {string} | ||
*/ | ||
url() { | ||
return this._url; | ||
} | ||
/** | ||
* @return {!Promise<ExecutionContext>} | ||
*/ | ||
async executionContext() { | ||
return this._executionContextPromise; | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<*>} | ||
*/ | ||
async evaluate(pageFunction, ...args) { | ||
return (await this._executionContextPromise).evaluate(pageFunction, ...args); | ||
} | ||
/** | ||
* @param {Function|string} pageFunction | ||
* @param {!Array<*>} args | ||
* @return {!Promise<!JSHandle>} | ||
*/ | ||
async evaluateHandle(pageFunction, ...args) { | ||
return (await this._executionContextPromise).evaluateHandle(pageFunction, ...args); | ||
} | ||
} | ||
module.exports = {Worker}; | ||
module.exports = { Worker }; |
{ | ||
"name": "puppeteer-core", | ||
"version": "2.1.1", | ||
"version": "3.0.0", | ||
"description": "A high-level API to control headless Chrome over the DevTools Protocol", | ||
@@ -8,22 +8,25 @@ "main": "index.js", | ||
"engines": { | ||
"node": ">=8.16.0" | ||
"node": ">=10.18.1" | ||
}, | ||
"puppeteer": { | ||
"chromium_revision": "722234" | ||
"chromium_revision": "737027", | ||
"firefox_revision": "latest" | ||
}, | ||
"scripts": { | ||
"unit": "node test/test.js", | ||
"fjunit": "PUPPETEER_PRODUCT=juggler node test/test.js", | ||
"funit": "PUPPETEER_PRODUCT=firefox node test/test.js", | ||
"unit": "mocha --config mocha-config/puppeteer-unit-tests.js", | ||
"coverage": "cross-env COVERAGE=1 npm run unit", | ||
"funit": "PUPPETEER_PRODUCT=firefox npm run unit", | ||
"debug-unit": "node --inspect-brk test/test.js", | ||
"test-doclint": "node utils/doclint/check_public_api/test/test.js && node utils/doclint/preprocessor/test.js", | ||
"test": "npm run lint --silent && npm run coverage && npm run test-doclint && npm run test-types && node utils/testrunner/test/test.js", | ||
"lint": "([ \"$CI\" = true ] && eslint --quiet -f codeframe . || eslint .) && npm run tsc && npm run doc", | ||
"test-doclint": "mocha --config mocha-config/doclint-tests.js", | ||
"test": "npm run tsc && npm run lint --silent && npm run coverage && npm run test-doclint && npm run test-types", | ||
"prepublishOnly": "npm run tsc", | ||
"dev-install": "npm run tsc && node install.js", | ||
"lint": "([ \"$CI\" = true ] && eslint --ext js --ext ts --quiet -f codeframe . || eslint --ext js --ext ts .) && npm run tsc && npm run doc", | ||
"doc": "node utils/doclint/cli.js", | ||
"coverage": "cross-env COVERAGE=true npm run unit", | ||
"tsc": "tsc -p .", | ||
"tsc": "tsc --version && tsc -p . && cp src/protocol.d.ts lib/ && cp src/externs.d.ts lib/", | ||
"apply-next-version": "node utils/apply_next_version.js", | ||
"bundle": "npx browserify -r ./index.js:puppeteer -o utils/browser/puppeteer-web.js", | ||
"test-types": "node utils/doclint/generate_types && npx -p typescript@2.1 tsc -p utils/doclint/generate_types/test/", | ||
"unit-bundle": "node utils/browser/test.js" | ||
"bundle": "npm run tsc && npx browserify -r ./index.js:puppeteer -o utils/browser/puppeteer-web.js", | ||
"test-types": "node utils/doclint/generate_types && tsc --version && tsc -p utils/doclint/generate_types/test/", | ||
"unit-bundle": "mocha --config mocha-config/browser-bundle-tests.js", | ||
"update-protocol-d-ts": "node utils/protocol-types-generator" | ||
}, | ||
@@ -35,3 +38,3 @@ "author": "The Chromium Authors", | ||
"debug": "^4.1.0", | ||
"extract-zip": "^1.6.6", | ||
"extract-zip": "^2.0.0", | ||
"https-proxy-agent": "^4.0.0", | ||
@@ -42,18 +45,25 @@ "mime": "^2.0.3", | ||
"proxy-from-env": "^1.0.0", | ||
"rimraf": "^2.6.1", | ||
"ws": "^6.1.0" | ||
"rimraf": "^3.0.2", | ||
"tar-fs": "^2.0.0", | ||
"unbzip2-stream": "^1.3.3", | ||
"ws": "^7.2.3" | ||
}, | ||
"devDependencies": { | ||
"@types/debug": "0.0.31", | ||
"@types/extract-zip": "^1.6.2", | ||
"@types/mime": "^2.0.0", | ||
"@types/node": "^8.10.34", | ||
"@types/node": "^10.17.14", | ||
"@types/rimraf": "^2.0.2", | ||
"@types/ws": "^6.0.1", | ||
"@types/tar-fs": "^1.16.2", | ||
"@types/ws": "^7.2.4", | ||
"@typescript-eslint/eslint-plugin": "^2.28.0", | ||
"@typescript-eslint/parser": "^2.28.0", | ||
"commonmark": "^0.28.1", | ||
"cross-env": "^5.0.5", | ||
"eslint": "^5.15.1", | ||
"eslint": "^6.8.0", | ||
"eslint-plugin-mocha": "^6.3.0", | ||
"esprima": "^4.0.0", | ||
"expect": "^25.2.7", | ||
"jpeg-js": "^0.3.4", | ||
"minimist": "^1.2.0", | ||
"mocha": "^7.1.1", | ||
"ncp": "^2.0.0", | ||
@@ -63,3 +73,3 @@ "pixelmatch": "^4.0.2", | ||
"text-diff": "^1.0.1", | ||
"typescript": "3.2.2" | ||
"typescript": "3.8.3" | ||
}, | ||
@@ -66,0 +76,0 @@ "browser": { |
# Puppeteer | ||
<!-- [START badges] --> | ||
[![Linux Build Status](https://img.shields.io/travis/com/puppeteer/puppeteer/master.svg)](https://travis-ci.com/puppeteer/puppeteer) [![Windows Build Status](https://img.shields.io/appveyor/ci/mathiasbynens/puppeteer/master.svg?logo=appveyor)](https://ci.appveyor.com/project/mathiasbynens/puppeteer/branch/master) [![Build Status](https://api.cirrus-ci.com/github/puppeteer/puppeteer.svg)](https://cirrus-ci.com/github/puppeteer/puppeteer) [![npm puppeteer package](https://img.shields.io/npm/v/puppeteer.svg)](https://npmjs.org/package/puppeteer) [![Issue resolution status](https://isitmaintained.com/badge/resolution/puppeteer/puppeteer.svg)](https://github.com/puppeteer/puppeteer/issues) | ||
[![Build status](https://img.shields.io/travis/com/puppeteer/puppeteer/master.svg)](https://travis-ci.com/puppeteer/puppeteer) [![npm puppeteer package](https://img.shields.io/npm/v/puppeteer.svg)](https://npmjs.org/package/puppeteer) [![Issue resolution status](https://isitmaintained.com/badge/resolution/puppeteer/puppeteer.svg)](https://github.com/puppeteer/puppeteer/issues) | ||
<!-- [END badges] --> | ||
@@ -9,3 +9,3 @@ | ||
###### [API](https://github.com/puppeteer/puppeteer/blob/v2.1.1/docs/api.md) | [FAQ](#faq) | [Contributing](https://github.com/puppeteer/puppeteer/blob/master/CONTRIBUTING.md) | [Troubleshooting](https://github.com/puppeteer/puppeteer/blob/master/docs/troubleshooting.md) | ||
###### [API](https://github.com/puppeteer/puppeteer/blob/v3.0.0/docs/api.md) | [FAQ](#faq) | [Contributing](https://github.com/puppeteer/puppeteer/blob/master/CONTRIBUTING.md) | [Troubleshooting](https://github.com/puppeteer/puppeteer/blob/master/docs/troubleshooting.md) | ||
@@ -41,3 +41,3 @@ > Puppeteer is a Node library which provides a high-level API to control Chrome or Chromium over the [DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/). Puppeteer runs [headless](https://developers.google.com/web/updates/2017/04/headless-chrome) by default, but can be configured to run full (non-headless) Chrome or Chromium. | ||
Note: When you install Puppeteer, it downloads a recent version of Chromium (~170MB Mac, ~282MB Linux, ~280MB Win) that is guaranteed to work with the API. To skip the download, see [Environment variables](https://github.com/puppeteer/puppeteer/blob/v2.1.1/docs/api.md#environment-variables). | ||
Note: When you install Puppeteer, it downloads a recent version of Chromium (~170MB Mac, ~282MB Linux, ~280MB Win) that is guaranteed to work with the API. To skip the download, or to download a different browser, see [Environment variables](https://github.com/puppeteer/puppeteer/blob/v3.0.0/docs/api.md#environment-variables). | ||
@@ -48,3 +48,3 @@ | ||
Since version 1.7.0 we publish the [`puppeteer-core`](https://www.npmjs.com/package/puppeteer-core) package, | ||
a version of Puppeteer that doesn't download Chromium by default. | ||
a version of Puppeteer that doesn't download any browser by default. | ||
@@ -65,7 +65,7 @@ ```bash | ||
Note: Prior to v1.18.1, Puppeteer required at least Node v6.4.0. All subsequent versions rely on | ||
Node 8.9.0+. All examples below use async/await which is only supported in Node v7.6.0 or greater. | ||
Note: Prior to v1.18.1, Puppeteer required at least Node v6.4.0. Versions from v1.18.1 to v2.1.0 rely on | ||
Node 8.9.0+. Starting from v3.0.0 Puppeteer starts to rely on Node 10.18.1+. All examples below use async/await which is only supported in Node v7.6.0 or greater. | ||
Puppeteer will be familiar to people using other browser testing frameworks. You create an instance | ||
of `Browser`, open pages, and then manipulate them with [Puppeteer's API](https://github.com/puppeteer/puppeteer/blob/v2.1.1/docs/api.md#). | ||
of `Browser`, open pages, and then manipulate them with [Puppeteer's API](https://github.com/puppeteer/puppeteer/blob/v3.0.0/docs/api.md#). | ||
@@ -95,3 +95,3 @@ **Example** - navigating to https://example.com and saving a screenshot as *example.png*: | ||
Puppeteer sets an initial page size to 800×600px, which defines the screenshot size. The page size can be customized with [`Page.setViewport()`](https://github.com/puppeteer/puppeteer/blob/v2.1.1/docs/api.md#pagesetviewportviewport). | ||
Puppeteer sets an initial page size to 800×600px, which defines the screenshot size. The page size can be customized with [`Page.setViewport()`](https://github.com/puppeteer/puppeteer/blob/v3.0.0/docs/api.md#pagesetviewportviewport). | ||
@@ -121,3 +121,3 @@ **Example** - create a PDF. | ||
See [`Page.pdf()`](https://github.com/puppeteer/puppeteer/blob/v2.1.1/docs/api.md#pagepdfoptions) for more information about creating pdfs. | ||
See [`Page.pdf()`](https://github.com/puppeteer/puppeteer/blob/v3.0.0/docs/api.md#pagepdfoptions) for more information about creating pdfs. | ||
@@ -157,3 +157,3 @@ **Example** - evaluate script in the context of the page | ||
See [`Page.evaluate()`](https://github.com/puppeteer/puppeteer/blob/v2.1.1/docs/api.md#pageevaluatepagefunction-args) for more information on `evaluate` and related methods like `evaluateOnNewDocument` and `exposeFunction`. | ||
See [`Page.evaluate()`](https://github.com/puppeteer/puppeteer/blob/v3.0.0/docs/api.md#pageevaluatepagefunction-args) for more information on `evaluate` and related methods like `evaluateOnNewDocument` and `exposeFunction`. | ||
@@ -167,3 +167,3 @@ <!-- [END getstarted] --> | ||
Puppeteer launches Chromium in [headless mode](https://developers.google.com/web/updates/2017/04/headless-chrome). To launch a full version of Chromium, set the [`headless` option](https://github.com/puppeteer/puppeteer/blob/v2.1.1/docs/api.md#puppeteerlaunchoptions) when launching a browser: | ||
Puppeteer launches Chromium in [headless mode](https://developers.google.com/web/updates/2017/04/headless-chrome). To launch a full version of Chromium, set the [`headless` option](https://github.com/puppeteer/puppeteer/blob/v3.0.0/docs/api.md#puppeteerlaunchoptions) when launching a browser: | ||
@@ -184,3 +184,3 @@ ```js | ||
See [`Puppeteer.launch()`](https://github.com/puppeteer/puppeteer/blob/v2.1.1/docs/api.md#puppeteerlaunchoptions) for more information. | ||
You can also use Puppeteer with Firefox Nightly (experimental support). See [`Puppeteer.launch()`](https://github.com/puppeteer/puppeteer/blob/v3.0.0/docs/api.md#puppeteerlaunchoptions) for more information. | ||
@@ -191,3 +191,3 @@ See [`this article`](https://www.howtogeek.com/202825/what%E2%80%99s-the-difference-between-chromium-and-chrome/) for a description of the differences between Chromium and Chrome. [`This article`](https://chromium.googlesource.com/chromium/src/+/master/docs/chromium_browser_vs_google_chrome.md) describes some differences for Linux users. | ||
Puppeteer creates its own Chromium user profile which it **cleans up on every run**. | ||
Puppeteer creates its own browser user profile which it **cleans up on every run**. | ||
@@ -198,3 +198,3 @@ <!-- [END runtimesettings] --> | ||
- [API Documentation](https://github.com/puppeteer/puppeteer/blob/v2.1.1/docs/api.md) | ||
- [API Documentation](https://github.com/puppeteer/puppeteer/blob/v3.0.0/docs/api.md) | ||
- [Examples](https://github.com/puppeteer/puppeteer/tree/master/examples/) | ||
@@ -312,11 +312,9 @@ - [Community list of Puppeteer resources](https://github.com/transitive-bullshit/awesome-puppeteer) | ||
Historically, Puppeteer supported Firefox indirectly through puppeteer-firefox, which relied on a custom, patched version of Firefox. This approach was also known as “Juggler”. | ||
After discussions with Mozilla, we collectively concluded that relying on custom patches was infeasible. | ||
Since then, we have been collaborating with Mozilla on supporting Puppeteer on “stock” Firefox. | ||
From Puppeteer v2.1.0 onwards, as an experimental feature, you can specify [`puppeteer.launch({product: 'firefox'})`](https://github.com/puppeteer/puppeteer/blob/v2.1.1/docs/api.md#puppeteerlaunchoptions) to run your Puppeteer scripts in Firefox, without any additional custom patches. | ||
Official Firefox support is currently experimental. The ongoing collaboration with Mozilla aims to support common end-to-end testing use cases, for which developers expect cross-browser coverage. The Puppeteer team needs input from users to stabilize Firefox support and to bring missing APIs to our attention. | ||
We will continue collaborating with other browser vendors to bring Puppeteer support to browsers such as Safari. | ||
From Puppeteer v2.1.0 onwards you can specify [`puppeteer.launch({product: 'firefox'})`](https://github.com/puppeteer/puppeteer/blob/v3.0.0/docs/api.md#puppeteerlaunchoptions) to run your Puppeteer scripts in Firefox Nightly, without any additional custom patches. While [an older experiment](https://www.npmjs.com/package/puppeteer-firefox) required a patched version of Firefox, [the current approach](https://wiki.mozilla.org/Remote) works with “stock” Firefox. | ||
We will continue to collaborate with other browser vendors to bring Puppeteer support to browsers such as Safari. | ||
This effort includes exploration of a standard for executing cross-browser commands (instead of relying on the non-standard DevTools Protocol used by Chrome). | ||
#### Q: What are Puppeteer’s goals and principles? | ||
@@ -371,2 +369,14 @@ | ||
#### Q: Which Firefox version does Puppeteer use? | ||
Since Firefox support is experimental, Puppeteer downloads the latest [Firefox Nightly](https://wiki.mozilla.org/Nightly) when the `PUPPETEER_PRODUCT` environment variable is set to `firefox`. That's also why the value of `firefox_revision` in [package.json](https://github.com/puppeteer/puppeteer/blob/master/package.json) is `latest` -- Puppeteer isn't tied to a particular Firefox version. | ||
To fetch Firefox Nightly as part of Puppeteer installation: | ||
```bash | ||
PUPPETEER_PRODUCT=firefox npm i puppeteer | ||
# or "yarn add puppeteer" | ||
``` | ||
#### Q: What’s considered a “Navigation”? | ||
@@ -402,3 +412,3 @@ | ||
* Puppeteer is bundled with Chromium — not Chrome — and so by default, it inherits all of [Chromium's media-related limitations](https://www.chromium.org/audio-video). This means that Puppeteer does not support licensed formats such as AAC or H.264. (However, it is possible to force Puppeteer to use a separately-installed version Chrome instead of Chromium via the [`executablePath` option to `puppeteer.launch`](https://github.com/puppeteer/puppeteer/blob/v2.1.1/docs/api.md#puppeteerlaunchoptions). You should only use this configuration if you need an official release of Chrome that supports these media formats.) | ||
* Puppeteer is bundled with Chromium — not Chrome — and so by default, it inherits all of [Chromium's media-related limitations](https://www.chromium.org/audio-video). This means that Puppeteer does not support licensed formats such as AAC or H.264. (However, it is possible to force Puppeteer to use a separately-installed version Chrome instead of Chromium via the [`executablePath` option to `puppeteer.launch`](https://github.com/puppeteer/puppeteer/blob/v3.0.0/docs/api.md#puppeteerlaunchoptions). You should only use this configuration if you need an official release of Chrome that supports these media formats.) | ||
* Since Puppeteer (in all configurations) controls a desktop version of Chromium/Chrome, features that are only supported by the mobile version of Chrome are not supported. This means that Puppeteer [does not support HTTP Live Streaming (HLS)](https://caniuse.com/#feat=http-live-streaming). | ||
@@ -405,0 +415,0 @@ |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 5 instances in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
Found 1 instance in 1 package
Uses eval
Supply chain riskPackage uses dynamic code execution (e.g., eval()), which is a dangerous practice. This can prevent the code from running in certain environments and increases the risk that the code may contain exploits or malicious behavior.
Found 1 instance in 1 package
933464
45
24597
426
12
22
49
14
+ Addedtar-fs@^2.0.0
+ Addedunbzip2-stream@^1.3.3
+ Added@types/node@22.7.4(transitive)
+ Added@types/yauzl@2.10.3(transitive)
+ Addedbase64-js@1.5.1(transitive)
+ Addedbl@4.1.0(transitive)
+ Addedbuffer@5.7.1(transitive)
+ Addedchownr@1.1.4(transitive)
+ Addedend-of-stream@1.4.4(transitive)
+ Addedextract-zip@2.0.1(transitive)
+ Addedfs-constants@1.0.0(transitive)
+ Addedget-stream@5.2.0(transitive)
+ Addedieee754@1.2.1(transitive)
+ Addedmkdirp-classic@0.5.3(transitive)
+ Addedpump@3.0.2(transitive)
+ Addedreadable-stream@3.6.2(transitive)
+ Addedrimraf@3.0.2(transitive)
+ Addedsafe-buffer@5.2.1(transitive)
+ Addedstring_decoder@1.3.0(transitive)
+ Addedtar-fs@2.1.1(transitive)
+ Addedtar-stream@2.2.0(transitive)
+ Addedthrough@2.3.8(transitive)
+ Addedunbzip2-stream@1.4.3(transitive)
+ Addedundici-types@6.19.8(transitive)
+ Addedws@7.5.10(transitive)
- Removedasync-limiter@1.0.1(transitive)
- Removedbuffer-from@1.1.2(transitive)
- Removedconcat-stream@1.6.2(transitive)
- Removedcore-util-is@1.0.3(transitive)
- Removeddebug@2.6.9(transitive)
- Removedextract-zip@1.7.0(transitive)
- Removedisarray@1.0.0(transitive)
- Removedminimist@1.2.8(transitive)
- Removedmkdirp@0.5.6(transitive)
- Removedms@2.0.0(transitive)
- Removedprocess-nextick-args@2.0.1(transitive)
- Removedreadable-stream@2.3.8(transitive)
- Removedrimraf@2.7.1(transitive)
- Removedsafe-buffer@5.1.2(transitive)
- Removedstring_decoder@1.1.1(transitive)
- Removedtypedarray@0.0.6(transitive)
- Removedws@6.2.3(transitive)
Updatedextract-zip@^2.0.0
Updatedrimraf@^3.0.2
Updatedws@^7.2.3