mirror of
https://github.com/microsoft/playwright.git
synced 2024-11-28 01:15:10 +03:00
Initial commit
This commit is contained in:
parent
615c2bc21d
commit
9ba375c063
19
.appveyor.yml
Normal file
19
.appveyor.yml
Normal file
@ -0,0 +1,19 @@
|
||||
environment:
|
||||
matrix:
|
||||
- nodejs_version: "8.16.0"
|
||||
FLAKINESS_DASHBOARD_NAME: Appveyor Chromium (Win + node8)
|
||||
FLAKINESS_DASHBOARD_PASSWORD:
|
||||
secure: g66jP+j6C+hkXLutBV9fdxB5fRJgcQQzy93SgQzXUmcCl/RjkJwnzyHvX0xfCVnv
|
||||
|
||||
build: off
|
||||
|
||||
install:
|
||||
- ps: $env:FLAKINESS_DASHBOARD_BUILD_URL="https://ci.appveyor.com/project/aslushnikov/playwright/builds/$env:APPVEYOR_BUILD_ID/job/$env:APPVEYOR_JOB_ID"
|
||||
- ps: Install-Product node $env:nodejs_version
|
||||
- npm install
|
||||
- if "%nodejs_version%" == "8.16.0" (
|
||||
npm run lint &&
|
||||
npm run coverage &&
|
||||
npm run test-doclint &&
|
||||
npm run test-types
|
||||
)
|
17
.ci/node10/Dockerfile.linux
Normal file
17
.ci/node10/Dockerfile.linux
Normal file
@ -0,0 +1,17 @@
|
||||
FROM node:10
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get -y install xvfb gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 \
|
||||
libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 \
|
||||
libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 \
|
||||
libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 \
|
||||
libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Add user so we don't need --no-sandbox.
|
||||
RUN groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \
|
||||
&& mkdir -p /home/pptruser/Downloads \
|
||||
&& chown -R pptruser:pptruser /home/pptruser
|
||||
|
||||
# Run everything after as non-privileged user.
|
||||
USER pptruser
|
17
.ci/node12/Dockerfile.linux
Normal file
17
.ci/node12/Dockerfile.linux
Normal file
@ -0,0 +1,17 @@
|
||||
FROM node:12
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get -y install xvfb gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 \
|
||||
libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 \
|
||||
libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 \
|
||||
libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 \
|
||||
libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Add user so we don't need --no-sandbox.
|
||||
RUN groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \
|
||||
&& mkdir -p /home/pptruser/Downloads \
|
||||
&& chown -R pptruser:pptruser /home/pptruser
|
||||
|
||||
# Run everything after as non-privileged user.
|
||||
USER pptruser
|
17
.ci/node8/Dockerfile.linux
Normal file
17
.ci/node8/Dockerfile.linux
Normal file
@ -0,0 +1,17 @@
|
||||
FROM node:8.11.3
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get -y install xvfb gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 \
|
||||
libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 \
|
||||
libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 \
|
||||
libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 \
|
||||
libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Add user so we don't need --no-sandbox.
|
||||
RUN groupadd -r pptruser && useradd -r -g pptruser -G audio,video pptruser \
|
||||
&& mkdir -p /home/pptruser/Downloads \
|
||||
&& chown -R pptruser:pptruser /home/pptruser
|
||||
|
||||
# Run everything after as non-privileged user.
|
||||
USER pptruser
|
47
.cirrus.yml
Normal file
47
.cirrus.yml
Normal file
@ -0,0 +1,47 @@
|
||||
env:
|
||||
DISPLAY: :99.0
|
||||
FLAKINESS_DASHBOARD_PASSWORD: ENCRYPTED[b3e207db5d153b543f219d3c3b9123d8321834b783b9e45ac7d380e026ab3a56398bde51b521ac5859e7e45cb95d0992]
|
||||
FLAKINESS_DASHBOARD_NAME: Cirrus ${CIRRUS_TASK_NAME}
|
||||
FLAKINESS_DASHBOARD_BUILD_URL: https://cirrus-ci.com/task/${CIRRUS_TASK_ID}
|
||||
|
||||
task:
|
||||
matrix:
|
||||
- name: Chromium (node8 + linux)
|
||||
container:
|
||||
dockerfile: .ci/node8/Dockerfile.linux
|
||||
- name: Chromium (node10 + linux)
|
||||
container:
|
||||
dockerfile: .ci/node10/Dockerfile.linux
|
||||
- name: Chromium (node12 + linux)
|
||||
container:
|
||||
dockerfile: .ci/node12/Dockerfile.linux
|
||||
xvfb_start_background_script: Xvfb :99 -ac -screen 0 1024x768x24
|
||||
install_script: npm install --unsafe-perm
|
||||
lint_script: npm run lint
|
||||
coverage_script: npm run coverage
|
||||
test_doclint_script: npm run test-doclint
|
||||
test_types_script: npm run test-types
|
||||
|
||||
task:
|
||||
matrix:
|
||||
- name: Firefox (node8 + linux)
|
||||
container:
|
||||
dockerfile: .ci/node8/Dockerfile.linux
|
||||
xvfb_start_background_script: Xvfb :99 -ac -screen 0 1024x768x24
|
||||
install_script: npm install --unsafe-perm
|
||||
test_script: npm run funit
|
||||
|
||||
task:
|
||||
osx_instance:
|
||||
image: high-sierra-base
|
||||
name: Chromium (node8 + macOS)
|
||||
env:
|
||||
HOMEBREW_NO_AUTO_UPDATE: 1
|
||||
node_install_script:
|
||||
- brew install node@8
|
||||
- brew link --force node@8
|
||||
install_script: npm install --unsafe-perm
|
||||
lint_script: npm run lint
|
||||
coverage_script: npm run coverage
|
||||
test_doclint_script: npm run test-doclint
|
||||
test_types_script: npm run test-types
|
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@ -0,0 +1,9 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
12
.eslintignore
Normal file
12
.eslintignore
Normal file
@ -0,0 +1,12 @@
|
||||
test/assets/modernizr.js
|
||||
third_party/*
|
||||
utils/browser/playwright-web.js
|
||||
utils/doclint/check_public_api/test/
|
||||
utils/testrunner/examples/
|
||||
node6/*
|
||||
node6-test/*
|
||||
node6-testrunner/*
|
||||
lib/
|
||||
*.js
|
||||
src/chromium/protocol.d.ts
|
||||
src/webkit/protocol.d.ts
|
107
.eslintrc.js
Normal file
107
.eslintrc.js
Normal file
@ -0,0 +1,107 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['@typescript-eslint'],
|
||||
parserOptions: {
|
||||
project: './tsconfig.json',
|
||||
ecmaVersion: 9,
|
||||
sourceType: 'module',
|
||||
},
|
||||
|
||||
/**
|
||||
* ESLint rules
|
||||
*
|
||||
* All available rules: http://eslint.org/docs/rules/
|
||||
*
|
||||
* Rules take the following form:
|
||||
* "rule-name", [severity, { opts }]
|
||||
* Severity: 2 == error, 1 == warning, 0 == off.
|
||||
*/
|
||||
"rules": {
|
||||
'@typescript-eslint/no-unused-vars': [2, {args: 'none'}],
|
||||
/**
|
||||
* Enforced rules
|
||||
*/
|
||||
// syntax preferences
|
||||
"quotes": [2, "single", {
|
||||
"avoidEscape": true,
|
||||
"allowTemplateLiterals": true
|
||||
}],
|
||||
"semi": 2,
|
||||
"no-extra-semi": 2,
|
||||
"comma-style": [2, "last"],
|
||||
"wrap-iife": [2, "inside"],
|
||||
"spaced-comment": [2, "always", {
|
||||
"markers": ["*"]
|
||||
}],
|
||||
"eqeqeq": [2],
|
||||
"arrow-body-style": [2, "as-needed"],
|
||||
"accessor-pairs": [2, {
|
||||
"getWithoutSet": false,
|
||||
"setWithoutGet": false
|
||||
}],
|
||||
"brace-style": [2, "1tbs", {"allowSingleLine": true}],
|
||||
"curly": [2, "multi-or-nest", "consistent"],
|
||||
"new-parens": 2,
|
||||
"func-call-spacing": 2,
|
||||
"arrow-parens": [2, "as-needed"],
|
||||
"prefer-const": 2,
|
||||
"quote-props": [2, "consistent"],
|
||||
|
||||
// anti-patterns
|
||||
"no-var": 2,
|
||||
"no-with": 2,
|
||||
"no-multi-str": 2,
|
||||
"no-caller": 2,
|
||||
"no-implied-eval": 2,
|
||||
"no-labels": 2,
|
||||
"no-new-object": 2,
|
||||
"no-octal-escape": 2,
|
||||
"no-self-compare": 2,
|
||||
"no-shadow-restricted-names": 2,
|
||||
"no-cond-assign": 2,
|
||||
"no-debugger": 2,
|
||||
"no-dupe-keys": 2,
|
||||
"no-duplicate-case": 2,
|
||||
"no-empty-character-class": 2,
|
||||
"no-unreachable": 2,
|
||||
"no-unsafe-negation": 2,
|
||||
"radix": 2,
|
||||
"valid-typeof": 2,
|
||||
"no-implicit-globals": [2],
|
||||
|
||||
// es2015 features
|
||||
"require-yield": 2,
|
||||
"template-curly-spacing": [2, "never"],
|
||||
|
||||
// spacing details
|
||||
"space-infix-ops": 2,
|
||||
"space-in-parens": [2, "never"],
|
||||
"space-before-function-paren": [2, "never"],
|
||||
"no-whitespace-before-property": 2,
|
||||
"keyword-spacing": [2, {
|
||||
"overrides": {
|
||||
"if": {"after": true},
|
||||
"else": {"after": true},
|
||||
"for": {"after": true},
|
||||
"while": {"after": true},
|
||||
"do": {"after": true},
|
||||
"switch": {"after": true},
|
||||
"return": {"after": true}
|
||||
}
|
||||
}],
|
||||
"arrow-spacing": [2, {
|
||||
"after": true,
|
||||
"before": true
|
||||
}],
|
||||
|
||||
// file whitespace
|
||||
"no-multiple-empty-lines": [2, {"max": 2}],
|
||||
"no-mixed-spaces-and-tabs": 2,
|
||||
"no-trailing-spaces": 2,
|
||||
"linebreak-style": [ process.platform === "win32" ? 0 : 2, "unix" ],
|
||||
"indent": [2, 2, { "SwitchCase": 1, "CallExpression": {"arguments": 2}, "MemberExpression": 2 }],
|
||||
"key-spacing": [2, {
|
||||
"beforeColon": false
|
||||
}]
|
||||
}
|
||||
};
|
81
.gitignore
vendored
81
.gitignore
vendored
@ -1,61 +1,20 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
/node_modules/
|
||||
/test/output-chromium
|
||||
/test/output-firefox
|
||||
/test/test-user-data-dir*
|
||||
/.local-chromium/
|
||||
/.local-browser/
|
||||
/.local-webkit/
|
||||
/.dev_profile*
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.pyc
|
||||
.vscode
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
/node6
|
||||
/src/chromium/protocol.d.ts
|
||||
/src/webkit/protocol.d.ts
|
||||
/utils/browser/playwright-web.js
|
||||
/index.d.ts
|
||||
lib/
|
||||
|
44
.npmignore
Normal file
44
.npmignore
Normal file
@ -0,0 +1,44 @@
|
||||
.appveyor.yml
|
||||
.gitattributes
|
||||
|
||||
# no longer generated, but old checkouts might still have it
|
||||
node6
|
||||
|
||||
# exclude all tests
|
||||
test
|
||||
utils/node6-transform
|
||||
|
||||
# exclude source files
|
||||
src
|
||||
|
||||
# repeats from .gitignore
|
||||
node_modules
|
||||
.local-chromium
|
||||
.local-browser
|
||||
.dev_profile*
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.pyc
|
||||
.vscode
|
||||
package-lock.json
|
||||
/node6/test
|
||||
/node6/utils
|
||||
/test
|
||||
/utils
|
||||
/docs
|
||||
yarn.lock
|
||||
|
||||
# other
|
||||
/.ci
|
||||
/examples
|
||||
.appveyour.yml
|
||||
.cirrus.yml
|
||||
.editorconfig
|
||||
.eslintignore
|
||||
.eslintrc.js
|
||||
.travis.yml
|
||||
README.md
|
||||
tsconfig.json
|
||||
|
||||
# exclude types, see https://github.com/GoogleChrome/puppeteer/issues/3878
|
||||
/index.d.ts
|
48
.travis.yml
Normal file
48
.travis.yml
Normal file
@ -0,0 +1,48 @@
|
||||
language: node_js
|
||||
dist: trusty
|
||||
addons:
|
||||
apt:
|
||||
packages:
|
||||
# This is required to run new chrome on old trusty
|
||||
- libnss3
|
||||
notifications:
|
||||
email: false
|
||||
cache:
|
||||
directories:
|
||||
- node_modules
|
||||
# allow headful tests
|
||||
before_install:
|
||||
- "sysctl kernel.unprivileged_userns_clone=1"
|
||||
- "export DISPLAY=:99.0"
|
||||
- "sh -e /etc/init.d/xvfb start"
|
||||
script:
|
||||
- 'if [ "$NODE8" = "true" ]; then npm run lint; fi'
|
||||
- 'if [ "$NODE8" = "true" ]; then npm run coverage; fi'
|
||||
- 'if [ "$FIREFOX" = "true" ]; then npm run funit; fi'
|
||||
- 'if [ "$NODE8" = "true" ]; then npm run test-doclint; fi'
|
||||
- 'if [ "$NODE8" = "true" ]; then npm run test-types; fi'
|
||||
- 'if [ "$NODE8" = "true" ]; then npm run bundle; fi'
|
||||
- 'if [ "$NODE8" = "true" ]; then npm run unit-bundle; fi'
|
||||
jobs:
|
||||
include:
|
||||
- node_js: "8.16.0"
|
||||
env:
|
||||
- NODE8=true
|
||||
- FLAKINESS_DASHBOARD_NAME="Travis Chromium (node8 + linux)"
|
||||
- FLAKINESS_DASHBOARD_BUILD_URL="${TRAVIS_JOB_WEB_URL}"
|
||||
- node_js: "8.16.0"
|
||||
env:
|
||||
- FIREFOX=true
|
||||
- FLAKINESS_DASHBOARD_NAME="Travis Firefox (node8 + linux)"
|
||||
- FLAKINESS_DASHBOARD_BUILD_URL="${TRAVIS_JOB_WEB_URL}"
|
||||
before_deploy: "npm run apply-next-version"
|
||||
deploy:
|
||||
provider: npm
|
||||
email: aslushnikov@gmail.com
|
||||
api_key:
|
||||
secure: Ng8o2KwJf90XCBNgUKK3jRZnwtdBSJatjYNmZBERJEqBWFTadFAp1NdhxZaqjnuG8aFYaH5bRJdL+EQBYUksVCbrv/gcaXeEFkwsfPfVX1QXGqu7NnZmtme2hbxppLQ7dEJ8hz2Z9K4vehqVOxmLabxvoupOumxEQMLCphVHh2FOmsm/S5JrRZqZ4V9k76eIc0/PiyfXNMdx5WTZjHbIRDIHRy9nqOXjFp2Rx3PMa3uU2fS8mTshYEYs151TA6e6VdHjqmBwEQC/M5tXbDlLCMNUr4JBtLTcL4OipNYjzkwD1N2xYlbSRqtvqqF4ifdvFhoI65a31GinlMC7Z/SH1Zy+d+/z3Mo7D63eYcsJVnsg9OYxTFy2piUntr0JqTBHtQoe/CvGxJmkcVt+H6YSkcBibSG9s9tG3qpAD5wBCFqqOYnfClX+YZziEd+Hngd9inxAf87qdvgVIZ5tPD2dygtE+te2/qoEHtvccv/HuS8MxNj5iKwlP7JaBPM6uAkazYqZP2R99I2ph9gNOEVuQLtk+3+OIdb8HWrEKUrJBgKhdKY1dvcKYElI+D8NRlyzrr6BnZfudACuAt2EtfKpfJ3mL+iRMFdBJ3ntLt93xBrB+j4z3pD0iWZcg1g3I742PFzQEHzyd/DDTP1yRTUoJeQWwoQRJyNO1m6Qk4wx77c=
|
||||
on:
|
||||
branch: master
|
||||
condition: "$NODE8 = true"
|
||||
skip_cleanup: true
|
||||
tag: next
|
23
DeviceDescriptors.js
Normal file
23
DeviceDescriptors.js
Normal file
@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright 2019 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const {DeviceDescriptors} = require('./lib/DeviceDescriptors');
|
||||
|
||||
const descriptors = DeviceDescriptors.slice();
|
||||
module.exports = descriptors;
|
||||
for (const device of descriptors)
|
||||
module.exports[device.name] = device;
|
17
Errors.js
Normal file
17
Errors.js
Normal file
@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
module.exports = require('./lib/Errors');
|
31
browser_patches/README.md
Normal file
31
browser_patches/README.md
Normal file
@ -0,0 +1,31 @@
|
||||
# Compiling and Uploading Builds
|
||||
|
||||
### 1. Getting code
|
||||
|
||||
```sh
|
||||
$ ./checkout.sh firefox/ # or ./checkout.sh webkit/
|
||||
```
|
||||
|
||||
This command will create a `./firefox/checkout` folder that contains firefox GIT checkout.
|
||||
Checkout current branch will be set to `pwdev` and it will have all additional changes
|
||||
applied to the browser atop of the `./firefox/BASE_REVISION` version.
|
||||
|
||||
### 2. Compiling
|
||||
|
||||
> **NOTE** You might need to prepare your host environment according to browser build instructions:
|
||||
> - [firefox](https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Build_Instructions)
|
||||
> - [webkit](https://webkit.org/building-webkit/)
|
||||
|
||||
```sh
|
||||
$ ./firefox/build.sh # or ./webkit/build.sh
|
||||
```
|
||||
|
||||
### 3. Uploading builds to Azure CDN
|
||||
|
||||
> **NOTE** You should have `$AZ_ACCOUNT_KEY` and `$AZ_ACCOUNT_NAME` variables set in your environment.
|
||||
|
||||
```sh
|
||||
$ ./upload.sh firefox/ # or ./upload.sh webkit/
|
||||
```
|
||||
|
||||
This will package archives and upload builds to Azure CDN.
|
58
browser_patches/check_cdn.sh
Executable file
58
browser_patches/check_cdn.sh
Executable file
@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set +x
|
||||
|
||||
HOST="https://playwrightaccount.blob.core.windows.net/builds"
|
||||
ARCHIVES=(
|
||||
"$HOST/firefox/%s/firefox-mac.zip"
|
||||
"$HOST/firefox/%s/firefox-linux.zip"
|
||||
"$HOST/firefox/%s/firefox-win.zip"
|
||||
"$HOST/webkit/%s/minibrowser-linux.zip"
|
||||
"$HOST/webkit/%s/minibrowser-mac10.14.zip"
|
||||
"$HOST/webkit/%s/minibrowser-mac10.15.zip"
|
||||
)
|
||||
|
||||
ALIASES=(
|
||||
"FF-MAC"
|
||||
"FF-LINUX"
|
||||
"FF-WIN"
|
||||
"WK-MAC-10.14"
|
||||
"WK-MAC-10.15"
|
||||
"WK-LINUX"
|
||||
)
|
||||
COLUMN="%-15s"
|
||||
|
||||
# COLORS
|
||||
RED=$'\e[1;31m'
|
||||
GRN=$'\e[1;32m'
|
||||
YEL=$'\e[1;33m'
|
||||
END=$'\e[0m'
|
||||
|
||||
# Read start revision if there's any.
|
||||
REVISION=$(git rev-parse HEAD)
|
||||
if [[ $# == 1 ]]; then
|
||||
if ! git rev-parse $1; then
|
||||
echo "ERROR: there is no $REVISION in this repo - pull from upstream?"
|
||||
exit 1
|
||||
fi
|
||||
REVISION=$(git rev-parse $1)
|
||||
fi
|
||||
|
||||
printf "%12s" ""
|
||||
for i in "${ALIASES[@]}"; do
|
||||
printf $COLUMN $i
|
||||
done
|
||||
printf "\n"
|
||||
while true; do
|
||||
printf "%-12s" ${REVISION:0:10}
|
||||
for i in "${ARCHIVES[@]}"; do
|
||||
URL=$(printf $i $REVISION)
|
||||
if [[ $(curl -s -L -I $URL | head -1 | cut -f2 -d' ') == 200 ]]; then
|
||||
printf ${GRN}$COLUMN${END} "YES"
|
||||
else
|
||||
printf ${RED}$COLUMN${END} "NO"
|
||||
fi
|
||||
done;
|
||||
echo
|
||||
REVISION=$(git rev-parse $REVISION^)
|
||||
done;
|
136
browser_patches/do_checkout.sh
Executable file
136
browser_patches/do_checkout.sh
Executable file
@ -0,0 +1,136 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set +x
|
||||
|
||||
function cleanup() {
|
||||
cd $OLD_DIR
|
||||
}
|
||||
|
||||
OLD_DIR=$(pwd -P)
|
||||
cd "$(dirname "$0")"
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ ($1 == '--help') || ($1 == '-h') ]]; then
|
||||
echo "usage: do_something.sh [firefox|webkit]"
|
||||
echo
|
||||
echo "Produces a browser checkout ready to be built."
|
||||
echo
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $# == 0 ]]; then
|
||||
echo "missing browser: 'firefox' or 'webkit'"
|
||||
echo "try './do_something.sh --help' for more information"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# FRIENDLY_CHECKOUT_PATH is used only for logging.
|
||||
FRIENDLY_CHECKOUT_PATH="";
|
||||
CHECKOUT_PATH=""
|
||||
# Export path is where we put the patches and BASE_REVISION
|
||||
REMOTE_URL=""
|
||||
BASE_BRANCH=""
|
||||
if [[ ("$1" == "firefox") || ("$1" == "firefox/") ]]; then
|
||||
# we always apply our patches atop of beta since it seems to get better
|
||||
# reliability guarantees.
|
||||
BASE_BRANCH="beta"
|
||||
FRIENDLY_CHECKOUT_PATH="//browser_patches/firefox/checkout";
|
||||
CHECKOUT_PATH="$PWD/firefox/checkout"
|
||||
REMOTE_URL="https://github.com/mozilla/gecko-dev"
|
||||
elif [[ ("$1" == "webkit") || ("$1" == "webkit/") ]]; then
|
||||
# webkit has only a master branch.
|
||||
BASE_BRANCH="master"
|
||||
FRIENDLY_CHECKOUT_PATH="//browser_patches/webkit/checkout";
|
||||
CHECKOUT_PATH="$PWD/webkit/checkout"
|
||||
REMOTE_URL=""
|
||||
REMOTE_URL="https://github.com/webkit/webkit"
|
||||
else
|
||||
echo ERROR: unknown browser to export - "$1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# if there's no checkout folder - checkout one.
|
||||
if ! [[ -d $CHECKOUT_PATH ]]; then
|
||||
echo "-- $FRIENDLY_CHECKOUT_PATH is missing - checking out.."
|
||||
git clone --single-branch --branch $BASE_BRANCH $REMOTE_URL $CHECKOUT_PATH
|
||||
else
|
||||
echo "-- checking $FRIENDLY_CHECKOUT_PATH folder - OK"
|
||||
fi
|
||||
|
||||
# if folder exists but not a git repository - bail out.
|
||||
if ! [[ -d $CHECKOUT_PATH/.git ]]; then
|
||||
echo "ERROR: $FRIENDLY_CHECKOUT_PATH is not a git repository! Remove it and re-run the script."
|
||||
exit 1
|
||||
else
|
||||
echo "-- checking $FRIENDLY_CHECKOUT_PATH is a git repo - OK"
|
||||
fi
|
||||
|
||||
# Switch to git repository.
|
||||
cd $CHECKOUT_PATH
|
||||
|
||||
# Check if git repo is dirty.
|
||||
if [[ -n $(git status -s) ]]; then
|
||||
echo "ERROR: $FRIENDLY_CHECKOUT_PATH has dirty GIT state - commit everything and re-run the script."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $(git config --get remote.origin.url) == "$REMOTE_URL" ]]; then
|
||||
echo "-- checking git origin url to point to $REMOTE_URL - OK";
|
||||
else
|
||||
echo "ERROR: git origin url DOES NOT point to $REMOTE_URL. Remove $FRIENDLY_CHECKOUT_PATH and re-run the script.";
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# if there's no "BASE_BRANCH" branch - bail out.
|
||||
if ! git show-ref --verify --quiet refs/heads/$BASE_BRANCH; then
|
||||
echo "ERROR: $FRIENDLY_CHECKOUT_PATH/ does not have '$BASE_BRANCH' branch! Remove checkout/ and re-run the script."
|
||||
exit 1
|
||||
else
|
||||
echo "-- checking $FRIENDLY_CHECKOUT_PATH has 'beta' branch - OK"
|
||||
fi
|
||||
|
||||
if ! [[ -z $(git log --oneline origin/$BASE_BRANCH..$BASE_BRANCH) ]]; then
|
||||
echo "ERROR: branch '$BASE_BRANCH' and branch 'origin/$BASE_BRANCH' have diverged - bailing out. Remove checkout/ and re-run the script."
|
||||
exit 1;
|
||||
else
|
||||
echo "-- checking that $BASE_BRANCH and origin/$BASE_BRANCH are not diverged - OK"
|
||||
fi
|
||||
|
||||
git checkout $BASE_BRANCH
|
||||
git pull origin $BASE_BRANCH
|
||||
|
||||
PINNED_COMMIT=$(cat ../BASE_REVISION)
|
||||
if ! git cat-file -e $PINNED_COMMIT^{commit}; then
|
||||
echo "ERROR: $FRIENDLY_CHECKOUT_PATH/ does not include the BASE_REVISION (@$PINNED_COMMIT). Remove checkout/ and re-run the script."
|
||||
exit 1
|
||||
else
|
||||
echo "-- checking $FRIENDLY_CHECKOUT_PATH repo has BASE_REVISION (@$PINNED_COMMIT) commit - OK"
|
||||
fi
|
||||
|
||||
# If there's already a PWDEV branch than we should check if it's fine to reset all changes
|
||||
# to it.
|
||||
if git show-ref --verify --quiet refs/heads/pwdev; then
|
||||
read -p "Do you want to reset 'PWDEV' branch? (ALL CHANGES WILL BE LOST) Y/n " -n 1 -r
|
||||
echo
|
||||
# if it's not fine to reset branch - bail out.
|
||||
if ! [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "If you want to keep the branch, than I can't do much! Bailing out!"
|
||||
exit 1
|
||||
else
|
||||
git checkout pwdev
|
||||
git reset --hard $PINNED_COMMIT
|
||||
echo "-- PWDEV now points to BASE_REVISION (@$PINNED_COMMIT)"
|
||||
fi
|
||||
else
|
||||
# Otherwise just create a new branch.
|
||||
git checkout -b pwdev
|
||||
git reset --hard $PINNED_COMMIT
|
||||
echo "-- created 'pwdev' branch that points to BASE_REVISION (@$PINNED_COMMIT)."
|
||||
fi
|
||||
|
||||
echo "-- applying all patches"
|
||||
git am ../patches/*
|
||||
|
||||
echo
|
||||
echo
|
||||
echo "DONE. Browser is ready to be built."
|
115
browser_patches/export.sh
Executable file
115
browser_patches/export.sh
Executable file
@ -0,0 +1,115 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set +x
|
||||
|
||||
cleanup() {
|
||||
cd $OLD_DIR
|
||||
}
|
||||
|
||||
OLD_DIR=$(pwd -P)
|
||||
cd "$(dirname "$0")"
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ ($1 == '--help') || ($1 == '-h') ]]; then
|
||||
echo "usage: export.sh [firefox|webkit] [custom_checkout_path]"
|
||||
echo
|
||||
echo "Exports BASE_REVISION and patch from the checkout to browser folder."
|
||||
echo
|
||||
echo "You can optionally specify custom_checkout_path if you have browser checkout somewhere else"
|
||||
echo "and wish to export patches from it."
|
||||
echo
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $# == 0 ]]; then
|
||||
echo "missing browser: 'firefox' or 'webkit'"
|
||||
echo "try './export.sh --help' for more information"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# FRIENDLY_CHECKOUT_PATH is used only for logging.
|
||||
FRIENDLY_CHECKOUT_PATH="";
|
||||
CHECKOUT_PATH=""
|
||||
# Export path is where we put the patches and BASE_REVISION
|
||||
EXPORT_PATH=""
|
||||
BASE_BRANCH=""
|
||||
if [[ ("$1" == "firefox") || ("$1" == "firefox/") ]]; then
|
||||
# we always apply our patches atop of beta since it seems to get better
|
||||
# reliability guarantees.
|
||||
BASE_BRANCH="origin/beta"
|
||||
FRIENDLY_CHECKOUT_PATH="//browser_patches/firefox/checkout";
|
||||
CHECKOUT_PATH="$PWD/firefox/checkout"
|
||||
EXPORT_PATH="$PWD/firefox/"
|
||||
elif [[ ("$1" == "webkit") || ("$1" == "webkit/") ]]; then
|
||||
# webkit has only a master branch.
|
||||
BASE_BRANCH="origin/master"
|
||||
FRIENDLY_CHECKOUT_PATH="//browser_patches/webkit/checkout";
|
||||
CHECKOUT_PATH="$PWD/webkit/checkout"
|
||||
EXPORT_PATH="$PWD/webkit/"
|
||||
else
|
||||
echo ERROR: unknown browser to export - "$1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# we will use this just for beauty.
|
||||
if [[ $# == 2 ]]; then
|
||||
echo "WARNING: using custom checkout path $CHECKOUT_PATH"
|
||||
CHECKOUT_PATH=$2
|
||||
FRIENDLY_CHECKOUT_PATH="<custom_checkout>"
|
||||
fi
|
||||
|
||||
# if there's no checkout folder - bail out.
|
||||
if ! [[ -d $CHECKOUT_PATH ]]; then
|
||||
echo "ERROR: $FRIENDLY_CHECKOUT_PATH is missing - nothing to export."
|
||||
exit 1;
|
||||
else
|
||||
echo "-- checking $FRIENDLY_CHECKOUT_PATH exists - OK"
|
||||
fi
|
||||
|
||||
# if folder exists but not a git repository - bail out.
|
||||
if ! [[ -d $CHECKOUT_PATH/.git ]]; then
|
||||
echo "ERROR: $FRIENDLY_CHECKOUT_PATH is not a git repository! Nothing to export."
|
||||
exit 1
|
||||
else
|
||||
echo "-- checking $FRIENDLY_CHECKOUT_PATH is a git repo - OK"
|
||||
fi
|
||||
|
||||
# Switch to git repository.
|
||||
cd $CHECKOUT_PATH
|
||||
|
||||
# Check if git repo is dirty.
|
||||
if [[ -n $(git status -s) ]]; then
|
||||
echo "ERROR: $FRIENDLY_CHECKOUT_PATH has dirty GIT state - aborting export."
|
||||
exit 1
|
||||
else
|
||||
echo "-- checking $FRIENDLY_CHECKOUT_PATH is clean - OK"
|
||||
fi
|
||||
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
MERGE_BASE=$(git merge-base $BASE_BRANCH $CURRENT_BRANCH)
|
||||
echo "=============================================================="
|
||||
echo " Repository: $FRIENDLY_CHECKOUT_PATH"
|
||||
echo " Changes between branches: $BASE_BRANCH..$CURRENT_BRANCH"
|
||||
echo " BASE_REVISION: $MERGE_BASE"
|
||||
echo
|
||||
read -p "Export? Y/n " -n 1 -r
|
||||
echo
|
||||
# if it's not fine to reset branch - bail out.
|
||||
if ! [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Exiting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo $MERGE_BASE > $EXPORT_PATH/BASE_REVISION
|
||||
git checkout -b tmpsquash_export_script $MERGE_BASE
|
||||
git merge --squash $CURRENT_BRANCH
|
||||
git commit -am "chore: bootstrap"
|
||||
PATCH_NAME=$(git format-patch -1 HEAD)
|
||||
mv $PATCH_NAME $EXPORT_PATH/patches/
|
||||
git checkout $CURRENT_BRANCH
|
||||
git branch -D tmpsquash_export_script
|
||||
|
||||
# Increment BUILD_NUMBER
|
||||
BUILD_NUMBER=$(cat $EXPORT_PATH/BUILD_NUMBER)
|
||||
BUILD_NUMBER=$((BUILD_NUMBER+1))
|
||||
echo $BUILD_NUMBER > $EXPORT_PATH/BUILD_NUMBER
|
1
browser_patches/firefox/.gitignore
vendored
Normal file
1
browser_patches/firefox/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/checkout
|
1
browser_patches/firefox/BASE_REVISION
Normal file
1
browser_patches/firefox/BASE_REVISION
Normal file
@ -0,0 +1 @@
|
||||
46ca28eadfe840021e2ea496fa6b26f924fa135b
|
2
browser_patches/firefox/BUILD_NUMBER
Normal file
2
browser_patches/firefox/BUILD_NUMBER
Normal file
@ -0,0 +1,2 @@
|
||||
1
|
||||
|
22
browser_patches/firefox/README.md
Normal file
22
browser_patches/firefox/README.md
Normal file
@ -0,0 +1,22 @@
|
||||
# Building Juggler (Linux & Mac)
|
||||
|
||||
1. Run `./do_checkout.sh` script. This will create a "checkout" folder with gecko-dev mirror from
|
||||
GitHub and apply the PlayWright-specific patches.
|
||||
2. Run `./do_build.sh` script to compile browser. Note: you'll need to follow [build instructions](https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Build_Instructions) to setup host environment first.
|
||||
|
||||
# Updating `FIREFOX_REVISION` and `//patches/*`
|
||||
|
||||
The `./export.sh` script will export a patch that describes all the differences between the current branch in `./checkout`
|
||||
and the `beta` branch in `./checkout`.
|
||||
|
||||
# Uploading to Azure CDN
|
||||
|
||||
Uploading requires having both `AZ_ACCOUNT_KEY` and `AZ_ACCOUNT_NAME` env variables to be defined.
|
||||
|
||||
The following sequence of steps will checkout, build and upload build to Azure CDN on both Linux and Mac:
|
||||
|
||||
```sh
|
||||
$ ./do_checkout.sh
|
||||
$ ./build.sh
|
||||
$ ./upload.sh
|
||||
```
|
53
browser_patches/firefox/archive.sh
Executable file
53
browser_patches/firefox/archive.sh
Executable file
@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ ("$1" == "-h") || ("$1" == "--help") ]]; then
|
||||
echo "usage: $0"
|
||||
echo
|
||||
echo "Generate distributable .zip archive from ./checkout folder that was previously built."
|
||||
echo
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
createZIPForLinuxOrMac() {
|
||||
cd checkout
|
||||
local zipname=$1
|
||||
local OBJ_FOLDER=$(ls -1 | grep obj-)
|
||||
if [[ $OBJ_FOLDER == "" ]]; then
|
||||
echo "ERROR: cannot find obj-* folder in the checkout/. Did you build?"
|
||||
exit 1;
|
||||
fi
|
||||
if ! [[ -d $OBJ_FOLDER/dist/firefox ]]; then
|
||||
echo "ERROR: cannot find $OBJ_FOLDER/dist/firefox folder in the checkout/. Did you build?"
|
||||
exit 1;
|
||||
fi
|
||||
# Copy the libstdc++ version we linked against.
|
||||
# TODO(aslushnikov): this won't be needed with official builds.
|
||||
if [[ "$(uname)" == "Linux" ]]; then
|
||||
cp /usr/lib/x86_64-linux-gnu/libstdc++.so.6 $OBJ_FOLDER/dist/firefox/libstdc++.so.6
|
||||
fi
|
||||
|
||||
# tar resulting directory and cleanup TMP.
|
||||
cd $OBJ_FOLDER/dist
|
||||
zip -r ../../../$zipname firefox
|
||||
cd -
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
cd $OLD_DIR
|
||||
}
|
||||
|
||||
OLD_DIR=$(pwd -P)
|
||||
cd "$(dirname "$0")"
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
createZIPForLinuxOrMac "firefox-mac.zip"
|
||||
elif [[ "$(uname)" == "Linux" ]]; then
|
||||
createZIPForLinuxOrMac "firefox-linux.zip"
|
||||
else
|
||||
echo "ERROR: cannot upload on this platform!" 1>&2
|
||||
exit 1;
|
||||
fi
|
45
browser_patches/firefox/build.sh
Executable file
45
browser_patches/firefox/build.sh
Executable file
@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set +x
|
||||
|
||||
function cleanup() {
|
||||
cd $OLD_DIR
|
||||
}
|
||||
|
||||
OLD_DIR=$(pwd -P)
|
||||
cd "$(dirname "$0")"
|
||||
trap cleanup EXIT
|
||||
|
||||
cd checkout
|
||||
|
||||
if ! [[ $(git rev-parse --abbrev-ref HEAD) == "pwdev" ]]; then
|
||||
echo "ERROR: Cannot build any branch other than PWDEV"
|
||||
exit 1;
|
||||
else
|
||||
echo "-- checking git branch is PWDEV - OK"
|
||||
fi
|
||||
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
# Firefox currently does not build on 10.15 out of the box - it requires SDK for 10.14.
|
||||
# Make sure the SDK is out there.
|
||||
if [[ $(sw_vers -productVersion) == "10.15" ]]; then
|
||||
if ! [[ -d $HOME/SDK-archive/MacOSX10.14.sdk ]]; then
|
||||
echo "As of Nov 2019, Firefox does not build on Mac 10.15 without 10.14 SDK."
|
||||
echo "Check out instructions on getting 10.14 sdk at https://developer.mozilla.org/en-US/docs/Mozilla/Developer_guide/Build_Instructions/Mac_OS_X_Prerequisites"
|
||||
echo "and make sure to put SDK to $HOME/SDK-archive/MacOSX10.14.sdk/"
|
||||
exit 1
|
||||
else
|
||||
echo "-- configuting .mozconfig with 10.14 SDK path"
|
||||
echo "ac_add_options --with-macos-sdk=$HOME/SDK-archive/MacOSX10.14.sdk/" > .mozconfig
|
||||
fi
|
||||
fi
|
||||
echo "-- building on Mac"
|
||||
elif [[ "$(uname)" == "Linux" ]]; then
|
||||
echo "-- building on Linux"
|
||||
else
|
||||
echo "ERROR: cannot upload on this platform!" 1>&2
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
./mach build
|
||||
./mach package
|
4895
browser_patches/firefox/patches/0001-chore-bootstrap.patch
Normal file
4895
browser_patches/firefox/patches/0001-chore-bootstrap.patch
Normal file
File diff suppressed because it is too large
Load Diff
68
browser_patches/upload.sh
Executable file
68
browser_patches/upload.sh
Executable file
@ -0,0 +1,68 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set +x
|
||||
|
||||
cleanup() {
|
||||
cd $OLD_DIR
|
||||
}
|
||||
|
||||
OLD_DIR=$(pwd -P)
|
||||
cd "$(dirname "$0")"
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ ($1 == '--help') || ($1 == '-h') ]]; then
|
||||
echo "usage: $0 [firefox|webkit]"
|
||||
echo
|
||||
echo "Archive and upload a browser"
|
||||
echo
|
||||
echo "NOTE: \$AZ_ACCOUNT_KEY (azure account name) and \$AZ_ACCOUNT_NAME (azure account name)"
|
||||
echo "env variables are required to upload builds to CDN."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ $# == 0 ]]; then
|
||||
echo "missing browser: 'firefox' or 'webkit'"
|
||||
echo "try '$0 --help' for more information"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ (-z $AZ_ACCOUNT_KEY) || (-z $AZ_ACCOUNT_NAME) ]]; then
|
||||
echo "ERROR: Either \$AZ_ACCOUNT_KEY or \$AZ_ACCOUNT_NAME environment variable is missing."
|
||||
echo " 'Azure Account Name' and 'Azure Account Key' secrets that are required"
|
||||
echo " to upload builds ot Azure CDN."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ARCHIVE_SCRIPT=""
|
||||
BROWSER_NAME=""
|
||||
BUILD_NUMBER=""
|
||||
if [[ ("$1" == "firefox") || ("$1" == "firefox/") ]]; then
|
||||
# we always apply our patches atop of beta since it seems to get better
|
||||
# reliability guarantees.
|
||||
ARCHIVE_FOLDER="$PWD/firefox"
|
||||
BUILD_NUMBER=$(cat "$PWD/firefox/BUILD_NUMBER")
|
||||
ARCHIVE_SCRIPT="$PWD/firefox/archive.sh"
|
||||
BROWSER_NAME="firefox"
|
||||
elif [[ ("$1" == "webkit") || ("$1" == "webkit/") ]]; then
|
||||
ARCHIVE_FOLDER="$PWD/webkit"
|
||||
BUILD_NUMBER=$(cat "$PWD/webkit/BUILD_NUMBER")
|
||||
ARCHIVE_SCRIPT="$PWD/webkit/archive.sh"
|
||||
BROWSER_NAME="webkit"
|
||||
else
|
||||
echo ERROR: unknown browser to export - "$1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ -z $(ls $ARCHIVE_FOLDER | grep '.zip') ]]; then
|
||||
echo ERROR: .zip file already exists in $ARCHIVE_FOLDER!
|
||||
echo Remove manually all zip files and re-run the script.
|
||||
exit 1
|
||||
fi
|
||||
|
||||
$ARCHIVE_SCRIPT
|
||||
ZIP_NAME=$(ls $ARCHIVE_FOLDER | grep '.zip')
|
||||
ZIP_PATH=$ARCHIVE_FOLDER/$ZIP_NAME
|
||||
BLOB_NAME="$BROWSER_NAME/$BUILD_NUMBER/$ZIP_NAME"
|
||||
az storage blob upload -c builds --account-key $AZ_ACCOUNT_KEY --account-name $AZ_ACCOUNT_NAME -f $ZIP_PATH -n "$BLOB_NAME"
|
||||
echo "Uploaded $(du -h "$ZIP_PATH" | awk '{print $1}') as $BLOB_NAME"
|
||||
rm $ZIP_PATH
|
1
browser_patches/webkit/.gitignore
vendored
Normal file
1
browser_patches/webkit/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/checkout
|
1
browser_patches/webkit/BASE_REVISION
Normal file
1
browser_patches/webkit/BASE_REVISION
Normal file
@ -0,0 +1 @@
|
||||
cadee71e3e832cc0b78184a714ade07d9a6d3173
|
2
browser_patches/webkit/BUILD_NUMBER
Normal file
2
browser_patches/webkit/BUILD_NUMBER
Normal file
@ -0,0 +1,2 @@
|
||||
1
|
||||
|
85
browser_patches/webkit/archive.sh
Executable file
85
browser_patches/webkit/archive.sh
Executable file
@ -0,0 +1,85 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [[ ("$1" == "-h") || ("$1" == "--help") ]]; then
|
||||
echo "usage: $0"
|
||||
echo
|
||||
echo "Generate distributable .zip archive from ./checkout folder that was previously built."
|
||||
echo
|
||||
exit 0
|
||||
fi
|
||||
|
||||
set -e
|
||||
set -x
|
||||
|
||||
main() {
|
||||
cd checkout
|
||||
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
createZipForMac
|
||||
elif [[ "$(uname)" == "Linux" ]]; then
|
||||
createZipForLinux
|
||||
else
|
||||
echo "ERROR: cannot upload on this platform!" 1>&2
|
||||
exit 1;
|
||||
fi
|
||||
}
|
||||
|
||||
createZipForLinux() {
|
||||
# create a TMP directory to copy all necessary files
|
||||
local tmpdir=$(mktemp -d -t webkit-deploy-XXXXXXXXXX)
|
||||
mkdir -p $tmpdir
|
||||
|
||||
# copy all relevant binaries
|
||||
cp -t $tmpdir ./WebKitBuild/Release/bin/MiniBrowser ./WebKitBuild/Release/bin/WebKit*Process
|
||||
# copy runner
|
||||
cp -t $tmpdir ../pw_run.sh
|
||||
# copy protocol
|
||||
node ../concat_protocol.js > $tmpdir/protocol.json
|
||||
# copy all relevant shared objects
|
||||
LD_LIBRARY_PATH="$PWD/WebKitBuild/DependenciesGTK/Root/lib" ldd WebKitBuild/Release/bin/MiniBrowser | grep -o '[^ ]*WebKitBuild/[^ ]*' | xargs cp -t $tmpdir
|
||||
|
||||
# we failed to nicely build libgdk_pixbuf - expect it in the env
|
||||
rm $tmpdir/libgdk_pixbuf*
|
||||
|
||||
# tar resulting directory and cleanup TMP.
|
||||
local zipname="minibrowser-linux.zip"
|
||||
zip -jr ../$zipname $tmpdir
|
||||
rm -rf $tmpdir
|
||||
}
|
||||
|
||||
createZipForMac() {
|
||||
# create a TMP directory to copy all necessary files
|
||||
local tmpdir=$(mktemp -d)
|
||||
|
||||
# copy all relevant files
|
||||
ditto {./WebKitBuild/Release,$tmpdir}/com.apple.WebKit.Networking.xpc
|
||||
ditto {./WebKitBuild/Release,$tmpdir}/com.apple.WebKit.Plugin.64.xpc
|
||||
ditto {./WebKitBuild/Release,$tmpdir}/com.apple.WebKit.WebContent.xpc
|
||||
ditto {./WebKitBuild/Release,$tmpdir}/JavaScriptCore.framework
|
||||
ditto {./WebKitBuild/Release,$tmpdir}/libwebrtc.dylib
|
||||
ditto {./WebKitBuild/Release,$tmpdir}/MiniBrowser.app
|
||||
ditto {./WebKitBuild/Release,$tmpdir}/PluginProcessShim.dylib
|
||||
ditto {./WebKitBuild/Release,$tmpdir}/SecItemShim.dylib
|
||||
ditto {./WebKitBuild/Release,$tmpdir}/WebCore.framework
|
||||
ditto {./WebKitBuild/Release,$tmpdir}/WebInspectorUI.framework
|
||||
ditto {./WebKitBuild/Release,$tmpdir}/WebKit.framework
|
||||
ditto {./WebKitBuild/Release,$tmpdir}/WebKitLegacy.framework
|
||||
ditto {..,$tmpdir}/pw_run.sh
|
||||
# copy protocol
|
||||
node ../concat_protocol.js > $tmpdir/protocol.json
|
||||
|
||||
# zip resulting directory and cleanup TMP.
|
||||
local MAC_MAJOR_MINOR_VERSION=$(sw_vers -productVersion | grep -o '^\d\+.\d\+')
|
||||
local zipname="minibrowser-mac-$MAC_MAJOR_MINOR_VERSION.zip"
|
||||
ditto -c -k $tmpdir ../$zipname
|
||||
rm -rf $tmpdir
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
cd $OLD_DIR
|
||||
}
|
||||
|
||||
OLD_DIR=$(pwd -P)
|
||||
cd "$(dirname "$0")"
|
||||
trap cleanup EXIT
|
||||
main "$@"
|
29
browser_patches/webkit/build.sh
Executable file
29
browser_patches/webkit/build.sh
Executable file
@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
set +x
|
||||
|
||||
function cleanup() {
|
||||
cd $OLD_DIR
|
||||
}
|
||||
|
||||
OLD_DIR=$(pwd -P)
|
||||
cd "$(dirname "$0")"
|
||||
trap cleanup EXIT
|
||||
|
||||
cd checkout
|
||||
|
||||
if ! [[ $(git rev-parse --abbrev-ref HEAD) == "pwdev" ]]; then
|
||||
echo "ERROR: Cannot build any branch other than PWDEV"
|
||||
exit 1;
|
||||
else
|
||||
echo "-- checking git branch is PWDEV - OK"
|
||||
fi
|
||||
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
./Tools/Scripts/build-webkit --release
|
||||
elif [[ "$(uname)" == "Linux" ]]; then
|
||||
./Tools/Scripts/build-webkit --gtk --release MiniBrowser
|
||||
else
|
||||
echo "ERROR: cannot upload on this platform!" 1>&2
|
||||
exit 1;
|
||||
fi
|
6
browser_patches/webkit/concat_protocol.js
Normal file
6
browser_patches/webkit/concat_protocol.js
Normal file
@ -0,0 +1,6 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const protocolDir = path.join(__dirname, './checkout/Source/JavaScriptCore/inspector/protocol');
|
||||
const files = fs.readdirSync(protocolDir).filter(f => f.endsWith('.json')).map(f => path.join(protocolDir, f));
|
||||
const json = files.map(file => JSON.parse(fs.readFileSync(file)));
|
||||
console.log(JSON.stringify(json));
|
5890
browser_patches/webkit/patches/0001-chore-bootstrap.patch
Normal file
5890
browser_patches/webkit/patches/0001-chore-bootstrap.patch
Normal file
File diff suppressed because it is too large
Load Diff
40
browser_patches/webkit/pw_run.sh
Executable file
40
browser_patches/webkit/pw_run.sh
Executable file
@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
|
||||
function runOSX() {
|
||||
# if script is run as-is
|
||||
if [ -d $SCRIPT_PATH/checkout/WebKitBuild/Release/MiniBrowser.app ]; then
|
||||
DYLIB_PATH="$SCRIPT_PATH/checkout/WebKitBuild/Release"
|
||||
elif [ -d $SCRIPT_PATH/MiniBrowser.app ]; then
|
||||
DYLIB_PATH="$SCRIPT_PATH"
|
||||
else
|
||||
echo "Cannot find a MiniBrowser.app in neither location" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
MINIBROWSER="$DYLIB_PATH/MiniBrowser.app/Contents/MacOS/MiniBrowser"
|
||||
DYLD_FRAMEWORK_PATH=$DYLIB_PATH DYLD_LIBRARY_PATH=$DYLIB_PATH $MINIBROWSER "$@"
|
||||
}
|
||||
|
||||
function runLinux() {
|
||||
# if script is run as-is
|
||||
if [ -d $SCRIPT_PATH/checkout/WebKitBuild ]; then
|
||||
LD_PATH="$SCRIPT_PATH/checkout/WebKitBuild/DependenciesGTK/Root/lib:$SCRIPT_PATH/checkout/WebKitBuild/Release/bin"
|
||||
MINIBROWSER="$SCRIPT_PATH/checkout/WebKitBuild/Release/bin/MiniBrowser"
|
||||
elif [ -f $SCRIPT_PATH/MiniBrowser ]; then
|
||||
LD_PATH="$SCRIPT_PATH"
|
||||
MINIBROWSER="$SCRIPT_PATH/MiniBrowser"
|
||||
else
|
||||
echo "Cannot find a MiniBrowser.app in neither location" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
LD_LIBRARY_PATH=$LD_LIBRARY_PATH:$LD_PATH $MINIBROWSER "$@"
|
||||
}
|
||||
|
||||
SCRIPT_PATH="$(cd "$(dirname "$0")" ; pwd -P)"
|
||||
if [ "$(uname)" == "Darwin" ]; then
|
||||
runOSX "$@"
|
||||
elif [ "$(uname)" == "Linux" ]; then
|
||||
runLinux "$@"
|
||||
else
|
||||
echo "ERROR: cannot run on this platform!" 1>&2
|
||||
exit 1;
|
||||
fi
|
30
chromium.js
Normal file
30
chromium.js
Normal file
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const {helper} = require('./lib/helper');
|
||||
const api = require('./lib/api');
|
||||
for (const className in api.Chromium) {
|
||||
// Playwright-web excludes certain classes from bundle, e.g. BrowserFetcher.
|
||||
if (typeof api.Chromium[className] === 'function')
|
||||
helper.installAsyncStackHooks(api.Chromium[className]);
|
||||
}
|
||||
|
||||
// If node does not support async await, use the compiled version.
|
||||
const {Playwright} = require('./lib/chromium/Playwright');
|
||||
const packageJson = require('./package.json');
|
||||
const isPlaywrightCore = packageJson.name === 'playwright-core';
|
||||
|
||||
module.exports = new Playwright(__dirname, packageJson.playwright.chromium_revision, isPlaywrightCore);
|
4107
docs/api.md
Normal file
4107
docs/api.md
Normal file
File diff suppressed because it is too large
Load Diff
36
examples/block-images.js
Normal file
36
examples/block-images.js
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc., PhantomJS Authors All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const playwright = require('playwright');
|
||||
|
||||
(async() => {
|
||||
const browser = await playwright.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', request => {
|
||||
if (request.resourceType() === 'image')
|
||||
request.abort();
|
||||
else
|
||||
request.continue();
|
||||
});
|
||||
await page.goto('https://news.google.com/news/');
|
||||
await page.screenshot({path: 'news.png', fullPage: true});
|
||||
|
||||
await browser.close();
|
||||
})();
|
||||
|
48
examples/custom-event.js
Normal file
48
examples/custom-event.js
Normal file
@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const playwright = require('playwright');
|
||||
|
||||
(async() => {
|
||||
const browser = await playwright.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
// Define a window.onCustomEvent function on the page.
|
||||
await page.exposeFunction('onCustomEvent', e => {
|
||||
console.log(`${e.type} fired`, e.detail || '');
|
||||
});
|
||||
|
||||
/**
|
||||
* Attach an event listener to page to capture a custom event on page load/navigation.
|
||||
* @param {string} type Event name.
|
||||
* @return {!Promise}
|
||||
*/
|
||||
function listenFor(type) {
|
||||
return page.evaluateOnNewDocument(type => {
|
||||
document.addEventListener(type, e => {
|
||||
window.onCustomEvent({type, detail: e.detail});
|
||||
});
|
||||
}, type);
|
||||
}
|
||||
|
||||
await listenFor('app-ready'); // Listen for "app-ready" custom event on page load.
|
||||
|
||||
await page.goto('https://www.chromestatus.com/features', {waitUntil: 'networkidle0'});
|
||||
|
||||
await browser.close();
|
||||
})();
|
44
examples/detect-sniff.js
Normal file
44
examples/detect-sniff.js
Normal file
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc., PhantomJS Authors All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const playwright = require('playwright');
|
||||
|
||||
function sniffDetector() {
|
||||
const userAgent = window.navigator.userAgent;
|
||||
const platform = window.navigator.platform;
|
||||
|
||||
window.navigator.__defineGetter__('userAgent', function() {
|
||||
window.navigator.sniffed = true;
|
||||
return userAgent;
|
||||
});
|
||||
|
||||
window.navigator.__defineGetter__('platform', function() {
|
||||
window.navigator.sniffed = true;
|
||||
return platform;
|
||||
});
|
||||
}
|
||||
|
||||
(async() => {
|
||||
const browser = await playwright.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.evaluateOnNewDocument(sniffDetector);
|
||||
await page.goto('https://www.google.com', {waitUntil: 'networkidle2'});
|
||||
console.log('Sniffed: ' + (await page.evaluate(() => !!navigator.sniffed)));
|
||||
|
||||
await browser.close();
|
||||
})();
|
33
examples/pdf.js
Normal file
33
examples/pdf.js
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const playwright = require('playwright');
|
||||
|
||||
(async() => {
|
||||
const browser = await playwright.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://news.ycombinator.com', {waitUntil: 'networkidle2'});
|
||||
// page.pdf() is currently supported only in headless mode.
|
||||
// @see https://bugs.chromium.org/p/chromium/issues/detail?id=753118
|
||||
await page.pdf({
|
||||
path: 'hn.pdf',
|
||||
format: 'letter'
|
||||
});
|
||||
|
||||
await browser.close();
|
||||
})();
|
35
examples/proxy.js
Normal file
35
examples/proxy.js
Normal file
@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const playwright = require('playwright');
|
||||
|
||||
(async() => {
|
||||
const browser = await playwright.launch({
|
||||
// Launch chromium using a proxy server on port 9876.
|
||||
// More on proxying:
|
||||
// https://www.chromium.org/developers/design-documents/network-settings
|
||||
args: [
|
||||
'--proxy-server=127.0.0.1:9876',
|
||||
// Use proxy for localhost URLs
|
||||
'--proxy-bypass-list=<-loopback>',
|
||||
]
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
await page.goto('https://google.com');
|
||||
await browser.close();
|
||||
})();
|
29
examples/screenshot-fullpage.js
Normal file
29
examples/screenshot-fullpage.js
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const playwright = require('playwright');
|
||||
const devices = require('playwright/DeviceDescriptors');
|
||||
|
||||
(async() => {
|
||||
const browser = await playwright.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.emulate(devices['iPhone 6']);
|
||||
await page.goto('https://www.nytimes.com/');
|
||||
await page.screenshot({path: 'full.png', fullPage: true});
|
||||
await browser.close();
|
||||
})();
|
27
examples/screenshot.js
Normal file
27
examples/screenshot.js
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const playwright = require('playwright');
|
||||
|
||||
(async() => {
|
||||
const browser = await playwright.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto('http://example.com');
|
||||
await page.screenshot({path: 'example.png'});
|
||||
await browser.close();
|
||||
})();
|
55
examples/search.js
Normal file
55
examples/search.js
Normal file
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @fileoverview Search developers.google.com/web for articles tagged
|
||||
* "Headless Chrome" and scrape results from the results page.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const playwright = require('playwright');
|
||||
|
||||
(async() => {
|
||||
const browser = await playwright.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto('https://developers.google.com/web/');
|
||||
|
||||
// Type into search box.
|
||||
await page.type('#searchbox input', 'Headless Chrome');
|
||||
|
||||
// Wait for suggest overlay to appear and click "show all results".
|
||||
const allResultsSelector = '.devsite-suggest-all-results';
|
||||
await page.waitForSelector(allResultsSelector);
|
||||
await page.click(allResultsSelector);
|
||||
|
||||
// Wait for the results page to load and display the results.
|
||||
const resultsSelector = '.gsc-results .gsc-thumbnail-inside a.gs-title';
|
||||
await page.waitForSelector(resultsSelector);
|
||||
|
||||
// Extract the results from the page.
|
||||
const links = await page.evaluate(resultsSelector => {
|
||||
const anchors = Array.from(document.querySelectorAll(resultsSelector));
|
||||
return anchors.map(anchor => {
|
||||
const title = anchor.textContent.split('|')[0].trim();
|
||||
return `${title} - ${anchor.href}`;
|
||||
});
|
||||
}, resultsSelector);
|
||||
console.log(links.join('\n'));
|
||||
|
||||
await browser.close();
|
||||
})();
|
29
firefox.js
Normal file
29
firefox.js
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const {helper} = require('./lib/helper');
|
||||
const api = require('./lib/api');
|
||||
for (const className in api.Firefox) {
|
||||
// Playwright-web excludes certain classes from bundle, e.g. BrowserFetcher.
|
||||
if (typeof api.Firefox[className] === 'function')
|
||||
helper.installAsyncStackHooks(api.Firefox[className]);
|
||||
}
|
||||
|
||||
// If node does not support async await, use the compiled version.
|
||||
const {Playwright} = require('./lib/firefox/Playwright');
|
||||
const packageJson = require('./package.json');
|
||||
|
||||
module.exports = new Playwright(__dirname, packageJson.playwright.firefox_revision);
|
137
install.js
Normal file
137
install.js
Normal file
@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// playwright-core should not install anything.
|
||||
if (require('./package.json').name === 'playwright-core')
|
||||
return;
|
||||
|
||||
for (const browser of ['Chromium', 'Firefox', 'WebKit']) {
|
||||
const templates = [
|
||||
`PLAYWRIGHT_SKIP_${browser}_DOWNLOAD`,
|
||||
`NPM_CONFIG_PLAYWRIGHT_SKIP_${browser}_DOWNLOAD`,
|
||||
`NPM_PACKAGE_CONFIG_PLAYWRIGHT_SKIP_${browser}_DOWNLOAD`,
|
||||
];
|
||||
const varNames = [...templates.map(n => n.toUpperCase()), ...templates.map(n => n.toLowerCase())];
|
||||
for (const varName of varNames) {
|
||||
if (process.env[varName.toUpperCase()]) {
|
||||
logPolitely(`**INFO** Skipping ${browser} download. "${varName}" environment variable was found.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const downloadHost = process.env.PLAYWRIGHT_DOWNLOAD_HOST || process.env.npm_config_playwright_download_host || process.env.npm_package_config_playwright_download_host;
|
||||
|
||||
|
||||
if (require('fs').existsSync(require('path').join(__dirname, 'src'))) {
|
||||
try {
|
||||
require('child_process').execSync('npm run build', {
|
||||
stdio: 'ignore'
|
||||
});
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
|
||||
(async function() {
|
||||
const {generateWebKitProtocol, generateChromeProtocol} = require('./utils/protocol-types-generator/') ;
|
||||
|
||||
const chromeRevision = await downloadBrowser('chromium', require('./chromium').createBrowserFetcher({host: downloadHost}));
|
||||
await generateChromeProtocol(chromeRevision);
|
||||
|
||||
await downloadBrowser('firefox', require('./firefox').createBrowserFetcher({host: downloadHost}));
|
||||
|
||||
const webkitRevision = await downloadBrowser('webkit', require('./webkit').createBrowserFetcher({host: downloadHost}));
|
||||
await generateWebKitProtocol(webkitRevision);
|
||||
})();
|
||||
function getRevision(browser) {
|
||||
if (browser === 'chromium')
|
||||
return process.env.PLAYWRIGHT_CHROMIUM_REVISION || process.env.npm_config_playwright_chromium_revision || process.env.npm_package_config_playwright_chromium_revision || require('./package.json').playwright.chromium_revision;
|
||||
if (browser === 'firefox')
|
||||
return process.env.PLAYWRIGHT_FIREFOX_REVISION || process.env.npm_config_playwright_firefox_revision || process.env.npm_package_config_playwright_firefox_revision || require('./package.json').playwright.firefox_revision;
|
||||
if (browser === 'webkit')
|
||||
return process.env.PLAYWRIGHT_WEBKIT_REVISION || process.env.npm_config_playwright_webkit_revision || process.env.npm_package_config_playwright_webkit_revision || require('./package.json').playwright.webkit_revision;
|
||||
}
|
||||
async function downloadBrowser(browser, browserFetcher) {
|
||||
const revision = getRevision(browser);
|
||||
|
||||
const revisionInfo = browserFetcher.revisionInfo(revision);
|
||||
|
||||
// Do nothing if the revision is already downloaded.
|
||||
if (revisionInfo.local)
|
||||
return revisionInfo;
|
||||
|
||||
// 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;
|
||||
|
||||
let progressBar = null;
|
||||
let lastDownloadedBytes = 0;
|
||||
function onProgress(downloadedBytes, totalBytes) {
|
||||
if (!progressBar) {
|
||||
const ProgressBar = require('progress');
|
||||
progressBar = new ProgressBar(`Downloading ${browser} ${revision} - ${toMegabytes(totalBytes)} [:bar] :percent :etas `, {
|
||||
complete: '=',
|
||||
incomplete: ' ',
|
||||
width: 20,
|
||||
total: totalBytes,
|
||||
});
|
||||
}
|
||||
const delta = downloadedBytes - lastDownloadedBytes;
|
||||
lastDownloadedBytes = downloadedBytes;
|
||||
progressBar.tick(delta);
|
||||
}
|
||||
|
||||
try {
|
||||
await browserFetcher.download(revisionInfo.revision, onProgress);
|
||||
} catch(error) {
|
||||
console.error(`ERROR: Failed to download ${browser} ${revision}! Set "PLAYWRIGHT_SKIP_${browser.toUpperCase()}_DOWNLOAD" env variable to skip download.`);
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
logPolitely(`${browser} downloaded to ${revisionInfo.folderPath}`);
|
||||
const localRevisions = await browserFetcher.localRevisions();
|
||||
// Remove previous chromium revisions.
|
||||
const cleanupOldVersions = localRevisions.filter(revision => revision !== revisionInfo.revision).map(revision => browserFetcher.remove(revision));
|
||||
await Promise.all([...cleanupOldVersions]);
|
||||
if (browser === 'firefox') {
|
||||
const installFirefoxPreferences = require('./misc/install-preferences');
|
||||
await installFirefoxPreferences(revisionInfo.executablePath);
|
||||
}
|
||||
return revisionInfo;
|
||||
}
|
||||
|
||||
|
||||
function toMegabytes(bytes) {
|
||||
const mb = bytes / 1024 / 1024;
|
||||
return `${Math.round(mb * 10) / 10} Mb`;
|
||||
}
|
||||
|
||||
function logPolitely(toBeLogged) {
|
||||
const logLevel = process.env.npm_config_loglevel;
|
||||
const logLevelDisplay = ['silent', 'error', 'warn'].indexOf(logLevel) > -1;
|
||||
|
||||
if (!logLevelDisplay)
|
||||
console.log(toBeLogged);
|
||||
}
|
||||
|
3
misc/00-playwright-prefs.js
Normal file
3
misc/00-playwright-prefs.js
Normal file
@ -0,0 +1,3 @@
|
||||
// Any comment. You must start the file with a single-line comment!
|
||||
pref("general.config.filename", "playwright.cfg");
|
||||
pref("general.config.obscure_value", 0);
|
59
misc/install-preferences.js
Normal file
59
misc/install-preferences.js
Normal file
@ -0,0 +1,59 @@
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const util = require('util');
|
||||
|
||||
// Install browser preferences after downloading and unpacking
|
||||
// firefox instances.
|
||||
// Based on: https://developer.mozilla.org/en-US/docs/Mozilla/Firefox/Enterprise_deployment_before_60#Configuration
|
||||
async function installFirefoxPreferences(executablePath) {
|
||||
const firefoxFolder = path.dirname(executablePath);
|
||||
const mkdirAsync = util.promisify(fs.mkdir.bind(fs));
|
||||
|
||||
let prefPath = '';
|
||||
let configPath = '';
|
||||
if (os.platform() === 'darwin') {
|
||||
prefPath = path.join(firefoxFolder, '..', 'Resources', 'defaults', 'pref');
|
||||
configPath = path.join(firefoxFolder, '..', 'Resources');
|
||||
} else if (os.platform() === 'linux') {
|
||||
if (!fs.existsSync(path.join(firefoxFolder, 'browser', 'defaults')))
|
||||
await mkdirAsync(path.join(firefoxFolder, 'browser', 'defaults'));
|
||||
if (!fs.existsSync(path.join(firefoxFolder, 'browser', 'defaults', 'preferences')))
|
||||
await mkdirAsync(path.join(firefoxFolder, 'browser', 'defaults', 'preferences'));
|
||||
prefPath = path.join(firefoxFolder, 'browser', 'defaults', 'preferences');
|
||||
configPath = firefoxFolder;
|
||||
} else if (os.platform() === 'win32') {
|
||||
prefPath = path.join(firefoxFolder, 'defaults', 'pref');
|
||||
configPath = firefoxFolder;
|
||||
} else {
|
||||
throw new Error('Unsupported platform: ' + os.platform());
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
copyFile({
|
||||
from: path.join(__dirname, '00-playwright-prefs.js'),
|
||||
to: path.join(prefPath, '00-playwright-prefs.js'),
|
||||
}),
|
||||
copyFile({
|
||||
from: path.join(__dirname, 'playwright.cfg'),
|
||||
to: path.join(configPath, 'playwright.cfg'),
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function copyFile({from, to}) {
|
||||
var rd = fs.createReadStream(from);
|
||||
var wr = fs.createWriteStream(to);
|
||||
return new Promise(function(resolve, reject) {
|
||||
rd.on('error', reject);
|
||||
wr.on('error', reject);
|
||||
wr.on('finish', resolve);
|
||||
rd.pipe(wr);
|
||||
}).catch(function(error) {
|
||||
rd.destroy();
|
||||
wr.end();
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = installFirefoxPreferences;
|
212
misc/playwright.cfg
Normal file
212
misc/playwright.cfg
Normal file
@ -0,0 +1,212 @@
|
||||
// Any comment. You must start the file with a comment!
|
||||
|
||||
// Make sure Shield doesn't hit the network.
|
||||
// pref("app.normandy.api_url", "");
|
||||
pref("app.normandy.enabled", false);
|
||||
|
||||
// Disable updater
|
||||
pref("app.update.enabled", false);
|
||||
// make absolutely sure it is really off
|
||||
pref("app.update.auto", false);
|
||||
pref("app.update.mode", 0);
|
||||
pref("app.update.service.enabled", false);
|
||||
|
||||
// Dislabe newtabpage
|
||||
pref("browser.startup.homepage", 'about:blank');
|
||||
pref("browser.newtabpage.enabled", false);
|
||||
// Disable topstories
|
||||
pref("browser.newtabpage.activity-stream.feeds.section.topstories", false);
|
||||
|
||||
// DevTools JSONViewer sometimes fails to load dependencies with its require.js.
|
||||
// This doesn't affect Playwright operations, but spams console with a lot of
|
||||
// unpleasant errors.
|
||||
// (bug 1424372)
|
||||
pref("devtools.jsonview.enabled", false);
|
||||
|
||||
// Increase the APZ content response timeout in tests to 1 minute.
|
||||
// This is to accommodate the fact that test environments tends to be
|
||||
// slower than production environments (with the b2g emulator being
|
||||
// the slowest of them all), resulting in the production timeout value
|
||||
// sometimes being exceeded and causing false-positive test failures.
|
||||
//
|
||||
// (bug 1176798, bug 1177018, bug 1210465)
|
||||
pref("apz.content_response_timeout", 60000);
|
||||
|
||||
// Allow creating files in content process - required for
|
||||
// |Page.setFileInputFiles| protocol method.
|
||||
pref("dom.file.createInChild", true);
|
||||
|
||||
// Indicate that the download panel has been shown once so that
|
||||
// whichever download test runs first doesn't show the popup
|
||||
// inconsistently.
|
||||
pref("browser.download.panel.shown", true);
|
||||
|
||||
// Background thumbnails in particular cause grief, and disabling
|
||||
// thumbnails in general cannot hurt
|
||||
pref("browser.pagethumbnails.capturing_disabled", true);
|
||||
|
||||
// Disable safebrowsing components.
|
||||
pref("browser.safebrowsing.blockedURIs.enabled", false);
|
||||
pref("browser.safebrowsing.downloads.enabled", false);
|
||||
pref("browser.safebrowsing.passwords.enabled", false);
|
||||
pref("browser.safebrowsing.malware.enabled", false);
|
||||
pref("browser.safebrowsing.phishing.enabled", false);
|
||||
|
||||
// Disable updates to search engines.
|
||||
pref("browser.search.update", false);
|
||||
|
||||
// Do not restore the last open set of tabs if the browser has crashed
|
||||
pref("browser.sessionstore.resume_from_crash", false);
|
||||
|
||||
// Don't check for the default web browser during startup.
|
||||
pref("browser.shell.checkDefaultBrowser", false);
|
||||
|
||||
// Do not redirect user when a milstone upgrade of Firefox is detected
|
||||
pref("browser.startup.homepage_override.mstone", "ignore");
|
||||
|
||||
// Disable browser animations (tabs, fullscreen, sliding alerts)
|
||||
pref("toolkit.cosmeticAnimations.enabled", false);
|
||||
|
||||
// Close the window when the last tab gets closed
|
||||
pref("browser.tabs.closeWindowWithLastTab", true);
|
||||
|
||||
// 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
|
||||
pref("browser.tabs.disableBackgroundZombification", false);
|
||||
|
||||
// Do not warn when closing all open tabs
|
||||
pref("browser.tabs.warnOnClose", false);
|
||||
|
||||
// Do not warn when closing all other open tabs
|
||||
pref("browser.tabs.warnOnCloseOtherTabs", false);
|
||||
|
||||
// Do not warn when multiple tabs will be opened
|
||||
pref("browser.tabs.warnOnOpen", false);
|
||||
|
||||
// Disable first run splash page on Windows 10
|
||||
pref("browser.usedOnWindows10.introURL", "");
|
||||
|
||||
// Disable the UI tour.
|
||||
//
|
||||
// Should be set in profile.
|
||||
pref("browser.uitour.enabled", false);
|
||||
|
||||
// Turn off search suggestions in the location bar so as not to trigger
|
||||
// network connections.
|
||||
pref("browser.urlbar.suggest.searches", false);
|
||||
|
||||
// Do not warn on quitting Firefox
|
||||
pref("browser.warnOnQuit", false);
|
||||
|
||||
// Do not show datareporting policy notifications which can
|
||||
// interfere with tests
|
||||
pref(
|
||||
"datareporting.healthreport.documentServerURI",
|
||||
"http://%(server)s/dummy/healthreport/",
|
||||
);
|
||||
pref("datareporting.healthreport.logging.consoleEnabled", false);
|
||||
pref("datareporting.healthreport.service.enabled", false);
|
||||
pref("datareporting.healthreport.service.firstRun", false);
|
||||
pref("datareporting.healthreport.uploadEnabled", false);
|
||||
pref("datareporting.policy.dataSubmissionEnabled", false);
|
||||
pref("datareporting.policy.dataSubmissionPolicyAccepted", false);
|
||||
pref("datareporting.policy.dataSubmissionPolicyBypassNotification", true);
|
||||
|
||||
// Automatically unload beforeunload alerts
|
||||
pref("dom.disable_beforeunload", false);
|
||||
|
||||
// Disable popup-blocker
|
||||
pref("dom.disable_open_during_load", false);
|
||||
|
||||
// Disable the ProcessHangMonitor
|
||||
pref("dom.ipc.reportProcessHangs", false);
|
||||
|
||||
// Disable slow script dialogues
|
||||
pref("dom.max_chrome_script_run_time", 0);
|
||||
pref("dom.max_script_run_time", 0);
|
||||
|
||||
// Only load extensions from the application and user profile
|
||||
// AddonManager.SCOPE_PROFILE + AddonManager.SCOPE_APPLICATION
|
||||
pref("extensions.autoDisableScopes", 0);
|
||||
pref("extensions.enabledScopes", 5);
|
||||
|
||||
// Disable metadata caching for installed add-ons by default
|
||||
pref("extensions.getAddons.cache.enabled", false);
|
||||
|
||||
// Disable installing any distribution extensions or add-ons.
|
||||
pref("extensions.installDistroAddons", false);
|
||||
|
||||
// Turn off extension updates so they do not bother tests
|
||||
pref("extensions.update.enabled", false);
|
||||
pref("extensions.update.notifyUser", false);
|
||||
|
||||
// Make sure opening about:addons will not hit the network
|
||||
pref(
|
||||
"extensions.webservice.discoverURL",
|
||||
"http://%(server)s/dummy/discoveryURL",
|
||||
);
|
||||
|
||||
pref("extensions.screenshots.disabled", true);
|
||||
pref("extensions.screenshots.upload-disabled", true);
|
||||
|
||||
// Allow the application to have focus even it runs in the background
|
||||
pref("focusmanager.testmode", true);
|
||||
|
||||
// Disable useragent updates
|
||||
pref("general.useragent.updates.enabled", false);
|
||||
|
||||
// Always use network provider for geolocation tests so we bypass the
|
||||
// macOS dialog raised by the corelocation provider
|
||||
pref("geo.provider.testing", true);
|
||||
|
||||
// Do not scan Wifi
|
||||
pref("geo.wifi.scan", false);
|
||||
|
||||
// Show chrome errors and warnings in the error console
|
||||
pref("javascript.options.showInConsole", true);
|
||||
|
||||
// Do not prompt with long usernames or passwords in URLs
|
||||
pref("network.http.phishy-userpass-length", 255);
|
||||
|
||||
// Do not prompt for temporary redirects
|
||||
pref("network.http.prompt-temp-redirect", false);
|
||||
|
||||
// Disable speculative connections so they are not reported as leaking
|
||||
// when they are hanging around
|
||||
pref("network.http.speculative-parallel-limit", 0);
|
||||
|
||||
// Do not automatically switch between offline and online
|
||||
pref("network.manage-offline-status", false);
|
||||
|
||||
// Make sure SNTP requests do not hit the network
|
||||
pref("network.sntp.pools", "%(server)s");
|
||||
|
||||
// Local documents have access to all other local documents,
|
||||
// including directory listings
|
||||
pref("security.fileuri.strict_origin_policy", false);
|
||||
|
||||
// Tests do not wait for the notification button security delay
|
||||
pref("security.notification_enable_delay", 0);
|
||||
|
||||
// Ensure blocklist updates do not hit the network
|
||||
pref("services.settings.server", "http://%(server)s/dummy/blocklist/");
|
||||
|
||||
// Do not automatically fill sign-in forms with known usernames and
|
||||
// passwords
|
||||
pref("signon.autofillForms", false);
|
||||
|
||||
// Disable password capture, so that tests that include forms are not
|
||||
// influenced by the presence of the persistent doorhanger notification
|
||||
pref("signon.rememberSignons", false);
|
||||
|
||||
// Disable first-run welcome page
|
||||
pref("startup.homepage_welcome_url", "about:blank");
|
||||
pref("startup.homepage_welcome_url.additional", "");
|
||||
|
||||
// Prevent starting into safe mode after application crashes
|
||||
pref("toolkit.startup.max_resumed_crashes", -1);
|
||||
lockPref("toolkit.crashreporter.enabled", false);
|
||||
|
||||
// Disable crash reporter.
|
||||
Components.classes["@mozilla.org/toolkit/crash-reporter;1"].getService(Components.interfaces.nsICrashReporter).submitReports = false;
|
75
package.json
Normal file
75
package.json
Normal file
@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "playwright",
|
||||
"version": "2.0.0-post",
|
||||
"description": "A high-level API to control headless Chrome over the DevTools Protocol",
|
||||
"main": "index.js",
|
||||
"repository": "github:Microsoft/playwright",
|
||||
"engines": {
|
||||
"node": ">=8.16.0"
|
||||
},
|
||||
"playwright": {
|
||||
"chromium_revision": "706915",
|
||||
"firefox_revision": "1",
|
||||
"webkit_revision": "1"
|
||||
},
|
||||
"scripts": {
|
||||
"unit": "node test/test.js",
|
||||
"funit": "cross-env BROWSER=firefox node test/test.js",
|
||||
"wunit": "cross-env BROWSER=webkit node test/test.js",
|
||||
"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",
|
||||
"install": "node install.js",
|
||||
"lint": "([ \"$CI\" = true ] && eslint --quiet -f codeframe --ext js,ts ./src || eslint --ext js,ts ./src) && npm run tsc && npm run doc",
|
||||
"doc": "node utils/doclint/cli.js",
|
||||
"coverage": "cross-env COVERAGE=true npm run unit",
|
||||
"tsc": "tsc -p .",
|
||||
"build": "npm run tsc",
|
||||
"watch": "tsc -w -p .",
|
||||
"apply-next-version": "node utils/apply_next_version.js",
|
||||
"bundle": "npx browserify -r ./index.js:playwright -o utils/browser/playwright-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"
|
||||
},
|
||||
"author": "The Chromium Authors",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"debug": "^4.1.0",
|
||||
"extract-zip": "^1.6.6",
|
||||
"https-proxy-agent": "^3.0.0",
|
||||
"mime": "^2.0.3",
|
||||
"progress": "^2.0.1",
|
||||
"proxy-from-env": "^1.0.0",
|
||||
"rimraf": "^2.6.1",
|
||||
"ws": "^6.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/debug": "0.0.31",
|
||||
"@types/extract-zip": "^1.6.2",
|
||||
"@types/mime": "^2.0.0",
|
||||
"@types/node": "^8.10.34",
|
||||
"@types/rimraf": "^2.0.2",
|
||||
"@types/ws": "^6.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^2.6.1",
|
||||
"@typescript-eslint/parser": "^2.6.1",
|
||||
"commonmark": "^0.28.1",
|
||||
"cross-env": "^5.0.5",
|
||||
"eslint": "^6.6.0",
|
||||
"esprima": "^4.0.0",
|
||||
"jpeg-js": "^0.3.4",
|
||||
"minimist": "^1.2.0",
|
||||
"ncp": "^2.0.0",
|
||||
"pixelmatch": "^4.0.2",
|
||||
"pngjs": "^3.3.3",
|
||||
"text-diff": "^1.0.1",
|
||||
"typescript": "3.6.3"
|
||||
},
|
||||
"browser": {
|
||||
"./lib/BrowserFetcher.js": false,
|
||||
"ws": "./utils/browser/WebSocket",
|
||||
"fs": false,
|
||||
"child_process": false,
|
||||
"rimraf": false,
|
||||
"readline": false
|
||||
}
|
||||
}
|
16
src/.eslintrc.js
Normal file
16
src/.eslintrc.js
Normal file
@ -0,0 +1,16 @@
|
||||
module.exports = {
|
||||
"extends": "../.eslintrc.js",
|
||||
/**
|
||||
* ESLint rules
|
||||
*
|
||||
* All available rules: http://eslint.org/docs/rules/
|
||||
*
|
||||
* Rules take the following form:
|
||||
* "rule-name", [severity, { opts }]
|
||||
* Severity: 2 == error, 1 == warning, 0 == off.
|
||||
*/
|
||||
"rules": {
|
||||
"no-console": [2, { "allow": ["warn", "error", "assert", "timeStamp", "time", "timeEnd"] }],
|
||||
"no-debugger": 0,
|
||||
}
|
||||
};
|
6
src/ConnectionTransport.ts
Normal file
6
src/ConnectionTransport.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export interface ConnectionTransport {
|
||||
send(s: string): void;
|
||||
close(): void;
|
||||
onmessage?: (message: string) => void,
|
||||
onclose?: () => void,
|
||||
}
|
871
src/DeviceDescriptors.ts
Normal file
871
src/DeviceDescriptors.ts
Normal file
@ -0,0 +1,871 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export const DeviceDescriptors = [
|
||||
{
|
||||
'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
|
||||
}
|
||||
}
|
||||
];
|
26
src/Errors.ts
Normal file
26
src/Errors.ts
Normal file
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
class CustomError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
export class TimeoutError extends CustomError {}
|
55
src/Events.ts
Normal file
55
src/Events.ts
Normal file
@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2019 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export 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',
|
||||
},
|
||||
|
||||
};
|
45
src/TimeoutSettings.ts
Normal file
45
src/TimeoutSettings.ts
Normal file
@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Copyright 2019 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const DEFAULT_TIMEOUT = 30000;
|
||||
|
||||
export class TimeoutSettings {
|
||||
private _defaultTimeout: number | null = null;
|
||||
private _defaultNavigationTimeout: number | null = null;
|
||||
|
||||
setDefaultTimeout(timeout: number) {
|
||||
this._defaultTimeout = timeout;
|
||||
}
|
||||
|
||||
setDefaultNavigationTimeout(timeout: number) {
|
||||
this._defaultNavigationTimeout = timeout;
|
||||
}
|
||||
|
||||
navigationTimeout(): number {
|
||||
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;
|
||||
}
|
||||
}
|
285
src/USKeyboardLayout.ts
Normal file
285
src/USKeyboardLayout.ts
Normal file
@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
type KeyDefinition = {
|
||||
keyCode?: number
|
||||
shiftKeyCode?: number
|
||||
key?: string
|
||||
shiftKey?: string
|
||||
code?: string
|
||||
text?: string
|
||||
shiftText?: string
|
||||
location ?: number
|
||||
}
|
||||
|
||||
export const keyDefinitions: { [s: string]: KeyDefinition; } = {
|
||||
'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},
|
||||
};
|
88
src/api.ts
Normal file
88
src/api.ts
Normal file
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Copyright 2019 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
export = {
|
||||
Chromium: {
|
||||
Accessibility: require('./chromium/Accessibility').Accessibility,
|
||||
Browser: require('./chromium/Browser').Browser,
|
||||
BrowserContext: require('./chromium/BrowserContext').BrowserContext,
|
||||
BrowserFetcher: require('./chromium/BrowserFetcher').BrowserFetcher,
|
||||
CDPSession: require('./chromium/Connection').CDPSession,
|
||||
ConsoleMessage: require('./chromium/Page').ConsoleMessage,
|
||||
Coverage: require('./chromium/Coverage').Coverage,
|
||||
Dialog: require('./chromium/Dialog').Dialog,
|
||||
ElementHandle: require('./chromium/JSHandle').ElementHandle,
|
||||
ExecutionContext: require('./chromium/ExecutionContext').ExecutionContext,
|
||||
FileChooser: require('./chromium/Page').FileChooser,
|
||||
Frame: require('./chromium/Frame').Frame,
|
||||
JSHandle: require('./chromium/JSHandle').JSHandle,
|
||||
Keyboard: require('./chromium/Input').Keyboard,
|
||||
Mouse: require('./chromium/Input').Mouse,
|
||||
Page: require('./chromium/Page').Page,
|
||||
Playwright: require('./chromium/Playwright').Playwright,
|
||||
Request: require('./chromium/NetworkManager').Request,
|
||||
Response: require('./chromium/NetworkManager').Response,
|
||||
SecurityDetails: require('./chromium/NetworkManager').SecurityDetails,
|
||||
Target: require('./chromium/Target').Target,
|
||||
TimeoutError: require('./Errors').TimeoutError,
|
||||
Touchscreen: require('./chromium/Input').Touchscreen,
|
||||
Tracing: require('./chromium/Tracing').Tracing,
|
||||
Worker: require('./chromium/Worker').Worker,
|
||||
},
|
||||
Firefox: {
|
||||
Accessibility: require('./firefox/Accessibility').Accessibility,
|
||||
Browser: require('./firefox/Browser').Browser,
|
||||
BrowserContext: require('./firefox/Browser').BrowserContext,
|
||||
BrowserFetcher: require('./firefox/BrowserFetcher').BrowserFetcher,
|
||||
CDPSession: require('./firefox/Connection').CDPSession,
|
||||
ConsoleMessage: require('./firefox/Page').ConsoleMessage,
|
||||
Dialog: require('./firefox/Dialog').Dialog,
|
||||
ElementHandle: require('./firefox/JSHandle').ElementHandle,
|
||||
ExecutionContext: require('./firefox/ExecutionContext').ExecutionContext,
|
||||
FileChooser: require('./firefox/Page').FileChooser,
|
||||
Frame: require('./firefox/FrameManager').Frame,
|
||||
JSHandle: require('./firefox/JSHandle').JSHandle,
|
||||
Keyboard: require('./firefox/Input').Keyboard,
|
||||
Mouse: require('./firefox/Input').Mouse,
|
||||
Page: require('./firefox/Page').Page,
|
||||
Playwright: require('./firefox/Playwright').Playwright,
|
||||
Request: require('./firefox/NetworkManager').Request,
|
||||
Response: require('./firefox/NetworkManager').Response,
|
||||
SecurityDetails: require('./firefox/NetworkManager').SecurityDetails,
|
||||
Target: require('./firefox/Browser').Target,
|
||||
TimeoutError: require('./Errors').TimeoutError,
|
||||
Touchscreen: require('./firefox/Input').Touchscreen,
|
||||
},
|
||||
WebKit: {
|
||||
Browser: require('./webkit/Browser').Browser,
|
||||
BrowserContext: require('./webkit/Browser').BrowserContext,
|
||||
BrowserFetcher: require('./webkit/BrowserFetcher'),
|
||||
ConsoleMessage: require('./webkit/Page').ConsoleMessage,
|
||||
ElementHandle: require('./webkit/JSHandle').ElementHandle,
|
||||
ExecutionContext: require('./webkit/ExecutionContext').ExecutionContext,
|
||||
Frame: require('./webkit/FrameManager').Frame,
|
||||
JSHandle: require('./webkit/JSHandle').JSHandle,
|
||||
Keyboard: require('./webkit/Input').Keyboard,
|
||||
Mouse: require('./webkit/Input').Mouse,
|
||||
Page: require('./webkit/Page').Page,
|
||||
Playwright: require('./webkit/Playwright').Playwright,
|
||||
Request: require('./webkit/NetworkManager').Request,
|
||||
Response: require('./webkit/NetworkManager').Response,
|
||||
Target: require('./webkit/Browser').Target,
|
||||
TimeoutError: require('./Errors').TimeoutError,
|
||||
Touchscreen: require('./webkit/Input').Touchscreen,
|
||||
}
|
||||
};
|
373
src/chromium/Accessibility.ts
Normal file
373
src/chromium/Accessibility.ts
Normal file
@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CDPSession } from './Connection';
|
||||
import { ElementHandle } from './JSHandle';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
type SerializedAXNode = {
|
||||
role: string,
|
||||
name?: string,
|
||||
value?: string|number,
|
||||
description?: string,
|
||||
|
||||
keyshortcuts?: string,
|
||||
roledescription?: string,
|
||||
valuetext?: string,
|
||||
|
||||
disabled?: boolean,
|
||||
expanded?: boolean,
|
||||
focused?: boolean,
|
||||
modal?: boolean,
|
||||
multiline?: boolean,
|
||||
multiselectable?: boolean,
|
||||
readonly?: boolean,
|
||||
required?: boolean,
|
||||
selected?: boolean,
|
||||
|
||||
checked?: boolean|'mixed',
|
||||
pressed?: boolean|'mixed',
|
||||
|
||||
level?: number,
|
||||
valuemin?: number,
|
||||
valuemax?: number,
|
||||
|
||||
autocomplete?: string,
|
||||
haspopup?: string,
|
||||
invalid?: string,
|
||||
orientation?: string,
|
||||
|
||||
children?: SerializedAXNode[]
|
||||
};
|
||||
|
||||
export class Accessibility {
|
||||
private _client: CDPSession;
|
||||
|
||||
constructor(client: CDPSession) {
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
async snapshot(options: {
|
||||
interestingOnly?: boolean;
|
||||
root?: ElementHandle | null;
|
||||
} = {}): Promise<SerializedAXNode> {
|
||||
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];
|
||||
|
||||
const interestingNodes: Set<AXNode> = new Set();
|
||||
collectInterestingNodes(interestingNodes, defaultRoot, false);
|
||||
if (!interestingNodes.has(needle))
|
||||
return null;
|
||||
return serializeTree(needle, interestingNodes)[0];
|
||||
}
|
||||
}
|
||||
|
||||
function collectInterestingNodes(collection: Set<AXNode>, node: AXNode, insideControl: boolean) {
|
||||
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);
|
||||
}
|
||||
|
||||
function serializeTree(node: AXNode, whitelistedNodes?: Set<AXNode>): SerializedAXNode[] {
|
||||
const children: SerializedAXNode[] = [];
|
||||
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 {
|
||||
_payload: Protocol.Accessibility.AXNode;
|
||||
_children: AXNode[] = [];
|
||||
private _richlyEditable = false;
|
||||
private _editable = false;
|
||||
private _focusable = false;
|
||||
private _expanded = false;
|
||||
private _hidden = false;
|
||||
private _name: string;
|
||||
private _role: string;
|
||||
private _cachedHasFocusableChild: boolean | undefined;
|
||||
|
||||
constructor(payload: Protocol.Accessibility.AXNode) {
|
||||
this._payload = payload;
|
||||
|
||||
this._name = this._payload.name ? this._payload.name.value : '';
|
||||
this._role = this._payload.role ? this._payload.role.value : 'Unknown';
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private _isPlainTextField(): boolean {
|
||||
if (this._richlyEditable)
|
||||
return false;
|
||||
if (this._editable)
|
||||
return true;
|
||||
return this._role === 'textbox' || this._role === 'ComboBox' || this._role === 'searchbox';
|
||||
}
|
||||
|
||||
private _isTextOnlyObject(): boolean {
|
||||
const role = this._role;
|
||||
return (role === 'LineBreak' || role === 'text' ||
|
||||
role === 'InlineTextBox');
|
||||
}
|
||||
|
||||
private _hasFocusableChild(): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
find(predicate: (arg0: AXNode) => boolean): AXNode | null {
|
||||
if (predicate(this))
|
||||
return this;
|
||||
for (const child of this._children) {
|
||||
const result = child.find(predicate);
|
||||
if (result)
|
||||
return result;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
isLeafNode(): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
isControl(): boolean {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
isInteresting(insideControl: boolean): boolean {
|
||||
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;
|
||||
}
|
||||
|
||||
serialize(): SerializedAXNode {
|
||||
const properties: Map<string, number | string | boolean> = 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);
|
||||
|
||||
const node: {[x in keyof SerializedAXNode]: any} = {
|
||||
role: this._role
|
||||
};
|
||||
|
||||
const userStringProperties: Array<keyof SerializedAXNode> = [
|
||||
'name',
|
||||
'value',
|
||||
'description',
|
||||
'keyshortcuts',
|
||||
'roledescription',
|
||||
'valuetext',
|
||||
];
|
||||
for (const userStringProperty of userStringProperties) {
|
||||
if (!properties.has(userStringProperty))
|
||||
continue;
|
||||
node[userStringProperty] = properties.get(userStringProperty);
|
||||
}
|
||||
|
||||
const booleanProperties: Array<keyof SerializedAXNode> = [
|
||||
'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;
|
||||
}
|
||||
|
||||
const tristateProperties: Array<keyof SerializedAXNode> = [
|
||||
'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;
|
||||
}
|
||||
const numericalProperties: Array<keyof SerializedAXNode> = [
|
||||
'level',
|
||||
'valuemax',
|
||||
'valuemin',
|
||||
];
|
||||
for (const numericalProperty of numericalProperties) {
|
||||
if (!properties.has(numericalProperty))
|
||||
continue;
|
||||
node[numericalProperty] = properties.get(numericalProperty);
|
||||
}
|
||||
const tokenProperties: Array<keyof SerializedAXNode> = [
|
||||
'autocomplete',
|
||||
'haspopup',
|
||||
'invalid',
|
||||
'orientation',
|
||||
];
|
||||
for (const tokenProperty of tokenProperties) {
|
||||
const value = properties.get(tokenProperty);
|
||||
if (!value || value === 'false')
|
||||
continue;
|
||||
node[tokenProperty] = value;
|
||||
}
|
||||
return node as SerializedAXNode;
|
||||
}
|
||||
|
||||
static createTree(payloads: Protocol.Accessibility.AXNode[]): AXNode {
|
||||
const nodeById: Map<string, AXNode> = 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;
|
||||
}
|
||||
}
|
220
src/chromium/Browser.ts
Normal file
220
src/chromium/Browser.ts
Normal file
@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as childProcess from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import { Events } from '../Events';
|
||||
import { assert, helper } from '../helper';
|
||||
import { BrowserContext } from './BrowserContext';
|
||||
import { Connection, ConnectionEvents } from './Connection';
|
||||
import { Page, Viewport } from './Page';
|
||||
import { Target } from './Target';
|
||||
import { TaskQueue } from './TaskQueue';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
export class Browser extends EventEmitter {
|
||||
private _ignoreHTTPSErrors: boolean;
|
||||
private _defaultViewport: Viewport;
|
||||
private _process: childProcess.ChildProcess;
|
||||
private _screenshotTaskQueue = new TaskQueue();
|
||||
private _connection: Connection;
|
||||
private _closeCallback: () => Promise<void>;
|
||||
private _defaultContext: BrowserContext;
|
||||
private _contexts = new Map<string, BrowserContext>();
|
||||
_targets = new Map<string, Target>();
|
||||
|
||||
static async create(
|
||||
connection: Connection,
|
||||
contextIds: string[],
|
||||
ignoreHTTPSErrors: boolean,
|
||||
defaultViewport: Viewport | null,
|
||||
process: childProcess.ChildProcess | null,
|
||||
closeCallback?: (() => Promise<void>)) {
|
||||
const browser = new Browser(connection, contextIds, ignoreHTTPSErrors, defaultViewport, process, closeCallback);
|
||||
await connection.send('Target.setDiscoverTargets', {discover: true});
|
||||
return browser;
|
||||
}
|
||||
|
||||
constructor(
|
||||
connection: Connection,
|
||||
contextIds: string[],
|
||||
ignoreHTTPSErrors: boolean,
|
||||
defaultViewport: Viewport | null,
|
||||
process: childProcess.ChildProcess | null,
|
||||
closeCallback?: (() => Promise<void>)) {
|
||||
super();
|
||||
this._ignoreHTTPSErrors = ignoreHTTPSErrors;
|
||||
this._defaultViewport = defaultViewport;
|
||||
this._process = process;
|
||||
this._connection = connection;
|
||||
this._closeCallback = closeCallback || (() => Promise.resolve());
|
||||
|
||||
this._defaultContext = new BrowserContext(this._connection, this, null);
|
||||
for (const contextId of contextIds)
|
||||
this._contexts.set(contextId, new BrowserContext(this._connection, this, contextId));
|
||||
|
||||
this._connection.on(ConnectionEvents.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));
|
||||
}
|
||||
|
||||
process(): childProcess.ChildProcess | null {
|
||||
return this._process;
|
||||
}
|
||||
|
||||
async createIncognitoBrowserContext(): Promise<BrowserContext> {
|
||||
const {browserContextId} = await this._connection.send('Target.createBrowserContext');
|
||||
const context = new BrowserContext(this._connection, this, browserContextId);
|
||||
this._contexts.set(browserContextId, context);
|
||||
return context;
|
||||
}
|
||||
|
||||
browserContexts(): BrowserContext[] {
|
||||
return [this._defaultContext, ...Array.from(this._contexts.values())];
|
||||
}
|
||||
|
||||
defaultBrowserContext(): BrowserContext {
|
||||
return this._defaultContext;
|
||||
}
|
||||
|
||||
async _disposeContext(contextId: string | null) {
|
||||
await this._connection.send('Target.disposeBrowserContext', {browserContextId: contextId || undefined});
|
||||
this._contexts.delete(contextId);
|
||||
}
|
||||
|
||||
async _targetCreated(event: Protocol.Target.targetCreatedPayload) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async _targetDestroyed(event: { targetId: string; }) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
_targetInfoChanged(event: Protocol.Target.targetInfoChangedPayload) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
wsEndpoint(): string {
|
||||
return this._connection.url();
|
||||
}
|
||||
|
||||
async newPage(): Promise<Page> {
|
||||
return this._defaultContext.newPage();
|
||||
}
|
||||
|
||||
async _createPageInContext(contextId: string | null): Promise<Page> {
|
||||
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;
|
||||
}
|
||||
|
||||
targets(): Target[] {
|
||||
return Array.from(this._targets.values()).filter(target => target._isInitialized);
|
||||
}
|
||||
|
||||
target(): Target {
|
||||
return this.targets().find(target => target.type() === 'browser');
|
||||
}
|
||||
|
||||
async waitForTarget(predicate: (arg0: Target) => boolean, options: { timeout?: number; } | undefined = {}): Promise<Target> {
|
||||
const {
|
||||
timeout = 30000
|
||||
} = options;
|
||||
const existingTarget = this.targets().find(predicate);
|
||||
if (existingTarget)
|
||||
return existingTarget;
|
||||
let resolve;
|
||||
const targetPromise = new Promise<Target>(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);
|
||||
}
|
||||
|
||||
function check(target: Target) {
|
||||
if (predicate(target))
|
||||
resolve(target);
|
||||
}
|
||||
}
|
||||
|
||||
async pages(): Promise<Page[]> {
|
||||
const contextPages = await Promise.all(this.browserContexts().map(context => context.pages()));
|
||||
// Flatten array.
|
||||
return contextPages.reduce((acc, x) => acc.concat(x), []);
|
||||
}
|
||||
|
||||
async version(): Promise<string> {
|
||||
const version = await this._getVersion();
|
||||
return version.product;
|
||||
}
|
||||
|
||||
async userAgent(): Promise<string> {
|
||||
const version = await this._getVersion();
|
||||
return version.userAgent;
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this._closeCallback.call(null);
|
||||
this.disconnect();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._connection.dispose();
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return !this._connection._closed;
|
||||
}
|
||||
|
||||
_getVersion(): Promise<any> {
|
||||
return this._connection.send('Browser.getVersion');
|
||||
}
|
||||
}
|
103
src/chromium/BrowserContext.ts
Normal file
103
src/chromium/BrowserContext.ts
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { assert } from '../helper';
|
||||
import { Browser } from './Browser';
|
||||
import { Connection } from './Connection';
|
||||
import { Page } from './Page';
|
||||
import { Target } from './Target';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
export class BrowserContext extends EventEmitter {
|
||||
private _connection: Connection;
|
||||
private _browser: Browser;
|
||||
private _id: string;
|
||||
|
||||
constructor(connection: Connection, browser: Browser, contextId: string | null) {
|
||||
super();
|
||||
this._connection = connection;
|
||||
this._browser = browser;
|
||||
this._id = contextId;
|
||||
}
|
||||
|
||||
targets(): Target[] {
|
||||
return this._browser.targets().filter(target => target.browserContext() === this);
|
||||
}
|
||||
|
||||
waitForTarget(predicate: (arg0: Target) => boolean, options: { timeout?: number; } | undefined): Promise<Target> {
|
||||
return this._browser.waitForTarget(target => target.browserContext() === this && predicate(target), options);
|
||||
}
|
||||
|
||||
async pages(): Promise<Page[]> {
|
||||
const pages = await Promise.all(
|
||||
this.targets()
|
||||
.filter(target => target.type() === 'page')
|
||||
.map(target => target.page())
|
||||
);
|
||||
return pages.filter(page => !!page);
|
||||
}
|
||||
|
||||
isIncognito(): boolean {
|
||||
return !!this._id;
|
||||
}
|
||||
|
||||
async overridePermissions(origin: string, userPermissions: string[]) {
|
||||
const webPermissionToProtocol = new Map<string, Protocol.Browser.PermissionType>([
|
||||
['geolocation', 'geolocation'],
|
||||
['midi', 'midi'],
|
||||
['notifications', 'notifications'],
|
||||
['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'],
|
||||
]);
|
||||
const permissions = userPermissions.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});
|
||||
}
|
||||
|
||||
newPage(): Promise<Page> {
|
||||
return this._browser._createPageInContext(this._id);
|
||||
}
|
||||
|
||||
browser(): Browser {
|
||||
return this._browser;
|
||||
}
|
||||
|
||||
async close() {
|
||||
assert(this._id, 'Non-incognito profiles cannot be closed!');
|
||||
await this._browser._disposeContext(this._id);
|
||||
}
|
||||
}
|
261
src/chromium/BrowserFetcher.ts
Normal file
261
src/chromium/BrowserFetcher.ts
Normal file
@ -0,0 +1,261 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as extract from 'extract-zip';
|
||||
import * as fs from 'fs';
|
||||
import * as ProxyAgent from 'https-proxy-agent';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
// @ts-ignore
|
||||
import { getProxyForUrl } from 'proxy-from-env';
|
||||
import * as removeRecursive from 'rimraf';
|
||||
import * as URL from 'url';
|
||||
import * as util from 'util';
|
||||
import { assert, helper } from '../helper';
|
||||
|
||||
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',
|
||||
};
|
||||
|
||||
function archiveName(platform: string, revision: string): string {
|
||||
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 downloadURL(platform: string, host: string, revision: string): string {
|
||||
return util.format(downloadURLs[platform], host, revision, archiveName(platform, revision));
|
||||
}
|
||||
|
||||
const readdirAsync = helper.promisify(fs.readdir.bind(fs));
|
||||
const mkdirAsync = helper.promisify(fs.mkdir.bind(fs));
|
||||
const unlinkAsync = helper.promisify(fs.unlink.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;
|
||||
}
|
||||
|
||||
export class BrowserFetcher {
|
||||
private _downloadsFolder: string;
|
||||
private _downloadHost: string;
|
||||
private _platform: string;
|
||||
|
||||
constructor(projectRoot: string, options: BrowserFetcherOptions = {}) {
|
||||
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());
|
||||
}
|
||||
assert(supportedPlatforms.includes(this._platform), 'Unsupported platform: ' + this._platform);
|
||||
}
|
||||
|
||||
platform(): string {
|
||||
return this._platform;
|
||||
}
|
||||
|
||||
canDownload(revision: string): Promise<boolean> {
|
||||
const url = downloadURL(this._platform, this._downloadHost, revision);
|
||||
let resolve;
|
||||
const promise = new Promise<boolean>(x => resolve = x);
|
||||
const request = httpRequest(url, 'HEAD', response => {
|
||||
resolve(response.statusCode === 200);
|
||||
});
|
||||
request.on('error', error => {
|
||||
console.error(error);
|
||||
resolve(false);
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
async download(revision: string, progressCallback: ((arg0: number, arg1: number) => void) | null): Promise<BrowserFetcherRevisionInfo> {
|
||||
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);
|
||||
}
|
||||
const revisionInfo = this.revisionInfo(revision);
|
||||
if (revisionInfo)
|
||||
await chmodAsync(revisionInfo.executablePath, 0o755);
|
||||
return revisionInfo;
|
||||
}
|
||||
|
||||
async localRevisions(): Promise<string[]> {
|
||||
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);
|
||||
}
|
||||
|
||||
async remove(revision: string) {
|
||||
const folderPath = this._getFolderPath(revision);
|
||||
assert(await existsAsync(folderPath), `Failed to remove: revision ${revision} is not downloaded`);
|
||||
await new Promise(fulfill => removeRecursive(folderPath, fulfill));
|
||||
}
|
||||
|
||||
revisionInfo(revision: string): BrowserFetcherRevisionInfo {
|
||||
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};
|
||||
}
|
||||
|
||||
_getFolderPath(revision: string): string {
|
||||
return path.join(this._downloadsFolder, this._platform + '-' + revision);
|
||||
}
|
||||
}
|
||||
|
||||
function parseFolderPath(folderPath: string): { platform: string; revision: string; } | null {
|
||||
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 downloadFile(url: string, destinationPath: string, progressCallback: ((arg0: number, arg1: number) => void) | null): Promise<any> {
|
||||
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(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);
|
||||
}
|
||||
}
|
||||
|
||||
function extractZip(zipPath: string, folderPath: string): Promise<Error | null> {
|
||||
return new Promise((fulfill, reject) => extract(zipPath, {dir: folderPath}, err => {
|
||||
if (err)
|
||||
reject(err);
|
||||
else
|
||||
fulfill();
|
||||
}));
|
||||
}
|
||||
|
||||
function httpRequest(url: string, method: string, response: (r: any) => void) {
|
||||
let options: any = 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 {
|
||||
const parsedProxyURL: any = 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;
|
||||
}
|
||||
|
||||
export type BrowserFetcherOptions = {
|
||||
platform?: string,
|
||||
path?: string,
|
||||
host ?: string,
|
||||
};
|
||||
|
||||
type BrowserFetcherRevisionInfo = {
|
||||
folderPath: string,
|
||||
executablePath: string,
|
||||
url: string,
|
||||
local: boolean,
|
||||
revision: string,
|
||||
};
|
219
src/chromium/Connection.ts
Normal file
219
src/chromium/Connection.ts
Normal file
@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {assert} from '../helper';
|
||||
import {Events} from '../Events';
|
||||
import * as debug from 'debug';
|
||||
import {EventEmitter} from 'events';
|
||||
import { ConnectionTransport } from '../ConnectionTransport';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
const debugProtocol = debug('playwright:protocol');
|
||||
|
||||
export const ConnectionEvents = {
|
||||
Disconnected: Symbol('ConnectionEvents.Disconnected')
|
||||
};
|
||||
|
||||
export class Connection extends EventEmitter {
|
||||
private _url: string;
|
||||
private _lastId = 0;
|
||||
private _callbacks = new Map<number, {resolve:(o: any) => void, reject: (e: Error) => void, error: Error, method: string}>();
|
||||
private _delay: number;
|
||||
private _transport: ConnectionTransport;
|
||||
private _sessions = new Map<string, CDPSession>();
|
||||
_closed = false;
|
||||
on: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
addListener: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
off: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
removeListener: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
once: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
|
||||
constructor(url: string, transport: ConnectionTransport, delay: number | undefined = 0) {
|
||||
super();
|
||||
this._url = url;
|
||||
this._delay = delay;
|
||||
|
||||
this._transport = transport;
|
||||
this._transport.onmessage = this._onMessage.bind(this);
|
||||
this._transport.onclose = this._onClose.bind(this);
|
||||
}
|
||||
|
||||
static fromSession(session: CDPSession): Connection {
|
||||
return session._connection;
|
||||
}
|
||||
|
||||
session(sessionId: string): CDPSession | null {
|
||||
return this._sessions.get(sessionId) || null;
|
||||
}
|
||||
|
||||
url(): string {
|
||||
return this._url;
|
||||
}
|
||||
|
||||
send<T extends keyof Protocol.CommandParameters>(
|
||||
method: T,
|
||||
params?: Protocol.CommandParameters[T]
|
||||
): Promise<Protocol.CommandReturnValues[T]> {
|
||||
const id = this._rawSend({method, params});
|
||||
return new Promise((resolve, reject) => {
|
||||
this._callbacks.set(id, {resolve, reject, error: new Error(), method});
|
||||
});
|
||||
}
|
||||
|
||||
_rawSend(message: any): number {
|
||||
const id = ++this._lastId;
|
||||
message = JSON.stringify(Object.assign({}, message, {id}));
|
||||
debugProtocol('SEND ► ' + message);
|
||||
this._transport.send(message);
|
||||
return id;
|
||||
}
|
||||
|
||||
async _onMessage(message: string) {
|
||||
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(ConnectionEvents.Disconnected);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._onClose();
|
||||
this._transport.close();
|
||||
}
|
||||
|
||||
async createSession(targetInfo: Protocol.Target.TargetInfo): Promise<CDPSession> {
|
||||
const {sessionId} = await this.send('Target.attachToTarget', {targetId: targetInfo.targetId, flatten: true});
|
||||
return this._sessions.get(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export const CDPSessionEvents = {
|
||||
Disconnected: Symbol('Events.CDPSession.Disconnected')
|
||||
};
|
||||
|
||||
export class CDPSession extends EventEmitter {
|
||||
_connection: Connection;
|
||||
private _callbacks = new Map<number, {resolve:(o: any) => void, reject: (e: Error) => void, error: Error, method: string}>();
|
||||
private _targetType: string;
|
||||
private _sessionId: string;
|
||||
on: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
addListener: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
off: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
removeListener: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
once: <T extends keyof Protocol.Events | symbol>(event: T, listener: (payload: T extends symbol ? any : Protocol.Events[T extends keyof Protocol.Events ? T : never]) => void) => this;
|
||||
|
||||
constructor(connection: Connection, targetType: string, sessionId: string) {
|
||||
super();
|
||||
this._connection = connection;
|
||||
this._targetType = targetType;
|
||||
this._sessionId = sessionId;
|
||||
}
|
||||
|
||||
send<T extends keyof Protocol.CommandParameters>(
|
||||
method: T,
|
||||
params?: Protocol.CommandParameters[T]
|
||||
): Promise<Protocol.CommandReturnValues[T]> {
|
||||
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});
|
||||
});
|
||||
}
|
||||
|
||||
_onMessage(object: { id?: number; method: string; params: any; error: { message: string; data: any; }; result?: any; }) {
|
||||
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(CDPSessionEvents.Disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
function createProtocolError(error: Error, method: string, object: { error: { message: string; data: any; }; }): Error {
|
||||
let message = `Protocol error (${method}): ${object.error.message}`;
|
||||
if ('data' in object.error)
|
||||
message += ` ${object.error.data}`;
|
||||
return rewriteError(error, message);
|
||||
}
|
||||
|
||||
function rewriteError(error: Error, message: string): Error {
|
||||
error.message = message;
|
||||
return error;
|
||||
}
|
298
src/chromium/Coverage.ts
Normal file
298
src/chromium/Coverage.ts
Normal file
@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CDPSession } from './Connection';
|
||||
import { assert, debugError, helper, RegisteredListener } from '../helper';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
const {EVALUATION_SCRIPT_URL} = require('./ExecutionContext');
|
||||
|
||||
type CoverageEntry = {
|
||||
url: string,
|
||||
text: string,
|
||||
ranges : {start: number, end: number}[]
|
||||
};
|
||||
|
||||
export class Coverage {
|
||||
private _jsCoverage: JSCoverage;
|
||||
private _cssCoverage: CSSCoverage;
|
||||
|
||||
constructor(client: CDPSession) {
|
||||
this._jsCoverage = new JSCoverage(client);
|
||||
this._cssCoverage = new CSSCoverage(client);
|
||||
}
|
||||
|
||||
async startJSCoverage(options: {
|
||||
resetOnNavigation?: boolean;
|
||||
reportAnonymousScripts?: boolean;
|
||||
}) {
|
||||
return await this._jsCoverage.start(options);
|
||||
}
|
||||
|
||||
async stopJSCoverage(): Promise<CoverageEntry[]> {
|
||||
return await this._jsCoverage.stop();
|
||||
}
|
||||
|
||||
async startCSSCoverage(options: { resetOnNavigation?: boolean; } = {}) {
|
||||
return await this._cssCoverage.start(options);
|
||||
}
|
||||
|
||||
async stopCSSCoverage(): Promise<CoverageEntry[]> {
|
||||
return await this._cssCoverage.stop();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {Coverage};
|
||||
|
||||
class JSCoverage {
|
||||
_client: CDPSession;
|
||||
_enabled: boolean;
|
||||
_scriptURLs: Map<string, string>;
|
||||
_scriptSources: Map<string, string>;
|
||||
_eventListeners: RegisteredListener[];
|
||||
_resetOnNavigation: boolean;
|
||||
_reportAnonymousScripts: boolean;
|
||||
|
||||
constructor(client: CDPSession) {
|
||||
this._client = client;
|
||||
this._enabled = false;
|
||||
this._scriptURLs = new Map();
|
||||
this._scriptSources = new Map();
|
||||
this._eventListeners = [];
|
||||
this._resetOnNavigation = false;
|
||||
}
|
||||
|
||||
async start(options: {
|
||||
resetOnNavigation?: boolean;
|
||||
reportAnonymousScripts?: boolean;
|
||||
} = {}) {
|
||||
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();
|
||||
}
|
||||
|
||||
async _onScriptParsed(event: Protocol.Debugger.scriptParsedPayload) {
|
||||
// Ignore playwright-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);
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<CoverageEntry[]> {
|
||||
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});
|
||||
}
|
||||
return coverage;
|
||||
}
|
||||
}
|
||||
|
||||
class CSSCoverage {
|
||||
_client: CDPSession;
|
||||
_enabled: boolean;
|
||||
_stylesheetURLs: Map<string, string>;
|
||||
_stylesheetSources: Map<string, string>;
|
||||
_eventListeners: RegisteredListener[];
|
||||
_resetOnNavigation: boolean;
|
||||
|
||||
constructor(client: CDPSession) {
|
||||
this._client = client;
|
||||
this._enabled = false;
|
||||
this._stylesheetURLs = new Map();
|
||||
this._stylesheetSources = new Map();
|
||||
this._eventListeners = [];
|
||||
this._resetOnNavigation = false;
|
||||
}
|
||||
|
||||
async start(options: { resetOnNavigation?: boolean; } = {}) {
|
||||
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();
|
||||
}
|
||||
|
||||
async _onStyleSheet(event: Protocol.CSS.styleSheetAddedPayload) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<CoverageEntry[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function convertToDisjointRanges(nestedRanges: {
|
||||
startOffset: number;
|
||||
endOffset: number;
|
||||
count: number; }[]): { start: number; end: number; }[] {
|
||||
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});
|
||||
}
|
||||
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);
|
||||
}
|
580
src/chromium/DOMWorld.ts
Normal file
580
src/chromium/DOMWorld.ts
Normal file
@ -0,0 +1,580 @@
|
||||
/**
|
||||
* Copyright 2019 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { TimeoutError } from '../Errors';
|
||||
import { ExecutionContext } from './ExecutionContext';
|
||||
import { Frame } from './Frame';
|
||||
import { FrameManager } from './FrameManager';
|
||||
import { assert, helper } from '../helper';
|
||||
import { ElementHandle, JSHandle, ClickOptions, PointerActionOptions, MultiClickOptions } from './JSHandle';
|
||||
import { LifecycleWatcher } from './LifecycleWatcher';
|
||||
import { TimeoutSettings } from '../TimeoutSettings';
|
||||
const readFileAsync = helper.promisify(fs.readFile);
|
||||
|
||||
export class DOMWorld {
|
||||
private _frameManager: FrameManager;
|
||||
private _frame: Frame;
|
||||
private _timeoutSettings: TimeoutSettings;
|
||||
private _documentPromise: Promise<ElementHandle> | null = null;
|
||||
private _contextPromise: Promise<ExecutionContext>;
|
||||
private _contextResolveCallback: ((c: ExecutionContext) => void) | null;
|
||||
_waitTasks = new Set<WaitTask>();
|
||||
private _detached = false;
|
||||
|
||||
constructor(frameManager: FrameManager, frame: Frame, timeoutSettings: TimeoutSettings) {
|
||||
this._frameManager = frameManager;
|
||||
this._frame = frame;
|
||||
this._timeoutSettings = timeoutSettings;
|
||||
this._contextPromise;
|
||||
this._setContext(null);
|
||||
}
|
||||
|
||||
frame(): Frame {
|
||||
return this._frame;
|
||||
}
|
||||
|
||||
_setContext(context: ExecutionContext | null) {
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_hasContext(): boolean {
|
||||
return !this._contextResolveCallback;
|
||||
}
|
||||
|
||||
_detach() {
|
||||
this._detached = true;
|
||||
for (const waitTask of this._waitTasks)
|
||||
waitTask.terminate(new Error('waitForFunction failed: frame got detached.'));
|
||||
}
|
||||
|
||||
executionContext(): Promise<ExecutionContext> {
|
||||
if (this._detached)
|
||||
throw new Error(`Execution Context is not available in detached frame "${this._frame.url()}" (are you trying to evaluate?)`);
|
||||
return this._contextPromise;
|
||||
}
|
||||
|
||||
async evaluateHandle(pageFunction: Function | string, ...args: any[]): Promise<JSHandle> {
|
||||
const context = await this.executionContext();
|
||||
return context.evaluateHandle(pageFunction, ...args);
|
||||
}
|
||||
|
||||
async evaluate(pageFunction: Function | string, ...args: any[]): Promise<any> {
|
||||
const context = await this.executionContext();
|
||||
return context.evaluate(pageFunction, ...args);
|
||||
}
|
||||
|
||||
async $(selector: string): Promise<ElementHandle | null> {
|
||||
const document = await this._document();
|
||||
const value = await document.$(selector);
|
||||
return value;
|
||||
}
|
||||
|
||||
async _document(): Promise<ElementHandle> {
|
||||
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;
|
||||
}
|
||||
|
||||
async $x(expression: string): Promise<ElementHandle[]> {
|
||||
const document = await this._document();
|
||||
const value = await document.$x(expression);
|
||||
return value;
|
||||
}
|
||||
|
||||
async $eval(selector: string, pageFunction: Function | string, ...args: any[]): Promise<any> {
|
||||
const document = await this._document();
|
||||
return document.$eval(selector, pageFunction, ...args);
|
||||
}
|
||||
|
||||
async $$eval(selector: string, pageFunction: Function | string, ...args: any[]): Promise<any> {
|
||||
const document = await this._document();
|
||||
const value = await document.$$eval(selector, pageFunction, ...args);
|
||||
return value;
|
||||
}
|
||||
|
||||
async $$(selector: string): Promise<ElementHandle[]> {
|
||||
const document = await this._document();
|
||||
const value = await document.$$(selector);
|
||||
return value;
|
||||
}
|
||||
|
||||
async content(): Promise<string> {
|
||||
return await this.evaluate(() => {
|
||||
let retVal = '';
|
||||
if (document.doctype)
|
||||
retVal = new XMLSerializer().serializeToString(document.doctype);
|
||||
if (document.documentElement)
|
||||
retVal += document.documentElement.outerHTML;
|
||||
return retVal;
|
||||
});
|
||||
}
|
||||
|
||||
async setContent(html: string, options: {
|
||||
timeout?: number;
|
||||
waitUntil?: string | string[];
|
||||
} = {}) {
|
||||
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;
|
||||
}
|
||||
|
||||
async addScriptTag(options: {
|
||||
url?: string; path?: string;
|
||||
content?: string;
|
||||
type?: string;
|
||||
}): Promise<ElementHandle> {
|
||||
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');
|
||||
|
||||
async function addScriptUrl(url: string, type: string): Promise<HTMLElement> {
|
||||
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;
|
||||
}
|
||||
|
||||
function addScriptContent(content: string, type: string = 'text/javascript'): HTMLElement {
|
||||
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 addStyleTag(options: { url?: string; path?: string; content?: string; }): Promise<ElementHandle> {
|
||||
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');
|
||||
|
||||
async function addStyleUrl(url: string): Promise<HTMLElement> {
|
||||
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 function addStyleContent(content: string): Promise<HTMLElement> {
|
||||
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 click(selector: string, options?: ClickOptions) {
|
||||
const handle = await this.$(selector);
|
||||
assert(handle, 'No node found for selector: ' + selector);
|
||||
await handle.click(options);
|
||||
await handle.dispose();
|
||||
}
|
||||
|
||||
async dblclick(selector: string, options?: MultiClickOptions) {
|
||||
const handle = await this.$(selector);
|
||||
assert(handle, 'No node found for selector: ' + selector);
|
||||
await handle.dblclick(options);
|
||||
await handle.dispose();
|
||||
}
|
||||
|
||||
async tripleclick(selector: string, options?: MultiClickOptions) {
|
||||
const handle = await this.$(selector);
|
||||
assert(handle, 'No node found for selector: ' + selector);
|
||||
await handle.tripleclick(options);
|
||||
await handle.dispose();
|
||||
}
|
||||
|
||||
async fill(selector: string, value: string) {
|
||||
const handle = await this.$(selector);
|
||||
assert(handle, 'No node found for selector: ' + selector);
|
||||
await handle.fill(value);
|
||||
await handle.dispose();
|
||||
}
|
||||
|
||||
async focus(selector: string) {
|
||||
const handle = await this.$(selector);
|
||||
assert(handle, 'No node found for selector: ' + selector);
|
||||
await handle.focus();
|
||||
await handle.dispose();
|
||||
}
|
||||
|
||||
async hover(selector: string, options?: PointerActionOptions) {
|
||||
const handle = await this.$(selector);
|
||||
assert(handle, 'No node found for selector: ' + selector);
|
||||
await handle.hover(options);
|
||||
await handle.dispose();
|
||||
}
|
||||
|
||||
async select(selector: string, ...values: string[]): Promise<string[]> {
|
||||
const handle = await this.$(selector);
|
||||
assert(handle, 'No node found for selector: ' + selector);
|
||||
const result = await handle.select(...values);
|
||||
await handle.dispose();
|
||||
return result;
|
||||
}
|
||||
|
||||
async tap(selector: string, options?: PointerActionOptions) {
|
||||
const handle = await this.$(selector);
|
||||
assert(handle, 'No node found for selector: ' + selector);
|
||||
await handle.tap(options);
|
||||
await handle.dispose();
|
||||
}
|
||||
|
||||
async type(selector: string, text: string, options: { delay: (number | undefined); } | undefined) {
|
||||
const handle = await this.$(selector);
|
||||
assert(handle, 'No node found for selector: ' + selector);
|
||||
await handle.type(text, options);
|
||||
await handle.dispose();
|
||||
}
|
||||
|
||||
waitForSelector(selector: string, options: { visible?: boolean; hidden?: boolean; timeout?: number; } | undefined): Promise<ElementHandle | null> {
|
||||
return this._waitForSelectorOrXPath(selector, false, options);
|
||||
}
|
||||
|
||||
waitForXPath(xpath: string, options: { visible?: boolean; hidden?: boolean; timeout?: number; } | undefined): Promise<ElementHandle | null> {
|
||||
return this._waitForSelectorOrXPath(xpath, true, options);
|
||||
}
|
||||
|
||||
waitForFunction(pageFunction: Function | string, options: { polling?: string | number; timeout?: number; } = {}, ...args): Promise<JSHandle> {
|
||||
const {
|
||||
polling = 'raf',
|
||||
timeout = this._timeoutSettings.timeout(),
|
||||
} = options;
|
||||
return new WaitTask(this, pageFunction, 'function', polling, timeout, ...args).promise;
|
||||
}
|
||||
|
||||
async title(): Promise<string> {
|
||||
return this.evaluate(() => document.title);
|
||||
}
|
||||
|
||||
async _waitForSelectorOrXPath(
|
||||
selectorOrXPath: string,
|
||||
isXPath: boolean,
|
||||
options: { visible?: boolean; hidden?: boolean; timeout?: number; } = {}): Promise<ElementHandle | null> {
|
||||
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();
|
||||
|
||||
function predicate(selectorOrXPath: string, isXPath: boolean, waitForVisible: boolean, waitForHidden: boolean): (Node | boolean) | null {
|
||||
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 = (node.nodeType === Node.TEXT_NODE ? node.parentElement : node) as Element;
|
||||
const style = window.getComputedStyle(element);
|
||||
const isVisible = style && style.visibility !== 'hidden' && hasVisibleBoundingBox();
|
||||
const success = (waitForVisible === isVisible || waitForHidden === !isVisible);
|
||||
return success ? node : null;
|
||||
|
||||
function hasVisibleBoundingBox(): boolean {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return !!(rect.top || rect.bottom || rect.width || rect.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WaitTask {
|
||||
promise: Promise<JSHandle>;
|
||||
_domWorld: DOMWorld;
|
||||
_polling: string | number;
|
||||
_timeout: number;
|
||||
_predicateBody: string;
|
||||
_args: any[];
|
||||
_runCount: number;
|
||||
_resolve: (result: JSHandle) => void;
|
||||
_reject: (reason: Error) => void;
|
||||
_timeoutTimer: NodeJS.Timer;
|
||||
_terminated: boolean;
|
||||
_runningTask: any;
|
||||
|
||||
constructor(domWorld: DOMWorld, predicateBody: Function | string, title, polling: string | number, timeout: number, ...args: any[]) {
|
||||
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<JSHandle>((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();
|
||||
}
|
||||
|
||||
terminate(error: Error) {
|
||||
this._terminated = true;
|
||||
this._reject(error);
|
||||
this._cleanup();
|
||||
}
|
||||
|
||||
async rerun() {
|
||||
const runCount = ++this._runCount;
|
||||
let success: JSHandle | null = 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();
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
clearTimeout(this._timeoutTimer);
|
||||
this._domWorld._waitTasks.delete(this);
|
||||
this._runningTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPredicatePageFunction(predicateBody: string, polling: string, timeout: number, ...args): Promise<any> {
|
||||
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);
|
||||
|
||||
function pollMutation(): Promise<any> {
|
||||
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;
|
||||
}
|
||||
|
||||
function pollRaf(): Promise<any> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function pollInterval(pollInterval: number): Promise<any> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
70
src/chromium/Dialog.ts
Normal file
70
src/chromium/Dialog.ts
Normal file
@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CDPSession } from './Connection';
|
||||
import { assert } from '../helper';
|
||||
|
||||
export class Dialog {
|
||||
private _client: CDPSession;
|
||||
private _type: string;
|
||||
private _message: string;
|
||||
private _handled = false;
|
||||
private _defaultValue: string;
|
||||
|
||||
constructor(client: CDPSession, type: string, message: string, defaultValue: (string | undefined) = '') {
|
||||
this._client = client;
|
||||
this._type = type;
|
||||
this._message = message;
|
||||
this._defaultValue = defaultValue;
|
||||
}
|
||||
|
||||
type(): string {
|
||||
return this._type;
|
||||
}
|
||||
|
||||
message(): string {
|
||||
return this._message;
|
||||
}
|
||||
|
||||
defaultValue(): string {
|
||||
return this._defaultValue;
|
||||
}
|
||||
|
||||
async accept(promptText: string | undefined) {
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const DialogType = {
|
||||
Alert: 'alert',
|
||||
BeforeUnload: 'beforeunload',
|
||||
Confirm: 'confirm',
|
||||
Prompt: 'prompt'
|
||||
};
|
51
src/chromium/EmulationManager.ts
Normal file
51
src/chromium/EmulationManager.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CDPSession } from './Connection';
|
||||
import { Viewport } from './Page';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
export class EmulationManager {
|
||||
private _client: CDPSession;
|
||||
private _emulatingMobile = false;
|
||||
private _hasTouch = false;
|
||||
|
||||
constructor(client: CDPSession) {
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
async emulateViewport(viewport: Viewport): Promise<boolean> {
|
||||
const mobile = viewport.isMobile || false;
|
||||
const width = viewport.width;
|
||||
const height = viewport.height;
|
||||
const deviceScaleFactor = viewport.deviceScaleFactor || 1;
|
||||
const screenOrientation: Protocol.Emulation.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;
|
||||
}
|
||||
}
|
171
src/chromium/ExecutionContext.ts
Normal file
171
src/chromium/ExecutionContext.ts
Normal file
@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CDPSession } from './Connection';
|
||||
import { DOMWorld } from './DOMWorld';
|
||||
import { Frame } from './Frame';
|
||||
import { assert, helper } from '../helper';
|
||||
import { valueFromRemoteObject, getExceptionMessage } from './protocolHelper';
|
||||
import { createJSHandle, ElementHandle, JSHandle } from './JSHandle';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
|
||||
export const EVALUATION_SCRIPT_URL = '__playwright_evaluation_script__';
|
||||
const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m;
|
||||
|
||||
export class ExecutionContext {
|
||||
_client: CDPSession;
|
||||
_world: DOMWorld;
|
||||
private _contextId: number;
|
||||
|
||||
constructor(client: CDPSession, contextPayload: Protocol.Runtime.ExecutionContextDescription, world: DOMWorld | null) {
|
||||
this._client = client;
|
||||
this._world = world;
|
||||
this._contextId = contextPayload.id;
|
||||
}
|
||||
|
||||
frame(): Frame | null {
|
||||
return this._world ? this._world.frame() : null;
|
||||
}
|
||||
|
||||
async evaluate(pageFunction: Function | string, ...args: any[]): Promise<any> {
|
||||
return await this._evaluateInternal(true /* returnByValue */, pageFunction, ...args);
|
||||
}
|
||||
|
||||
async evaluateHandle(pageFunction: Function | string, ...args: any[]): Promise<JSHandle> {
|
||||
return this._evaluateInternal(false /* returnByValue */, pageFunction, ...args);
|
||||
}
|
||||
|
||||
async _evaluateInternal(returnByValue: boolean, pageFunction: Function | string, ...args: any[]): Promise<any> {
|
||||
const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`;
|
||||
|
||||
if (helper.isString(pageFunction)) {
|
||||
const contextId = this._contextId;
|
||||
const expression: string = pageFunction as string;
|
||||
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: ' + getExceptionMessage(exceptionDetails));
|
||||
return returnByValue ? 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: ' + getExceptionMessage(exceptionDetails));
|
||||
return returnByValue ? valueFromRemoteObject(remoteObject) : createJSHandle(this, remoteObject);
|
||||
|
||||
function convertArgument(arg: any): any {
|
||||
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 };
|
||||
}
|
||||
|
||||
function rewriteError(error: Error): Protocol.Runtime.evaluateReturnValue {
|
||||
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 queryObjects(prototypeHandle: JSHandle): Promise<JSHandle> {
|
||||
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);
|
||||
}
|
||||
|
||||
async _adoptElementHandle(elementHandle: ElementHandle): Promise<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,
|
||||
});
|
||||
const {object} = await this._client.send('DOM.resolveNode', {
|
||||
backendNodeId: nodeInfo.node.backendNodeId,
|
||||
executionContextId: this._contextId,
|
||||
});
|
||||
return createJSHandle(this, object) as ElementHandle;
|
||||
}
|
||||
}
|
264
src/chromium/Frame.ts
Normal file
264
src/chromium/Frame.ts
Normal file
@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { helper } from '../helper';
|
||||
import { CDPSession } from './Connection';
|
||||
import { DOMWorld } from './DOMWorld';
|
||||
import { ExecutionContext } from './ExecutionContext';
|
||||
import { FrameManager } from './FrameManager';
|
||||
import { ClickOptions, ElementHandle, JSHandle, MultiClickOptions, PointerActionOptions } from './JSHandle';
|
||||
import { Response } from './NetworkManager';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
export class Frame {
|
||||
_id: string;
|
||||
_frameManager: FrameManager;
|
||||
private _client: CDPSession;
|
||||
private _parentFrame: Frame;
|
||||
private _url = '';
|
||||
private _detached = false;
|
||||
_loaderId = '';
|
||||
_lifecycleEvents = new Set<string>();
|
||||
_mainWorld: DOMWorld;
|
||||
_secondaryWorld: DOMWorld;
|
||||
private _childFrames = new Set<Frame>();
|
||||
private _name: string;
|
||||
private _navigationURL: string;
|
||||
|
||||
constructor(frameManager: FrameManager, client: CDPSession, parentFrame: Frame | null, frameId: string) {
|
||||
this._frameManager = frameManager;
|
||||
this._client = client;
|
||||
this._parentFrame = parentFrame;
|
||||
this._id = frameId;
|
||||
|
||||
this._mainWorld = new DOMWorld(frameManager, this, frameManager._timeoutSettings);
|
||||
this._secondaryWorld = new DOMWorld(frameManager, this, frameManager._timeoutSettings);
|
||||
|
||||
if (this._parentFrame)
|
||||
this._parentFrame._childFrames.add(this);
|
||||
}
|
||||
|
||||
async goto(
|
||||
url: string,
|
||||
options: { referer?: string; timeout?: number; waitUntil?: string | string[]; } | undefined
|
||||
): Promise<Response | null> {
|
||||
return await this._frameManager.navigateFrame(this, url, options);
|
||||
}
|
||||
|
||||
async waitForNavigation(options: { timeout?: number; waitUntil?: string | string[]; } | undefined): Promise<Response | null> {
|
||||
return await this._frameManager.waitForFrameNavigation(this, options);
|
||||
}
|
||||
|
||||
executionContext(): Promise<ExecutionContext> {
|
||||
return this._mainWorld.executionContext();
|
||||
}
|
||||
|
||||
async evaluateHandle(pageFunction: Function | string, ...args: any[]): Promise<JSHandle> {
|
||||
return this._mainWorld.evaluateHandle(pageFunction, ...args);
|
||||
}
|
||||
|
||||
async evaluate(pageFunction: Function | string, ...args: any[]): Promise<any> {
|
||||
return this._mainWorld.evaluate(pageFunction, ...args);
|
||||
}
|
||||
|
||||
async $(selector: string): Promise<ElementHandle | null> {
|
||||
return this._mainWorld.$(selector);
|
||||
}
|
||||
|
||||
async $x(expression: string): Promise<ElementHandle[]> {
|
||||
return this._mainWorld.$x(expression);
|
||||
}
|
||||
|
||||
async $eval(selector: string, pageFunction: Function | string, ...args: any[]): Promise<(object | undefined)> {
|
||||
return this._mainWorld.$eval(selector, pageFunction, ...args);
|
||||
}
|
||||
|
||||
async $$eval(selector: string, pageFunction: Function | string, ...args: any[]): Promise<(object | undefined)> {
|
||||
return this._mainWorld.$$eval(selector, pageFunction, ...args);
|
||||
}
|
||||
|
||||
async $$(selector: string): Promise<ElementHandle[]> {
|
||||
return this._mainWorld.$$(selector);
|
||||
}
|
||||
|
||||
async content(): Promise<string> {
|
||||
return this._secondaryWorld.content();
|
||||
}
|
||||
|
||||
async setContent(html: string, options: {
|
||||
timeout?: number;
|
||||
waitUntil?: string | string[];
|
||||
} = {}) {
|
||||
return this._secondaryWorld.setContent(html, options);
|
||||
}
|
||||
|
||||
name(): string {
|
||||
return this._name || '';
|
||||
}
|
||||
|
||||
url(): string {
|
||||
return this._url;
|
||||
}
|
||||
|
||||
parentFrame(): Frame | null {
|
||||
return this._parentFrame;
|
||||
}
|
||||
|
||||
childFrames(): Frame[] {
|
||||
return Array.from(this._childFrames);
|
||||
}
|
||||
|
||||
isDetached(): boolean {
|
||||
return this._detached;
|
||||
}
|
||||
|
||||
async addScriptTag(options: {
|
||||
url?: string; path?: string;
|
||||
content?: string;
|
||||
type?: string; }): Promise<ElementHandle> {
|
||||
return this._mainWorld.addScriptTag(options);
|
||||
}
|
||||
|
||||
async addStyleTag(options: {
|
||||
url?: string;
|
||||
path?: string;
|
||||
content?: string; }): Promise<ElementHandle> {
|
||||
return this._mainWorld.addStyleTag(options);
|
||||
}
|
||||
|
||||
async click(selector: string, options?: ClickOptions) {
|
||||
return this._secondaryWorld.click(selector, options);
|
||||
}
|
||||
|
||||
async dblclick(selector: string, options?: MultiClickOptions) {
|
||||
return this._secondaryWorld.dblclick(selector, options);
|
||||
}
|
||||
|
||||
async tripleclick(selector: string, options?: MultiClickOptions) {
|
||||
return this._secondaryWorld.tripleclick(selector, options);
|
||||
}
|
||||
|
||||
async fill(selector: string, value: string) {
|
||||
return this._secondaryWorld.fill(selector, value);
|
||||
}
|
||||
|
||||
async focus(selector: string) {
|
||||
return this._secondaryWorld.focus(selector);
|
||||
}
|
||||
|
||||
async hover(selector: string, options?: PointerActionOptions) {
|
||||
return this._secondaryWorld.hover(selector, options);
|
||||
}
|
||||
|
||||
select(selector: string, ...values: string[]): Promise<string[]>{
|
||||
return this._secondaryWorld.select(selector, ...values);
|
||||
}
|
||||
|
||||
async tap(selector: string, options?: PointerActionOptions) {
|
||||
return this._secondaryWorld.tap(selector, options);
|
||||
}
|
||||
|
||||
async type(selector: string, text: string, options: { delay: (number | undefined); } | undefined) {
|
||||
return this._mainWorld.type(selector, text, options);
|
||||
}
|
||||
|
||||
waitFor(selectorOrFunctionOrTimeout: (string | number | Function), options: any = {}, ...args: any[]): Promise<JSHandle | null> {
|
||||
const xPathPattern = '//';
|
||||
|
||||
if (helper.isString(selectorOrFunctionOrTimeout)) {
|
||||
const string = selectorOrFunctionOrTimeout as string;
|
||||
if (string.startsWith(xPathPattern))
|
||||
return this.waitForXPath(string, options);
|
||||
return this.waitForSelector(string, options);
|
||||
}
|
||||
if (helper.isNumber(selectorOrFunctionOrTimeout))
|
||||
return new Promise(fulfill => setTimeout(fulfill, selectorOrFunctionOrTimeout as number));
|
||||
if (typeof selectorOrFunctionOrTimeout === 'function')
|
||||
return this.waitForFunction(selectorOrFunctionOrTimeout, options, ...args);
|
||||
return Promise.reject(new Error('Unsupported target type: ' + (typeof selectorOrFunctionOrTimeout)));
|
||||
}
|
||||
|
||||
async waitForSelector(selector: string, options: {
|
||||
visible?: boolean;
|
||||
hidden?: boolean;
|
||||
timeout?: number; } | undefined): Promise<ElementHandle | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
async waitForXPath(xpath: string, options: {
|
||||
visible?: boolean;
|
||||
hidden?: boolean;
|
||||
timeout?: number; } | undefined): Promise<ElementHandle | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
waitForFunction(
|
||||
pageFunction: Function | string,
|
||||
options: { polling?: string | number; timeout?: number; } = {},
|
||||
...args): Promise<JSHandle> {
|
||||
return this._mainWorld.waitForFunction(pageFunction, options, ...args);
|
||||
}
|
||||
|
||||
async title(): Promise<string> {
|
||||
return this._secondaryWorld.title();
|
||||
}
|
||||
|
||||
_navigated(framePayload: Protocol.Page.Frame) {
|
||||
this._name = framePayload.name;
|
||||
// TODO(lushnikov): remove this once requestInterception has loaderId exposed.
|
||||
this._navigationURL = framePayload.url;
|
||||
this._url = framePayload.url;
|
||||
}
|
||||
|
||||
_navigatedWithinDocument(url: string) {
|
||||
this._url = url;
|
||||
}
|
||||
|
||||
_onLifecycleEvent(loaderId: string, name: string) {
|
||||
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;
|
||||
}
|
||||
}
|
316
src/chromium/FrameManager.ts
Normal file
316
src/chromium/FrameManager.ts
Normal file
@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { assert, debugError } from '../helper';
|
||||
import { TimeoutSettings } from '../TimeoutSettings';
|
||||
import { CDPSession } from './Connection';
|
||||
import { EVALUATION_SCRIPT_URL, ExecutionContext } from './ExecutionContext';
|
||||
import { Frame } from './Frame';
|
||||
import { LifecycleWatcher } from './LifecycleWatcher';
|
||||
import { NetworkManager, Response } from './NetworkManager';
|
||||
import { Page } from './Page';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
|
||||
|
||||
export const FrameManagerEvents = {
|
||||
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'),
|
||||
};
|
||||
|
||||
export class FrameManager extends EventEmitter {
|
||||
_client: CDPSession;
|
||||
private _page: Page;
|
||||
private _networkManager: NetworkManager;
|
||||
_timeoutSettings: TimeoutSettings;
|
||||
private _frames = new Map<string, Frame>();
|
||||
private _contextIdToContext = new Map<number, ExecutionContext>();
|
||||
private _isolatedWorlds = new Set<string>();
|
||||
private _mainFrame: Frame;
|
||||
|
||||
constructor(client: CDPSession, page: Page, ignoreHTTPSErrors: boolean, timeoutSettings: TimeoutSettings) {
|
||||
super();
|
||||
this._client = client;
|
||||
this._page = page;
|
||||
this._networkManager = new NetworkManager(client, ignoreHTTPSErrors, this);
|
||||
this._timeoutSettings = timeoutSettings;
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
|
||||
networkManager(): NetworkManager {
|
||||
return this._networkManager;
|
||||
}
|
||||
|
||||
async navigateFrame(
|
||||
frame: Frame,
|
||||
url: string,
|
||||
options: { referer?: string; timeout?: number; waitUntil?: string | string[]; } = {}): Promise<Response | null> {
|
||||
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();
|
||||
|
||||
async function navigate(client: CDPSession, url: string, referrer: string, frameId: string): Promise<Error | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async waitForFrameNavigation(
|
||||
frame: Frame,
|
||||
options: { timeout?: number; waitUntil?: string | string[]; } = {}
|
||||
): Promise<Response | null> {
|
||||
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();
|
||||
}
|
||||
|
||||
_onLifecycleEvent(event: Protocol.Page.lifecycleEventPayload) {
|
||||
const frame = this._frames.get(event.frameId);
|
||||
if (!frame)
|
||||
return;
|
||||
frame._onLifecycleEvent(event.loaderId, event.name);
|
||||
this.emit(FrameManagerEvents.LifecycleEvent, frame);
|
||||
}
|
||||
|
||||
_onFrameStoppedLoading(frameId: string) {
|
||||
const frame = this._frames.get(frameId);
|
||||
if (!frame)
|
||||
return;
|
||||
frame._onLoadingStopped();
|
||||
this.emit(FrameManagerEvents.LifecycleEvent, frame);
|
||||
}
|
||||
|
||||
_handleFrameTree(frameTree: Protocol.Page.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);
|
||||
}
|
||||
|
||||
page(): Page {
|
||||
return this._page;
|
||||
}
|
||||
|
||||
mainFrame(): Frame {
|
||||
return this._mainFrame;
|
||||
}
|
||||
|
||||
frames(): Frame[] {
|
||||
return Array.from(this._frames.values());
|
||||
}
|
||||
|
||||
frame(frameId: string): Frame | null {
|
||||
return this._frames.get(frameId) || null;
|
||||
}
|
||||
|
||||
_onFrameAttached(frameId: string, parentFrameId: string | null) {
|
||||
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(FrameManagerEvents.FrameAttached, frame);
|
||||
}
|
||||
|
||||
_onFrameNavigated(framePayload: Protocol.Page.Frame) {
|
||||
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(FrameManagerEvents.FrameNavigated, frame);
|
||||
}
|
||||
|
||||
async _ensureIsolatedWorld(name: string) {
|
||||
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
|
||||
}
|
||||
|
||||
_onFrameNavigatedWithinDocument(frameId: string, url: string) {
|
||||
const frame = this._frames.get(frameId);
|
||||
if (!frame)
|
||||
return;
|
||||
frame._navigatedWithinDocument(url);
|
||||
this.emit(FrameManagerEvents.FrameNavigatedWithinDocument, frame);
|
||||
this.emit(FrameManagerEvents.FrameNavigated, frame);
|
||||
}
|
||||
|
||||
_onFrameDetached(frameId: string) {
|
||||
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);
|
||||
const context: ExecutionContext = new ExecutionContext(this._client, contextPayload, world);
|
||||
if (world)
|
||||
world._setContext(context);
|
||||
this._contextIdToContext.set(contextPayload.id, context);
|
||||
}
|
||||
|
||||
_onExecutionContextDestroyed(executionContextId: number) {
|
||||
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();
|
||||
}
|
||||
|
||||
executionContextById(contextId: number): ExecutionContext {
|
||||
const context = this._contextIdToContext.get(contextId);
|
||||
assert(context, 'INTERNAL ERROR: missing context with id = ' + contextId);
|
||||
return context;
|
||||
}
|
||||
|
||||
_removeFramesRecursively(frame: Frame) {
|
||||
for (const child of frame.childFrames())
|
||||
this._removeFramesRecursively(child);
|
||||
frame._detach();
|
||||
this._frames.delete(frame._id);
|
||||
this.emit(FrameManagerEvents.FrameDetached, frame);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
337
src/chromium/Input.ts
Normal file
337
src/chromium/Input.ts
Normal file
@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CDPSession } from './Connection';
|
||||
import { assert } from '../helper';
|
||||
import { keyDefinitions } from '../USKeyboardLayout';
|
||||
|
||||
type KeyDescription = {
|
||||
keyCode: number,
|
||||
key: string,
|
||||
text: string,
|
||||
code: string,
|
||||
location: number,
|
||||
};
|
||||
|
||||
export type Modifier = 'Alt' | 'Control' | 'Meta' | 'Shift';
|
||||
const kModifiers: Modifier[] = ['Alt', 'Control', 'Meta', 'Shift'];
|
||||
|
||||
export type Button = 'left' | 'right' | 'middle';
|
||||
|
||||
export class Keyboard {
|
||||
private _client: CDPSession;
|
||||
_modifiers = 0;
|
||||
private _pressedKeys = new Set<unknown>();
|
||||
|
||||
constructor(client: CDPSession) {
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
async down(key: string, options: { text?: string; } = { 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
|
||||
});
|
||||
}
|
||||
|
||||
_modifierBit(key: string): number {
|
||||
if (key === 'Alt')
|
||||
return 1;
|
||||
if (key === 'Control')
|
||||
return 2;
|
||||
if (key === 'Meta')
|
||||
return 4;
|
||||
if (key === 'Shift')
|
||||
return 8;
|
||||
return 0;
|
||||
}
|
||||
|
||||
_keyDescriptionForString(keyString: string): KeyDescription {
|
||||
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;
|
||||
}
|
||||
|
||||
async up(key: string) {
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
async sendCharacter(char: string) {
|
||||
await this._client.send('Input.insertText', {text: char});
|
||||
}
|
||||
|
||||
async type(text: string, options: { delay: (number | undefined); } | undefined) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async press(key: string, options: { delay?: number; text?: string; } = {}) {
|
||||
const {delay = null} = options;
|
||||
await this.down(key, options);
|
||||
if (delay)
|
||||
await new Promise(f => setTimeout(f, options.delay));
|
||||
await this.up(key);
|
||||
}
|
||||
|
||||
async _ensureModifiers(modifiers: Modifier[]): Promise<Modifier[]> {
|
||||
for (const modifier of modifiers) {
|
||||
if (!kModifiers.includes(modifier))
|
||||
throw new Error('Uknown modifier ' + modifier);
|
||||
}
|
||||
const restore: Modifier[] = [];
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const key of kModifiers) {
|
||||
const needDown = modifiers.includes(key);
|
||||
const isDown = (this._modifiers & this._modifierBit(key)) !== 0;
|
||||
if (isDown)
|
||||
restore.push(key);
|
||||
if (needDown && !isDown)
|
||||
promises.push(this.down(key));
|
||||
else if (!needDown && isDown)
|
||||
promises.push(this.up(key));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
return restore;
|
||||
}
|
||||
}
|
||||
|
||||
export class Mouse {
|
||||
private _client: CDPSession;
|
||||
private _keyboard: Keyboard;
|
||||
private _x = 0;
|
||||
private _y = 0;
|
||||
private _button: 'none' | Button = 'none';
|
||||
|
||||
constructor(client: CDPSession, keyboard: Keyboard) {
|
||||
this._client = client;
|
||||
this._keyboard = keyboard;
|
||||
}
|
||||
|
||||
async move(x: number, y: number, options: { steps?: number; } = {}) {
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async click(x: number, y: number, options: { delay?: number; button?: Button; clickCount?: number; } = {}) {
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async dblclick(x: number, y: number, options: { delay?: number; button?: Button; } = {}) {
|
||||
const { delay = null } = options;
|
||||
if (delay !== null) {
|
||||
await this.move(x, y);
|
||||
await this.down({ ...options, clickCount: 1 });
|
||||
await new Promise(f => setTimeout(f, delay));
|
||||
await this.up({ ...options, clickCount: 1 });
|
||||
await new Promise(f => setTimeout(f, delay));
|
||||
await this.down({ ...options, clickCount: 2 });
|
||||
await new Promise(f => setTimeout(f, delay));
|
||||
await this.up({ ...options, clickCount: 2 });
|
||||
} else {
|
||||
await Promise.all([
|
||||
this.move(x, y),
|
||||
this.down({ ...options, clickCount: 1 }),
|
||||
this.up({ ...options, clickCount: 1 }),
|
||||
this.down({ ...options, clickCount: 2 }),
|
||||
this.up({ ...options, clickCount: 2 }),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async tripleclick(x: number, y: number, options: { delay?: number; button?: Button; } = {}) {
|
||||
const { delay = null } = options;
|
||||
if (delay !== null) {
|
||||
await this.move(x, y);
|
||||
await this.down({ ...options, clickCount: 1 });
|
||||
await new Promise(f => setTimeout(f, delay));
|
||||
await this.up({ ...options, clickCount: 1 });
|
||||
await new Promise(f => setTimeout(f, delay));
|
||||
await this.down({ ...options, clickCount: 2 });
|
||||
await new Promise(f => setTimeout(f, delay));
|
||||
await this.up({ ...options, clickCount: 2 });
|
||||
await new Promise(f => setTimeout(f, delay));
|
||||
await this.down({ ...options, clickCount: 3 });
|
||||
await new Promise(f => setTimeout(f, delay));
|
||||
await this.up({ ...options, clickCount: 3 });
|
||||
} else {
|
||||
await Promise.all([
|
||||
this.move(x, y),
|
||||
this.down({ ...options, clickCount: 1 }),
|
||||
this.up({ ...options, clickCount: 1 }),
|
||||
this.down({ ...options, clickCount: 2 }),
|
||||
this.up({ ...options, clickCount: 2 }),
|
||||
this.down({ ...options, clickCount: 3 }),
|
||||
this.up({ ...options, clickCount: 3 }),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async down(options: { button?: Button; clickCount?: number; } = {}) {
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
async up(options: { button?: Button; clickCount?: number; } = {}) {
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class Touchscreen {
|
||||
private _client: CDPSession;
|
||||
private _keyboard: Keyboard;
|
||||
|
||||
constructor(client: CDPSession, keyboard: Keyboard) {
|
||||
this._client = client;
|
||||
this._keyboard = keyboard;
|
||||
}
|
||||
|
||||
async tap(x: number, y: number) {
|
||||
// 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
|
||||
});
|
||||
}
|
||||
}
|
578
src/chromium/JSHandle.ts
Normal file
578
src/chromium/JSHandle.ts
Normal file
@ -0,0 +1,578 @@
|
||||
/**
|
||||
* Copyright 2019 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import { CDPSession } from './Connection';
|
||||
import { ExecutionContext } from './ExecutionContext';
|
||||
import { Frame } from './Frame';
|
||||
import { FrameManager } from './FrameManager';
|
||||
import { assert, debugError, helper } from '../helper';
|
||||
import { valueFromRemoteObject, releaseObject } from './protocolHelper';
|
||||
import { Page } from './Page';
|
||||
import { Modifier, Button } from './Input';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
type Point = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type PointerActionOptions = {
|
||||
modifiers?: Modifier[];
|
||||
relativePoint?: Point;
|
||||
};
|
||||
|
||||
export type ClickOptions = PointerActionOptions & {
|
||||
delay?: number;
|
||||
button?: Button;
|
||||
clickCount?: number;
|
||||
};
|
||||
|
||||
export type MultiClickOptions = PointerActionOptions & {
|
||||
delay?: number;
|
||||
button?: Button;
|
||||
};
|
||||
|
||||
export function createJSHandle(context: ExecutionContext, remoteObject: Protocol.Runtime.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);
|
||||
}
|
||||
|
||||
export class JSHandle {
|
||||
_context: ExecutionContext;
|
||||
protected _client: CDPSession;
|
||||
_remoteObject: Protocol.Runtime.RemoteObject;
|
||||
_disposed = false;
|
||||
|
||||
constructor(context: ExecutionContext, client: CDPSession, remoteObject: Protocol.Runtime.RemoteObject) {
|
||||
this._context = context;
|
||||
this._client = client;
|
||||
this._remoteObject = remoteObject;
|
||||
}
|
||||
|
||||
executionContext(): ExecutionContext {
|
||||
return this._context;
|
||||
}
|
||||
|
||||
async evaluate(pageFunction: Function | string, ...args: any[]): Promise<(any)> {
|
||||
return await this.executionContext().evaluate(pageFunction, this, ...args);
|
||||
}
|
||||
|
||||
async evaluateHandle(pageFunction: Function | string, ...args: any[]): Promise<JSHandle> {
|
||||
return await this.executionContext().evaluateHandle(pageFunction, this, ...args);
|
||||
}
|
||||
|
||||
async getProperty(propertyName: string): Promise<JSHandle | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
async getProperties(): Promise<Map<string, JSHandle>> {
|
||||
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;
|
||||
}
|
||||
|
||||
async jsonValue(): Promise<object | null> {
|
||||
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 valueFromRemoteObject(response.result);
|
||||
}
|
||||
return valueFromRemoteObject(this._remoteObject);
|
||||
}
|
||||
|
||||
asElement(): ElementHandle | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
if (this._disposed)
|
||||
return;
|
||||
this._disposed = true;
|
||||
await releaseObject(this._client, this._remoteObject);
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
if (this._remoteObject.objectId) {
|
||||
const type = this._remoteObject.subtype || this._remoteObject.type;
|
||||
return 'JSHandle@' + type;
|
||||
}
|
||||
return 'JSHandle:' + valueFromRemoteObject(this._remoteObject);
|
||||
}
|
||||
}
|
||||
|
||||
export class ElementHandle extends JSHandle {
|
||||
private _page: Page;
|
||||
private _frameManager: FrameManager;
|
||||
|
||||
constructor(context: ExecutionContext, client: CDPSession, remoteObject: Protocol.Runtime.RemoteObject, page: Page, frameManager: FrameManager) {
|
||||
super(context, client, remoteObject);
|
||||
this._client = client;
|
||||
this._remoteObject = remoteObject;
|
||||
this._page = page;
|
||||
this._frameManager = frameManager;
|
||||
}
|
||||
|
||||
asElement(): ElementHandle | null {
|
||||
return this;
|
||||
}
|
||||
|
||||
async contentFrame(): Promise<Frame|null> {
|
||||
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();
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
async _clickablePoint(): Promise<Point> {
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
async _viewportPointAndScroll(relativePoint: Point): Promise<{point: Point, scrollX: number, scrollY: number}> {
|
||||
const model = await this._getBoxModel();
|
||||
let point: Point;
|
||||
if (!model) {
|
||||
point = relativePoint;
|
||||
} else {
|
||||
// Use padding quad to be compatible with offsetX/offsetY properties.
|
||||
const quad = model.model.padding;
|
||||
const x = Math.min(quad[0], quad[2], quad[4], quad[6]);
|
||||
const y = Math.min(quad[1], quad[3], quad[5], quad[7]);
|
||||
point = {
|
||||
x: x + relativePoint.x,
|
||||
y: y + relativePoint.y,
|
||||
};
|
||||
}
|
||||
const metrics = await this._client.send('Page.getLayoutMetrics');
|
||||
// Give one extra pixel to avoid any issues on viewport edge.
|
||||
let scrollX = 0;
|
||||
if (point.x < 1)
|
||||
scrollX = point.x - 1;
|
||||
if (point.x > metrics.layoutViewport.clientWidth - 1)
|
||||
scrollX = point.x - metrics.layoutViewport.clientWidth + 1;
|
||||
let scrollY = 0;
|
||||
if (point.y < 1)
|
||||
scrollY = point.y - 1;
|
||||
if (point.y > metrics.layoutViewport.clientHeight - 1)
|
||||
scrollY = point.y - metrics.layoutViewport.clientHeight + 1;
|
||||
return { point, scrollX, scrollY };
|
||||
}
|
||||
|
||||
async _performPointerAction(action: (point: Point) => Promise<void>, options?: PointerActionOptions): Promise<void> {
|
||||
await this._scrollIntoViewIfNeeded();
|
||||
let point: Point;
|
||||
if (options && options.relativePoint) {
|
||||
let r = await this._viewportPointAndScroll(options.relativePoint);
|
||||
if (r.scrollX || r.scrollY) {
|
||||
const error = await this.evaluate((element, scrollX, scrollY) => {
|
||||
if (!element.ownerDocument || !element.ownerDocument.defaultView)
|
||||
return 'Node does not have a containing window';
|
||||
element.ownerDocument.defaultView.scrollBy(scrollX, scrollY);
|
||||
return false;
|
||||
}, r.scrollX, r.scrollY);
|
||||
if (error)
|
||||
throw new Error(error);
|
||||
r = await this._viewportPointAndScroll(options.relativePoint);
|
||||
if (r.scrollX || r.scrollY)
|
||||
throw new Error('Failed to scroll relative point into viewport');
|
||||
}
|
||||
point = r.point;
|
||||
} else {
|
||||
await this._scrollIntoViewIfNeeded();
|
||||
point = await this._clickablePoint();
|
||||
}
|
||||
let restoreModifiers: Modifier[] | undefined;
|
||||
if (options && options.modifiers)
|
||||
restoreModifiers = await this._page.keyboard._ensureModifiers(options.modifiers);
|
||||
await action(point);
|
||||
if (restoreModifiers)
|
||||
await this._page.keyboard._ensureModifiers(restoreModifiers);
|
||||
}
|
||||
|
||||
_getBoxModel(): Promise<void | Protocol.DOM.getBoxModelReturnValue> {
|
||||
return this._client.send('DOM.getBoxModel', {
|
||||
objectId: this._remoteObject.objectId
|
||||
}).catch(error => debugError(error));
|
||||
}
|
||||
|
||||
_fromProtocolQuad(quad: number[]): Array<{ x: number; y: number; }> {
|
||||
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]}
|
||||
];
|
||||
}
|
||||
|
||||
_intersectQuadWithViewport(quad: Array<{ x: number; y: number; }>, width: number, height: number): Array<{ x: number; y: number; }> {
|
||||
return quad.map(point => ({
|
||||
x: Math.min(Math.max(point.x, 0), width),
|
||||
y: Math.min(Math.max(point.y, 0), height),
|
||||
}));
|
||||
}
|
||||
|
||||
hover(options?: PointerActionOptions): Promise<void> {
|
||||
return this._performPointerAction(point => this._page.mouse.move(point.x, point.y), options);
|
||||
}
|
||||
|
||||
click(options?: ClickOptions): Promise<void> {
|
||||
return this._performPointerAction(point => this._page.mouse.click(point.x, point.y, options), options);
|
||||
}
|
||||
|
||||
dblclick(options?: MultiClickOptions): Promise<void> {
|
||||
return this._performPointerAction(point => this._page.mouse.dblclick(point.x, point.y, options), options);
|
||||
}
|
||||
|
||||
tripleclick(options?: MultiClickOptions): Promise<void> {
|
||||
return this._performPointerAction(point => this._page.mouse.tripleclick(point.x, point.y, options), options);
|
||||
}
|
||||
|
||||
async select(...values: string[]): Promise<string[]> {
|
||||
for (const value of values)
|
||||
assert(helper.isString(value), 'Values must be strings. Found value "' + value + '" of type "' + (typeof value) + '"');
|
||||
return this.evaluate((element: HTMLSelectElement, values: string[]) => {
|
||||
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);
|
||||
}
|
||||
|
||||
async fill(value: string): Promise<void> {
|
||||
assert(helper.isString(value), 'Value must be string. Found value "' + value + '" of type "' + (typeof value) + '"');
|
||||
const error = await this.evaluate((element: HTMLElement) => {
|
||||
if (element.nodeType !== Node.ELEMENT_NODE)
|
||||
return 'Node is not of type HTMLElement';
|
||||
if (element.nodeName.toLowerCase() === 'input') {
|
||||
const input = element as HTMLInputElement;
|
||||
const type = input.getAttribute('type') || '';
|
||||
const kTextInputTypes = new Set(['', 'password', 'search', 'tel', 'text', 'url']);
|
||||
if (!kTextInputTypes.has(type.toLowerCase()))
|
||||
return 'Cannot fill input of type "' + type + '".';
|
||||
input.selectionStart = 0;
|
||||
input.selectionEnd = input.value.length;
|
||||
} else if (element.nodeName.toLowerCase() === 'textarea') {
|
||||
const textarea = element as HTMLTextAreaElement;
|
||||
textarea.selectionStart = 0;
|
||||
textarea.selectionEnd = textarea.value.length;
|
||||
} else if (element.isContentEditable) {
|
||||
if (!element.ownerDocument || !element.ownerDocument.defaultView)
|
||||
return 'Element does not belong to a window';
|
||||
const range = element.ownerDocument.createRange();
|
||||
range.selectNodeContents(element);
|
||||
const selection = element.ownerDocument.defaultView.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} else {
|
||||
return 'Element is not an <input>, <textarea> or [contenteditable] element.';
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (error)
|
||||
throw new Error(error);
|
||||
await this.focus();
|
||||
await this._page.keyboard.sendCharacter(value);
|
||||
}
|
||||
|
||||
async uploadFile(...filePaths: string[]) {
|
||||
const files = filePaths.map(filePath => path.resolve(filePath));
|
||||
const objectId = this._remoteObject.objectId;
|
||||
await this._client.send('DOM.setFileInputFiles', { objectId, files });
|
||||
}
|
||||
|
||||
tap(options?: PointerActionOptions): Promise<void> {
|
||||
return this._performPointerAction(point => this._page.touchscreen.tap(point.x, point.y), options);
|
||||
}
|
||||
|
||||
async focus() {
|
||||
await this.evaluate(element => element.focus());
|
||||
}
|
||||
|
||||
async type(text: string, options: { delay: (number | undefined); } | undefined) {
|
||||
await this.focus();
|
||||
await this._page.keyboard.type(text, options);
|
||||
}
|
||||
|
||||
async press(key: string, options: { delay?: number; text?: string; } | undefined) {
|
||||
await this.focus();
|
||||
await this._page.keyboard.press(key, options);
|
||||
}
|
||||
|
||||
async boundingBox(): Promise<{ x: number; y: number; width: number; height: number; } | null> {
|
||||
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};
|
||||
}
|
||||
|
||||
async boxModel(): Promise<BoxModel | null> {
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
async screenshot(options: any = {}): Promise<string | Buffer> {
|
||||
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;
|
||||
}
|
||||
|
||||
async $(selector: string): Promise<ElementHandle | null> {
|
||||
const handle = await this.evaluateHandle(
|
||||
(element, selector) => element.querySelector(selector),
|
||||
selector
|
||||
);
|
||||
const element = handle.asElement();
|
||||
if (element)
|
||||
return element;
|
||||
await handle.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
async $$(selector: string): Promise<ElementHandle[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
async $eval(selector: string, pageFunction: Function | string, ...args: any[]): Promise<(object | undefined)> {
|
||||
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;
|
||||
}
|
||||
|
||||
async $$eval(selector: string, pageFunction: Function | string, ...args: any[]): Promise<(object | undefined)> {
|
||||
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;
|
||||
}
|
||||
|
||||
async $x(expression: string): Promise<ElementHandle[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
isIntersectingViewport(): Promise<boolean> {
|
||||
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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
type BoxModel = {
|
||||
content: {x: number, y: number}[],
|
||||
padding: {x: number, y: number}[],
|
||||
border: {x: number, y: number}[],
|
||||
margin: {x: number, y: number}[],
|
||||
width: number,
|
||||
height : number
|
||||
};
|
404
src/chromium/Launcher.ts
Normal file
404
src/chromium/Launcher.ts
Normal file
@ -0,0 +1,404 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import * as childProcess from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as readline from 'readline';
|
||||
import * as removeFolder from 'rimraf';
|
||||
import * as URL from 'url';
|
||||
import { Browser } from './Browser';
|
||||
import { BrowserFetcher } from './BrowserFetcher';
|
||||
import { Connection } from './Connection';
|
||||
import { TimeoutError } from '../Errors';
|
||||
import { assert, debugError, helper } from '../helper';
|
||||
import { Viewport } from './Page';
|
||||
import { PipeTransport } from './PipeTransport';
|
||||
import { WebSocketTransport } from './WebSocketTransport';
|
||||
import { ConnectionTransport } from '../ConnectionTransport';
|
||||
|
||||
const mkdtempAsync = helper.promisify(fs.mkdtemp);
|
||||
const removeFolderAsync = helper.promisify(removeFolder);
|
||||
|
||||
const CHROME_PROFILE_PATH = path.join(os.tmpdir(), 'playwright_dev_profile-');
|
||||
|
||||
const DEFAULT_ARGS = [
|
||||
'--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',
|
||||
// BlinkGenPropertyTrees disabled due to crbug.com/937609
|
||||
'--disable-features=TranslateUI,BlinkGenPropertyTrees',
|
||||
'--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',
|
||||
];
|
||||
|
||||
export class Launcher {
|
||||
private _projectRoot: string;
|
||||
private _preferredRevision: string;
|
||||
private _isPlaywrightCore: boolean;
|
||||
|
||||
constructor(projectRoot: string, preferredRevision: string, isPlaywrightCore: boolean) {
|
||||
this._projectRoot = projectRoot;
|
||||
this._preferredRevision = preferredRevision;
|
||||
this._isPlaywrightCore = isPlaywrightCore;
|
||||
}
|
||||
|
||||
async launch(options: (LauncherLaunchOptions & LauncherChromeArgOptions & LauncherBrowserOptions) = {}): Promise<Browser> {
|
||||
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 chromeArguments = [];
|
||||
if (!ignoreDefaultArgs)
|
||||
chromeArguments.push(...this.defaultArgs(options));
|
||||
else if (Array.isArray(ignoreDefaultArgs))
|
||||
chromeArguments.push(...this.defaultArgs(options).filter(arg => ignoreDefaultArgs.indexOf(arg) === -1));
|
||||
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(CHROME_PROFILE_PATH);
|
||||
chromeArguments.push(`--user-data-dir=${temporaryUserDataDir}`);
|
||||
}
|
||||
|
||||
let chromeExecutable = executablePath;
|
||||
if (!executablePath) {
|
||||
const {missingText, executablePath} = this._resolveExecutablePath();
|
||||
if (missingText)
|
||||
throw new Error(missingText);
|
||||
chromeExecutable = executablePath;
|
||||
}
|
||||
|
||||
const usePipe = chromeArguments.includes('--remote-debugging-pipe');
|
||||
let stdio: ('ignore' | 'pipe')[] = ['pipe', 'pipe', 'pipe'];
|
||||
if (usePipe) {
|
||||
if (dumpio)
|
||||
stdio = ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'];
|
||||
else
|
||||
stdio = ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'];
|
||||
}
|
||||
const chromeProcess = childProcess.spawn(
|
||||
chromeExecutable,
|
||||
chromeArguments,
|
||||
{
|
||||
// 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) {
|
||||
chromeProcess.stderr.pipe(process.stderr);
|
||||
chromeProcess.stdout.pipe(process.stdout);
|
||||
}
|
||||
|
||||
let chromeClosed = false;
|
||||
const waitForChromeToClose = new Promise((fulfill, reject) => {
|
||||
chromeProcess.once('exit', () => {
|
||||
chromeClosed = true;
|
||||
// Cleanup as processes exit.
|
||||
if (temporaryUserDataDir) {
|
||||
removeFolderAsync(temporaryUserDataDir)
|
||||
.then(() => fulfill())
|
||||
.catch(err => console.error(err));
|
||||
} else {
|
||||
fulfill();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const listeners = [ helper.addEventListener(process, 'exit', killChrome) ];
|
||||
if (handleSIGINT)
|
||||
listeners.push(helper.addEventListener(process, 'SIGINT', () => { killChrome(); process.exit(130); }));
|
||||
if (handleSIGTERM)
|
||||
listeners.push(helper.addEventListener(process, 'SIGTERM', gracefullyCloseChrome));
|
||||
if (handleSIGHUP)
|
||||
listeners.push(helper.addEventListener(process, 'SIGHUP', gracefullyCloseChrome));
|
||||
let connection: Connection | null = null;
|
||||
try {
|
||||
if (!usePipe) {
|
||||
const browserWSEndpoint = await waitForWSEndpoint(chromeProcess, timeout, this._preferredRevision);
|
||||
const transport = await WebSocketTransport.create(browserWSEndpoint);
|
||||
connection = new Connection(browserWSEndpoint, transport, slowMo);
|
||||
} else {
|
||||
const transport = new PipeTransport(chromeProcess.stdio[3] as NodeJS.WritableStream, chromeProcess.stdio[4] as NodeJS.ReadableStream);
|
||||
connection = new Connection('', transport, slowMo);
|
||||
}
|
||||
const browser = await Browser.create(connection, [], ignoreHTTPSErrors, defaultViewport, chromeProcess, gracefullyCloseChrome);
|
||||
await browser.waitForTarget(t => t.type() === 'page');
|
||||
return browser;
|
||||
} catch (e) {
|
||||
killChrome();
|
||||
throw e;
|
||||
}
|
||||
|
||||
function gracefullyCloseChrome(): Promise<any> {
|
||||
helper.removeEventListeners(listeners);
|
||||
if (temporaryUserDataDir) {
|
||||
killChrome();
|
||||
} else if (connection) {
|
||||
// Attempt to close chrome gracefully
|
||||
connection.send('Browser.close').catch(error => {
|
||||
debugError(error);
|
||||
killChrome();
|
||||
});
|
||||
}
|
||||
return waitForChromeToClose;
|
||||
}
|
||||
|
||||
// This method has to be sync to be used as 'exit' event handler.
|
||||
function killChrome() {
|
||||
helper.removeEventListeners(listeners);
|
||||
if (chromeProcess.pid && !chromeProcess.killed && !chromeClosed) {
|
||||
// Force kill chrome.
|
||||
try {
|
||||
if (process.platform === 'win32')
|
||||
childProcess.execSync(`taskkill /pid ${chromeProcess.pid} /T /F`);
|
||||
else
|
||||
process.kill(-chromeProcess.pid, 'SIGKILL');
|
||||
} catch (e) {
|
||||
// the process might have already stopped
|
||||
}
|
||||
}
|
||||
// Attempt to remove temporary profile directory to avoid littering.
|
||||
try {
|
||||
removeFolder.sync(temporaryUserDataDir);
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
|
||||
defaultArgs(options: LauncherChromeArgOptions = {}): string[] {
|
||||
const {
|
||||
devtools = false,
|
||||
headless = !devtools,
|
||||
args = [],
|
||||
userDataDir = null
|
||||
} = options;
|
||||
const chromeArguments = [...DEFAULT_ARGS];
|
||||
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;
|
||||
}
|
||||
|
||||
executablePath(): string {
|
||||
return this._resolveExecutablePath().executablePath;
|
||||
}
|
||||
|
||||
async connect(options: (LauncherBrowserOptions & {
|
||||
browserWSEndpoint?: string;
|
||||
browserURL?: string;
|
||||
transport?: ConnectionTransport; })): Promise<Browser> {
|
||||
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 playwright.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));
|
||||
}
|
||||
|
||||
_resolveExecutablePath(): { executablePath: string; missingText: string | null; } {
|
||||
// playwright-core doesn't take into account PLAYWRIGHT_* env variables.
|
||||
if (!this._isPlaywrightCore) {
|
||||
const executablePath = process.env.PLAYWRIGHT_EXECUTABLE_PATH || process.env.npm_config_playwright_executable_path || process.env.npm_package_config_playwright_executable_path;
|
||||
if (executablePath) {
|
||||
const missingText = !fs.existsSync(executablePath) ? 'Tried to use PLAYWRIGHT_EXECUTABLE_PATH env variable to launch browser but did not find any executable at: ' + executablePath : null;
|
||||
return { executablePath, missingText };
|
||||
}
|
||||
}
|
||||
const browserFetcher = new BrowserFetcher(this._projectRoot);
|
||||
if (!this._isPlaywrightCore) {
|
||||
const revision = process.env['PLAYWRIGHT_CHROMIUM_REVISION'];
|
||||
if (revision) {
|
||||
const revisionInfo = browserFetcher.revisionInfo(revision);
|
||||
const missingText = !revisionInfo.local ? 'Tried to use PLAYWRIGHT_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(this._preferredRevision);
|
||||
const missingText = !revisionInfo.local ? `Chromium revision is not downloaded. Run "npm install" or "yarn install"` : null;
|
||||
return {executablePath: revisionInfo.executablePath, missingText};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function waitForWSEndpoint(chromeProcess: childProcess.ChildProcess, timeout: number, preferredRevision: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const rl = readline.createInterface({ input: chromeProcess.stderr });
|
||||
let stderr = '';
|
||||
const listeners = [
|
||||
helper.addEventListener(rl, 'line', onLine),
|
||||
helper.addEventListener(rl, 'close', () => onClose()),
|
||||
helper.addEventListener(chromeProcess, 'exit', () => onClose()),
|
||||
helper.addEventListener(chromeProcess, 'error', error => onClose(error))
|
||||
];
|
||||
const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0;
|
||||
|
||||
function onClose(error?: Error) {
|
||||
cleanup();
|
||||
reject(new Error([
|
||||
'Failed to launch chrome!' + (error ? ' ' + error.message : ''),
|
||||
stderr,
|
||||
'',
|
||||
'TROUBLESHOOTING: https://github.com/Microsoft/playwright/blob/master/docs/troubleshooting.md',
|
||||
'',
|
||||
].join('\n')));
|
||||
}
|
||||
|
||||
function onTimeout() {
|
||||
cleanup();
|
||||
reject(new TimeoutError(`Timed out after ${timeout} ms while trying to connect to Chrome! The only Chrome revision guaranteed to work is r${preferredRevision}`));
|
||||
}
|
||||
|
||||
function onLine(line: string) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getWSEndpoint(browserURL: string): Promise<string> {
|
||||
let resolve, reject;
|
||||
const promise = new Promise<string>((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;
|
||||
});
|
||||
}
|
||||
|
||||
export type LauncherChromeArgOptions = {
|
||||
headless?: boolean,
|
||||
args?: string[],
|
||||
userDataDir?: string,
|
||||
devtools?: boolean,
|
||||
};
|
||||
|
||||
export type LauncherLaunchOptions = {
|
||||
executablePath?: string,
|
||||
ignoreDefaultArgs?: boolean|string[],
|
||||
handleSIGINT?: boolean,
|
||||
handleSIGTERM?: boolean,
|
||||
handleSIGHUP?: boolean,
|
||||
timeout?: number,
|
||||
dumpio?: boolean,
|
||||
env?: {[key: string]: string} | undefined,
|
||||
pipe?: boolean,
|
||||
};
|
||||
|
||||
export type LauncherBrowserOptions = {
|
||||
ignoreHTTPSErrors?: boolean,
|
||||
defaultViewport?: Viewport | null,
|
||||
slowMo?: number,
|
||||
};
|
176
src/chromium/LifecycleWatcher.ts
Normal file
176
src/chromium/LifecycleWatcher.ts
Normal file
@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Copyright 2019 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { CDPSessionEvents } from './Connection';
|
||||
import { TimeoutError } from '../Errors';
|
||||
import { Frame } from './Frame';
|
||||
import { FrameManager, FrameManagerEvents } from './FrameManager';
|
||||
import { assert, helper, RegisteredListener } from '../helper';
|
||||
import { NetworkManagerEvents, Request, Response } from './NetworkManager';
|
||||
|
||||
export class LifecycleWatcher {
|
||||
private _expectedLifecycle: string[];
|
||||
private _frameManager: FrameManager;
|
||||
private _frame: Frame;
|
||||
private _initialLoaderId: string;
|
||||
private _timeout: number;
|
||||
private _navigationRequest: Request | null = null;
|
||||
private _eventListeners: RegisteredListener[];
|
||||
private _sameDocumentNavigationPromise: Promise<Error | null>;
|
||||
private _sameDocumentNavigationCompleteCallback: () => void;
|
||||
private _lifecyclePromise: Promise<void>;
|
||||
private _lifecycleCallback: () => void;
|
||||
private _newDocumentNavigationPromise: Promise<Error | null>;
|
||||
private _newDocumentNavigationCompleteCallback: () => void;
|
||||
private _timeoutPromise: Promise<Error>;
|
||||
private _terminationPromise: Promise<Error | null>;
|
||||
private _terminationCallback: () => void;
|
||||
private _maximumTimer: NodeJS.Timer;
|
||||
private _hasSameDocumentNavigation: boolean;
|
||||
|
||||
constructor(frameManager: FrameManager, frame: Frame, waitUntil: string | string[], timeout: number) {
|
||||
if (Array.isArray(waitUntil))
|
||||
waitUntil = waitUntil.slice();
|
||||
else if (typeof waitUntil === 'string')
|
||||
waitUntil = [waitUntil];
|
||||
this._expectedLifecycle = waitUntil.map(value => {
|
||||
const protocolEvent = playwrightToProtocolLifecycle.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;
|
||||
this._eventListeners = [
|
||||
helper.addEventListener(frameManager._client, CDPSessionEvents.Disconnected, () => this._terminate(new Error('Navigation failed because browser has disconnected!'))),
|
||||
helper.addEventListener(this._frameManager, FrameManagerEvents.LifecycleEvent, this._checkLifecycleComplete.bind(this)),
|
||||
helper.addEventListener(this._frameManager, FrameManagerEvents.FrameNavigatedWithinDocument, this._navigatedWithinDocument.bind(this)),
|
||||
helper.addEventListener(this._frameManager, FrameManagerEvents.FrameDetached, this._onFrameDetached.bind(this)),
|
||||
helper.addEventListener(this._frameManager.networkManager(), NetworkManagerEvents.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();
|
||||
}
|
||||
|
||||
_onRequest(request: Request) {
|
||||
if (request.frame() !== this._frame || !request.isNavigationRequest())
|
||||
return;
|
||||
this._navigationRequest = request;
|
||||
}
|
||||
|
||||
_onFrameDetached(frame: Frame) {
|
||||
if (this._frame === frame) {
|
||||
this._terminationCallback.call(null, new Error('Navigating frame was detached'));
|
||||
return;
|
||||
}
|
||||
this._checkLifecycleComplete();
|
||||
}
|
||||
|
||||
navigationResponse(): Response | null {
|
||||
return this._navigationRequest ? this._navigationRequest.response() : null;
|
||||
}
|
||||
|
||||
_terminate(error: Error) {
|
||||
this._terminationCallback.call(null, error);
|
||||
}
|
||||
|
||||
sameDocumentNavigationPromise(): Promise<Error | null> {
|
||||
return this._sameDocumentNavigationPromise;
|
||||
}
|
||||
|
||||
newDocumentNavigationPromise(): Promise<Error | null> {
|
||||
return this._newDocumentNavigationPromise;
|
||||
}
|
||||
|
||||
lifecyclePromise(): Promise<any> {
|
||||
return this._lifecyclePromise;
|
||||
}
|
||||
|
||||
timeoutOrTerminationPromise(): Promise<Error | null> {
|
||||
return Promise.race([this._timeoutPromise, this._terminationPromise]);
|
||||
}
|
||||
|
||||
_createTimeoutPromise(): Promise<Error | null> {
|
||||
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));
|
||||
}
|
||||
|
||||
_navigatedWithinDocument(frame: 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();
|
||||
|
||||
function checkLifecycle(frame: Frame, expectedLifecycle: string[]): boolean {
|
||||
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 playwrightToProtocolLifecycle = new Map([
|
||||
['load', 'load'],
|
||||
['domcontentloaded', 'DOMContentLoaded'],
|
||||
['networkidle0', 'networkIdle'],
|
||||
['networkidle2', 'networkAlmostIdle'],
|
||||
]);
|
93
src/chromium/Multimap.ts
Normal file
93
src/chromium/Multimap.ts
Normal file
@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export class Multimap<T, V> {
|
||||
private _map: Map<T, Set<V>>;
|
||||
|
||||
constructor() {
|
||||
this._map = new Map();
|
||||
}
|
||||
|
||||
set(key: T, value: V) {
|
||||
let set = this._map.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this._map.set(key, set);
|
||||
}
|
||||
set.add(value);
|
||||
}
|
||||
|
||||
get(key: T): Set<V> {
|
||||
let result = this._map.get(key);
|
||||
if (!result)
|
||||
result = new Set();
|
||||
return result;
|
||||
}
|
||||
|
||||
has(key: T): boolean {
|
||||
return this._map.has(key);
|
||||
}
|
||||
|
||||
hasValue(key: T, value: V): boolean {
|
||||
const set = this._map.get(key);
|
||||
if (!set)
|
||||
return false;
|
||||
return set.has(value);
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this._map.size;
|
||||
}
|
||||
|
||||
delete(key: T, value: V): boolean {
|
||||
const values = this.get(key);
|
||||
const result = values.delete(value);
|
||||
if (!values.size)
|
||||
this._map.delete(key);
|
||||
return result;
|
||||
}
|
||||
|
||||
deleteAll(key: T) {
|
||||
this._map.delete(key);
|
||||
}
|
||||
|
||||
firstValue(key: T): V {
|
||||
const set = this._map.get(key);
|
||||
if (!set)
|
||||
return null;
|
||||
return set.values().next().value;
|
||||
}
|
||||
|
||||
firstKey(): T {
|
||||
return this._map.keys().next().value;
|
||||
}
|
||||
|
||||
valuesArray(): V[] {
|
||||
const result = [];
|
||||
for (const key of this._map.keys())
|
||||
result.push(...Array.from(this._map.get(key).values()));
|
||||
return result;
|
||||
}
|
||||
|
||||
keysArray(): T[] {
|
||||
return Array.from(this._map.keys());
|
||||
}
|
||||
|
||||
clear() {
|
||||
this._map.clear();
|
||||
}
|
||||
}
|
657
src/chromium/NetworkManager.ts
Normal file
657
src/chromium/NetworkManager.ts
Normal file
@ -0,0 +1,657 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { CDPSession } from './Connection';
|
||||
import { Frame } from './Frame';
|
||||
import { FrameManager } from './FrameManager';
|
||||
import { assert, debugError, helper } from '../helper';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
export const NetworkManagerEvents = {
|
||||
Request: Symbol('Events.NetworkManager.Request'),
|
||||
Response: Symbol('Events.NetworkManager.Response'),
|
||||
RequestFailed: Symbol('Events.NetworkManager.RequestFailed'),
|
||||
RequestFinished: Symbol('Events.NetworkManager.RequestFinished'),
|
||||
};
|
||||
|
||||
export class NetworkManager extends EventEmitter {
|
||||
private _client: CDPSession;
|
||||
private _ignoreHTTPSErrors: boolean;
|
||||
private _frameManager: FrameManager;
|
||||
private _requestIdToRequest = new Map<string, Request>();
|
||||
private _requestIdToRequestWillBeSentEvent = new Map<string, Protocol.Network.requestWillBeSentPayload>();
|
||||
private _extraHTTPHeaders: {[key: string]: string} = {};
|
||||
private _offline = false;
|
||||
private _credentials: {username: string, password: string} | null = null;
|
||||
private _attemptedAuthentications = new Set<string>();
|
||||
private _userRequestInterceptionEnabled = false;
|
||||
private _protocolRequestInterceptionEnabled = false;
|
||||
private _userCacheDisabled = false;
|
||||
private _requestIdToInterceptionId = new Map<string, string>();
|
||||
|
||||
constructor(client: CDPSession, ignoreHTTPSErrors: boolean, frameManager: FrameManager) {
|
||||
super();
|
||||
this._client = client;
|
||||
this._ignoreHTTPSErrors = ignoreHTTPSErrors;
|
||||
this._frameManager = frameManager;
|
||||
|
||||
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});
|
||||
}
|
||||
|
||||
async authenticate(credentials: { username: string; password: string; } | null) {
|
||||
this._credentials = credentials;
|
||||
await this._updateProtocolRequestInterception();
|
||||
}
|
||||
|
||||
async setExtraHTTPHeaders(extraHTTPHeaders: { [s: string]: string; }) {
|
||||
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 });
|
||||
}
|
||||
|
||||
extraHTTPHeaders(): { [s: string]: string; } {
|
||||
return Object.assign({}, this._extraHTTPHeaders);
|
||||
}
|
||||
|
||||
async setOfflineMode(value: boolean) {
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
async setUserAgent(userAgent: string) {
|
||||
await this._client.send('Network.setUserAgentOverride', { userAgent });
|
||||
}
|
||||
|
||||
async setCacheEnabled(enabled: boolean) {
|
||||
this._userCacheDisabled = !enabled;
|
||||
await this._updateProtocolCacheDisabled();
|
||||
}
|
||||
|
||||
async setRequestInterception(value: boolean) {
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
_onRequestWillBeSent(event: Protocol.Network.requestWillBeSentPayload) {
|
||||
// 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);
|
||||
}
|
||||
|
||||
_onAuthRequired(event: Protocol.Fetch.authRequiredPayload) {
|
||||
let response: 'Default' | 'CancelAuth' | 'ProvideCredentials' = '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);
|
||||
}
|
||||
|
||||
_onRequestPaused(event: Protocol.Fetch.requestPausedPayload) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
_onRequest(event: Protocol.Network.requestWillBeSentPayload, interceptionId: string | null) {
|
||||
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(NetworkManagerEvents.Request, request);
|
||||
}
|
||||
|
||||
|
||||
_onRequestServedFromCache(event: Protocol.Network.requestServedFromCachePayload) {
|
||||
const request = this._requestIdToRequest.get(event.requestId);
|
||||
if (request)
|
||||
request._fromMemoryCache = true;
|
||||
}
|
||||
|
||||
_handleRequestRedirect(request: Request, responsePayload: Protocol.Network.Response) {
|
||||
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(NetworkManagerEvents.Response, response);
|
||||
this.emit(NetworkManagerEvents.RequestFinished, request);
|
||||
}
|
||||
|
||||
_onResponseReceived(event: Protocol.Network.responseReceivedPayload) {
|
||||
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(NetworkManagerEvents.Response, response);
|
||||
}
|
||||
|
||||
_onLoadingFinished(event: Protocol.Network.loadingFinishedPayload) {
|
||||
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(NetworkManagerEvents.RequestFinished, request);
|
||||
}
|
||||
|
||||
_onLoadingFailed(event: Protocol.Network.loadingFailedPayload) {
|
||||
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(NetworkManagerEvents.RequestFailed, request);
|
||||
}
|
||||
}
|
||||
|
||||
export class Request {
|
||||
_response: Response | null = null;
|
||||
_redirectChain: Request[];
|
||||
_requestId: string;
|
||||
_interceptionId: string;
|
||||
private _client: CDPSession;
|
||||
private _isNavigationRequest: boolean;
|
||||
private _allowInterception: boolean;
|
||||
private _interceptionHandled = false;
|
||||
_failureText: string | null = null;
|
||||
private _url: string;
|
||||
private _resourceType: string;
|
||||
private _method: string;
|
||||
private _postData: string;
|
||||
private _headers: {[key: string]: string} = {};
|
||||
private _frame: Frame;
|
||||
_fromMemoryCache = false;
|
||||
|
||||
constructor(client: CDPSession, frame: Frame | null, interceptionId: string, allowInterception: boolean, event: Protocol.Network.requestWillBeSentPayload, redirectChain: Request[]) {
|
||||
this._client = client;
|
||||
this._requestId = event.requestId;
|
||||
this._isNavigationRequest = event.requestId === event.loaderId && event.type === 'Document';
|
||||
this._interceptionId = interceptionId;
|
||||
this._allowInterception = allowInterception;
|
||||
|
||||
this._url = event.request.url;
|
||||
this._resourceType = event.type.toLowerCase();
|
||||
this._method = event.request.method;
|
||||
this._postData = event.request.postData;
|
||||
this._frame = frame;
|
||||
this._redirectChain = redirectChain;
|
||||
for (const key of Object.keys(event.request.headers))
|
||||
this._headers[key.toLowerCase()] = event.request.headers[key];
|
||||
}
|
||||
|
||||
url(): string {
|
||||
return this._url;
|
||||
}
|
||||
|
||||
resourceType(): string {
|
||||
return this._resourceType;
|
||||
}
|
||||
|
||||
method(): string {
|
||||
return this._method;
|
||||
}
|
||||
|
||||
postData(): string | undefined {
|
||||
return this._postData;
|
||||
}
|
||||
|
||||
headers(): {[key: string]: string} {
|
||||
return this._headers;
|
||||
}
|
||||
|
||||
response(): Response | null {
|
||||
return this._response;
|
||||
}
|
||||
|
||||
frame(): Frame | null {
|
||||
return this._frame;
|
||||
}
|
||||
|
||||
isNavigationRequest(): boolean {
|
||||
return this._isNavigationRequest;
|
||||
}
|
||||
|
||||
redirectChain(): Request[] {
|
||||
return this._redirectChain.slice();
|
||||
}
|
||||
|
||||
failure(): { errorText: string; } | null {
|
||||
if (!this._failureText)
|
||||
return null;
|
||||
return {
|
||||
errorText: this._failureText
|
||||
};
|
||||
}
|
||||
|
||||
async continue(overrides: { url?: string; method?: string; postData?: string; headers?: {[key: string]: string}; } = {}) {
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
async respond(response: { status: number; headers: {[key: string]: string}; contentType: string; body: (string | Buffer); }) {
|
||||
// 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);
|
||||
|
||||
const responseHeaders: { [s: string]: string; } = {};
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
async abort(errorCode: string = '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',
|
||||
};
|
||||
|
||||
export class Response {
|
||||
_bodyLoadedPromiseFulfill: any;
|
||||
private _client: CDPSession;
|
||||
private _request: Request;
|
||||
private _contentPromise: Promise<Buffer> | null = null;
|
||||
private _bodyLoadedPromise: Promise<Error | null>;
|
||||
private _remoteAddress: { ip: string; port: number; };
|
||||
private _status: number;
|
||||
private _statusText: string;
|
||||
private _url: string;
|
||||
private _fromDiskCache: boolean;
|
||||
private _fromServiceWorker: boolean;
|
||||
private _headers: {[key: string]: string} = {};
|
||||
private _securityDetails: SecurityDetails;
|
||||
|
||||
constructor(client: CDPSession, request: Request, responsePayload: Protocol.Network.Response) {
|
||||
this._client = client;
|
||||
this._request = request;
|
||||
|
||||
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;
|
||||
for (const key of Object.keys(responsePayload.headers))
|
||||
this._headers[key.toLowerCase()] = responsePayload.headers[key];
|
||||
this._securityDetails = responsePayload.securityDetails ? new SecurityDetails(responsePayload.securityDetails) : null;
|
||||
}
|
||||
|
||||
remoteAddress(): { ip: string; port: number; } {
|
||||
return this._remoteAddress;
|
||||
}
|
||||
|
||||
url(): string {
|
||||
return this._url;
|
||||
}
|
||||
|
||||
ok(): boolean {
|
||||
return this._status === 0 || (this._status >= 200 && this._status <= 299);
|
||||
}
|
||||
|
||||
status(): number {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
statusText(): string {
|
||||
return this._statusText;
|
||||
}
|
||||
|
||||
headers(): object {
|
||||
return this._headers;
|
||||
}
|
||||
|
||||
securityDetails(): SecurityDetails | null {
|
||||
return this._securityDetails;
|
||||
}
|
||||
|
||||
buffer(): Promise<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;
|
||||
}
|
||||
|
||||
async text(): Promise<string> {
|
||||
const content = await this.buffer();
|
||||
return content.toString('utf8');
|
||||
}
|
||||
|
||||
async json(): Promise<object> {
|
||||
const content = await this.text();
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
request(): Request {
|
||||
return this._request;
|
||||
}
|
||||
|
||||
fromCache(): boolean {
|
||||
return this._fromDiskCache || this._request._fromMemoryCache;
|
||||
}
|
||||
|
||||
fromServiceWorker(): boolean {
|
||||
return this._fromServiceWorker;
|
||||
}
|
||||
|
||||
frame(): Frame | null {
|
||||
return this._request.frame();
|
||||
}
|
||||
}
|
||||
|
||||
export class SecurityDetails {
|
||||
private _subjectName: string;
|
||||
private _issuer: string;
|
||||
private _validFrom: number;
|
||||
private _validTo: number;
|
||||
private _protocol: string;
|
||||
|
||||
constructor(securityPayload: Protocol.Network.SecurityDetails) {
|
||||
this._subjectName = securityPayload['subjectName'];
|
||||
this._issuer = securityPayload['issuer'];
|
||||
this._validFrom = securityPayload['validFrom'];
|
||||
this._validTo = securityPayload['validTo'];
|
||||
this._protocol = securityPayload['protocol'];
|
||||
}
|
||||
|
||||
subjectName(): string {
|
||||
return this._subjectName;
|
||||
}
|
||||
|
||||
issuer(): string {
|
||||
return this._issuer;
|
||||
}
|
||||
|
||||
validFrom(): number {
|
||||
return this._validFrom;
|
||||
}
|
||||
|
||||
validTo(): number {
|
||||
return this._validTo;
|
||||
}
|
||||
|
||||
protocol(): string {
|
||||
return this._protocol;
|
||||
}
|
||||
}
|
||||
|
||||
function headersArray(headers: { [s: string]: string; }): { name: string; value: string; }[] {
|
||||
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',
|
||||
};
|
1073
src/chromium/Page.ts
Normal file
1073
src/chromium/Page.ts
Normal file
File diff suppressed because it is too large
Load Diff
73
src/chromium/PipeTransport.ts
Normal file
73
src/chromium/PipeTransport.ts
Normal file
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ConnectionTransport } from '../ConnectionTransport';
|
||||
import { debugError, helper, RegisteredListener } from '../helper';
|
||||
|
||||
export class PipeTransport implements ConnectionTransport {
|
||||
private _pipeWrite: NodeJS.WritableStream;
|
||||
private _pendingMessage = '';
|
||||
private _eventListeners: RegisteredListener[];
|
||||
onmessage?: (message: string) => void;
|
||||
onclose?: () => void;
|
||||
|
||||
constructor(pipeWrite: NodeJS.WritableStream, pipeRead: NodeJS.ReadableStream) {
|
||||
this._pipeWrite = pipeWrite;
|
||||
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;
|
||||
}
|
||||
|
||||
send(message: string) {
|
||||
this._pipeWrite.write(message);
|
||||
this._pipeWrite.write('\0');
|
||||
}
|
||||
|
||||
_dispatch(buffer: 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);
|
||||
}
|
||||
}
|
66
src/chromium/Playwright.ts
Normal file
66
src/chromium/Playwright.ts
Normal file
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Browser } from './Browser';
|
||||
import { BrowserFetcher, BrowserFetcherOptions } from './BrowserFetcher';
|
||||
import { ConnectionTransport } from '../ConnectionTransport';
|
||||
import { DeviceDescriptors } from '../DeviceDescriptors';
|
||||
import * as Errors from '../Errors';
|
||||
import { Launcher, LauncherBrowserOptions, LauncherChromeArgOptions, LauncherLaunchOptions } from './Launcher';
|
||||
|
||||
export class Playwright {
|
||||
private _projectRoot: string;
|
||||
private _launcher: Launcher;
|
||||
|
||||
constructor(projectRoot: string, preferredRevision: string, isPlaywrightCore: boolean) {
|
||||
this._projectRoot = projectRoot;
|
||||
this._launcher = new Launcher(projectRoot, preferredRevision, isPlaywrightCore);
|
||||
}
|
||||
|
||||
launch(options: (LauncherLaunchOptions & LauncherChromeArgOptions & LauncherBrowserOptions) | undefined): Promise<Browser> {
|
||||
return this._launcher.launch(options);
|
||||
}
|
||||
|
||||
connect(options: (LauncherBrowserOptions & {
|
||||
browserWSEndpoint?: string;
|
||||
browserURL?: string;
|
||||
transport?: ConnectionTransport; })): Promise<Browser> {
|
||||
return this._launcher.connect(options);
|
||||
}
|
||||
|
||||
executablePath(): string {
|
||||
return this._launcher.executablePath();
|
||||
}
|
||||
|
||||
get devices(): any {
|
||||
const result = DeviceDescriptors.slice();
|
||||
for (const device of DeviceDescriptors)
|
||||
result[device.name] = device;
|
||||
return result;
|
||||
}
|
||||
|
||||
get errors(): any {
|
||||
return Errors;
|
||||
}
|
||||
|
||||
defaultArgs(options: LauncherChromeArgOptions | undefined): string[] {
|
||||
return this._launcher.defaultArgs(options);
|
||||
}
|
||||
|
||||
createBrowserFetcher(options: BrowserFetcherOptions | undefined): BrowserFetcher {
|
||||
return new BrowserFetcher(this._projectRoot, options);
|
||||
}
|
||||
}
|
134
src/chromium/Target.ts
Normal file
134
src/chromium/Target.ts
Normal file
@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Copyright 2019 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Events } from '../Events';
|
||||
import { Browser } from './Browser';
|
||||
import { BrowserContext } from './BrowserContext';
|
||||
import { CDPSession } from './Connection';
|
||||
import { Page, Viewport } from './Page';
|
||||
import { TaskQueue } from './TaskQueue';
|
||||
import { Worker } from './Worker';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
export class Target {
|
||||
private _targetInfo: Protocol.Target.TargetInfo;
|
||||
private _browserContext: BrowserContext;
|
||||
_targetId: string;
|
||||
private _sessionFactory: () => Promise<CDPSession>;
|
||||
private _ignoreHTTPSErrors: boolean;
|
||||
private _defaultViewport: Viewport;
|
||||
private _screenshotTaskQueue: TaskQueue;
|
||||
private _pagePromise: Promise<Page> | null = null;
|
||||
private _workerPromise: Promise<Worker> | null = null;
|
||||
_initializedPromise: Promise<boolean>;
|
||||
_initializedCallback: (value?: unknown) => void;
|
||||
_isClosedPromise: Promise<void>;
|
||||
_closedCallback: (value?: unknown) => void;
|
||||
_isInitialized: boolean;
|
||||
|
||||
constructor(
|
||||
targetInfo: Protocol.Target.TargetInfo,
|
||||
browserContext: BrowserContext,
|
||||
sessionFactory: () => Promise<CDPSession>,
|
||||
ignoreHTTPSErrors: boolean,
|
||||
defaultViewport: Viewport | null,
|
||||
screenshotTaskQueue: TaskQueue) {
|
||||
this._targetInfo = targetInfo;
|
||||
this._browserContext = browserContext;
|
||||
this._targetId = targetInfo.targetId;
|
||||
this._sessionFactory = sessionFactory;
|
||||
this._ignoreHTTPSErrors = ignoreHTTPSErrors;
|
||||
this._defaultViewport = defaultViewport;
|
||||
this._screenshotTaskQueue = screenshotTaskQueue;
|
||||
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);
|
||||
}
|
||||
|
||||
createCDPSession(): Promise<CDPSession> {
|
||||
return this._sessionFactory();
|
||||
}
|
||||
|
||||
async page(): Promise<Page | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
async worker(): Promise<Worker | null> {
|
||||
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 this._workerPromise;
|
||||
}
|
||||
|
||||
url(): string {
|
||||
return this._targetInfo.url;
|
||||
}
|
||||
|
||||
type(): 'page' | 'background_page' | 'service_worker' | 'shared_worker' | 'other' | 'browser' {
|
||||
const type = this._targetInfo.type;
|
||||
if (type === 'page' || type === 'background_page' || type === 'service_worker' || type === 'shared_worker' || type === 'browser')
|
||||
return type;
|
||||
return 'other';
|
||||
}
|
||||
|
||||
browser(): Browser {
|
||||
return this._browserContext.browser();
|
||||
}
|
||||
|
||||
browserContext(): BrowserContext {
|
||||
return this._browserContext;
|
||||
}
|
||||
|
||||
opener(): Target | null {
|
||||
const { openerId } = this._targetInfo;
|
||||
if (!openerId)
|
||||
return null;
|
||||
return this.browser()._targets.get(openerId);
|
||||
}
|
||||
|
||||
_targetInfoChanged(targetInfo: Protocol.Target.TargetInfo) {
|
||||
this._targetInfo = targetInfo;
|
||||
|
||||
if (!this._isInitialized && (this._targetInfo.type !== 'page' || this._targetInfo.url !== '')) {
|
||||
this._isInitialized = true;
|
||||
this._initializedCallback(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
30
src/chromium/TaskQueue.ts
Normal file
30
src/chromium/TaskQueue.ts
Normal file
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Copyright 2019 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export class TaskQueue {
|
||||
private _chain: Promise<any>;
|
||||
|
||||
constructor() {
|
||||
this._chain = Promise.resolve();
|
||||
}
|
||||
|
||||
postTask(task: () => any): Promise<any> {
|
||||
const result = this._chain.then(task);
|
||||
this._chain = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
}
|
66
src/chromium/Tracing.ts
Normal file
66
src/chromium/Tracing.ts
Normal file
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { CDPSession } from './Connection';
|
||||
import { assert } from '../helper';
|
||||
import { readProtocolStream } from './protocolHelper';
|
||||
|
||||
export class Tracing {
|
||||
private _client: CDPSession;
|
||||
private _recording = false;
|
||||
private _path = '';
|
||||
|
||||
constructor(client: CDPSession) {
|
||||
this._client = client;
|
||||
}
|
||||
|
||||
async start(options: { path?: string; screenshots?: boolean; categories?: string[]; } = {}) {
|
||||
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(',')
|
||||
});
|
||||
}
|
||||
|
||||
async stop(): Promise<Buffer> {
|
||||
let fulfill: (buffer: Buffer) => void;
|
||||
const contentPromise = new Promise<Buffer>(x => fulfill = x);
|
||||
this._client.once('Tracing.tracingComplete', event => {
|
||||
readProtocolStream(this._client, event.stream, this._path).then(fulfill);
|
||||
});
|
||||
await this._client.send('Tracing.end');
|
||||
this._recording = false;
|
||||
return contentPromise;
|
||||
}
|
||||
}
|
58
src/chromium/WebSocketTransport.ts
Normal file
58
src/chromium/WebSocketTransport.ts
Normal file
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as WebSocket from 'ws';
|
||||
import { ConnectionTransport } from '../ConnectionTransport';
|
||||
|
||||
export class WebSocketTransport implements ConnectionTransport {
|
||||
private _ws: WebSocket;
|
||||
onmessage?: (message: string) => void;
|
||||
onclose?: () => void;
|
||||
|
||||
static create(url: string): Promise<WebSocketTransport> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
constructor(ws: WebSocket) {
|
||||
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', () => {});
|
||||
}
|
||||
|
||||
send(message: string) {
|
||||
this._ws.send(message);
|
||||
}
|
||||
|
||||
close() {
|
||||
this._ws.close();
|
||||
}
|
||||
}
|
63
src/chromium/Worker.ts
Normal file
63
src/chromium/Worker.ts
Normal file
@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { EventEmitter } from 'events';
|
||||
import { CDPSession } from './Connection';
|
||||
import { ExecutionContext } from './ExecutionContext';
|
||||
import { debugError } from '../helper';
|
||||
import { JSHandle } from './JSHandle';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
export class Worker extends EventEmitter {
|
||||
private _client: CDPSession;
|
||||
private _url: string;
|
||||
private _executionContextPromise: Promise<ExecutionContext>;
|
||||
private _executionContextCallback: (value?: ExecutionContext) => void;
|
||||
|
||||
constructor(client: CDPSession, url: string, consoleAPICalled: (arg0: string, arg1: JSHandle[], arg2: Protocol.Runtime.StackTrace | undefined) => void, exceptionThrown: (arg0: Protocol.Runtime.ExceptionDetails) => void) {
|
||||
super();
|
||||
this._client = client;
|
||||
this._url = url;
|
||||
this._executionContextPromise = new Promise(x => this._executionContextCallback = x);
|
||||
let jsHandleFactory: (o: Protocol.Runtime.RemoteObject) => JSHandle;
|
||||
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));
|
||||
}
|
||||
|
||||
url(): string {
|
||||
return this._url;
|
||||
}
|
||||
|
||||
async executionContext(): Promise<ExecutionContext> {
|
||||
return this._executionContextPromise;
|
||||
}
|
||||
|
||||
async evaluate(pageFunction: Function | string, ...args: any[]): Promise<any> {
|
||||
return (await this._executionContextPromise).evaluate(pageFunction, ...args);
|
||||
}
|
||||
|
||||
async evaluateHandle(pageFunction: Function | string, ...args: any[]): Promise<JSHandle> {
|
||||
return (await this._executionContextPromise).evaluateHandle(pageFunction, ...args);
|
||||
}
|
||||
}
|
97
src/chromium/protocolHelper.ts
Normal file
97
src/chromium/protocolHelper.ts
Normal file
@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import {helper, assert, debugError} from '../helper';
|
||||
import { CDPSession } from './Connection';
|
||||
import { Protocol } from './protocol';
|
||||
|
||||
const openAsync = helper.promisify(fs.open);
|
||||
const writeAsync = helper.promisify(fs.write);
|
||||
const closeAsync = helper.promisify(fs.close);
|
||||
|
||||
|
||||
export function getExceptionMessage(exceptionDetails: Protocol.Runtime.ExceptionDetails): string {
|
||||
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;
|
||||
}
|
||||
|
||||
export function valueFromRemoteObject(remoteObject: Protocol.Runtime.RemoteObject): any {
|
||||
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;
|
||||
}
|
||||
|
||||
export async function releaseObject(client: CDPSession, remoteObject: Protocol.Runtime.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);
|
||||
});
|
||||
}
|
||||
|
||||
export async function readProtocolStream(client: CDPSession, handle: string, path: string | null): Promise<Buffer> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
273
src/firefox/Accessibility.ts
Normal file
273
src/firefox/Accessibility.ts
Normal file
@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
interface SerializedAXNode {
|
||||
role: string;
|
||||
|
||||
name?: string;
|
||||
value?: string|number;
|
||||
description?: string;
|
||||
|
||||
keyshortcuts?: string;
|
||||
roledescription?: string;
|
||||
valuetext?: string;
|
||||
|
||||
disabled?: boolean;
|
||||
expanded?: boolean;
|
||||
focused?: boolean;
|
||||
modal?: boolean;
|
||||
multiline?: boolean;
|
||||
multiselectable?: boolean;
|
||||
readonly?: boolean;
|
||||
required?: boolean;
|
||||
selected?: boolean;
|
||||
|
||||
checked?: boolean|'mixed';
|
||||
pressed?: boolean|'mixed';
|
||||
|
||||
level?: number;
|
||||
|
||||
autocomplete?: string;
|
||||
haspopup?: string;
|
||||
invalid?: string;
|
||||
orientation?: string;
|
||||
|
||||
children?: Array<SerializedAXNode>;
|
||||
}
|
||||
export class Accessibility {
|
||||
_session: any;
|
||||
constructor(session) {
|
||||
this._session = session;
|
||||
}
|
||||
async snapshot(options: { interestingOnly?: boolean; } | undefined = {}): Promise<SerializedAXNode> {
|
||||
const { interestingOnly = true } = options;
|
||||
const { tree } = await this._session.send('Accessibility.getFullAXTree');
|
||||
const root = new AXNode(tree);
|
||||
if (!interestingOnly)
|
||||
return serializeTree(root)[0];
|
||||
const interestingNodes: Set<AXNode> = new Set();
|
||||
collectInterestingNodes(interestingNodes, root, false);
|
||||
return serializeTree(root, interestingNodes)[0];
|
||||
}
|
||||
}
|
||||
function collectInterestingNodes(collection: Set<AXNode>, node: AXNode, insideControl: boolean) {
|
||||
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);
|
||||
}
|
||||
function serializeTree(node: AXNode, whitelistedNodes?: Set<AXNode>): Array<SerializedAXNode> {
|
||||
const children: Array<SerializedAXNode> = [];
|
||||
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 {
|
||||
_children: AXNode[];
|
||||
private _payload: any;
|
||||
private _editable: boolean;
|
||||
private _richlyEditable: boolean;
|
||||
private _focusable: boolean;
|
||||
private _expanded: boolean;
|
||||
private _name: string;
|
||||
private _role: string;
|
||||
private _cachedHasFocusableChild: boolean|undefined;
|
||||
constructor(payload) {
|
||||
this._payload = payload;
|
||||
this._children = (payload.children || []).map(x => new AXNode(x));
|
||||
this._editable = payload.editable;
|
||||
this._richlyEditable = this._editable && (payload.tag !== 'textarea' && payload.tag !== 'input');
|
||||
this._focusable = payload.focusable;
|
||||
this._expanded = payload.expanded;
|
||||
this._name = this._payload.name;
|
||||
this._role = this._payload.role;
|
||||
this._cachedHasFocusableChild;
|
||||
}
|
||||
_isPlainTextField(): boolean {
|
||||
if (this._richlyEditable)
|
||||
return false;
|
||||
if (this._editable)
|
||||
return true;
|
||||
return this._role === 'entry';
|
||||
}
|
||||
_isTextOnlyObject(): boolean {
|
||||
const role = this._role;
|
||||
return (role === 'text leaf' || role === 'text' || role === 'statictext');
|
||||
}
|
||||
_hasFocusableChild(): boolean {
|
||||
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;
|
||||
}
|
||||
isLeafNode(): boolean {
|
||||
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 'graphic':
|
||||
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;
|
||||
}
|
||||
isControl(): boolean {
|
||||
switch (this._role) {
|
||||
case 'checkbutton':
|
||||
case 'check menu item':
|
||||
case 'check rich option':
|
||||
case 'combobox':
|
||||
case 'combobox option':
|
||||
case 'color chooser':
|
||||
case 'listbox':
|
||||
case 'listbox option':
|
||||
case 'listbox rich option':
|
||||
case 'popup menu':
|
||||
case 'menupopup':
|
||||
case 'menuitem':
|
||||
case 'menubar':
|
||||
case 'button':
|
||||
case 'pushbutton':
|
||||
case 'radiobutton':
|
||||
case 'radio menuitem':
|
||||
case 'scrollbar':
|
||||
case 'slider':
|
||||
case 'spinbutton':
|
||||
case 'switch':
|
||||
case 'pagetab':
|
||||
case 'entry':
|
||||
case 'tree table':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
isInteresting(insideControl: boolean): boolean {
|
||||
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.trim();
|
||||
}
|
||||
serialize(): SerializedAXNode {
|
||||
const node: {[x in keyof SerializedAXNode]: any} = {
|
||||
role: this._role
|
||||
};
|
||||
const userStringProperties: Array<keyof SerializedAXNode> = [
|
||||
'name',
|
||||
'value',
|
||||
'description',
|
||||
'roledescription',
|
||||
'valuetext',
|
||||
'keyshortcuts',
|
||||
];
|
||||
for (const userStringProperty of userStringProperties) {
|
||||
if (!(userStringProperty in this._payload))
|
||||
continue;
|
||||
node[userStringProperty] = this._payload[userStringProperty];
|
||||
}
|
||||
const booleanProperties: Array<keyof SerializedAXNode> = [
|
||||
'disabled',
|
||||
'expanded',
|
||||
'focused',
|
||||
'modal',
|
||||
'multiline',
|
||||
'multiselectable',
|
||||
'readonly',
|
||||
'required',
|
||||
'selected',
|
||||
];
|
||||
for (const booleanProperty of booleanProperties) {
|
||||
if (this._role === 'document' && booleanProperty === 'focused')
|
||||
continue; // document focusing is strange
|
||||
const value = this._payload[booleanProperty];
|
||||
if (!value)
|
||||
continue;
|
||||
node[booleanProperty] = value;
|
||||
}
|
||||
const tristateProperties: Array<keyof SerializedAXNode> = [
|
||||
'checked',
|
||||
'pressed',
|
||||
];
|
||||
for (const tristateProperty of tristateProperties) {
|
||||
if (!(tristateProperty in this._payload))
|
||||
continue;
|
||||
const value = this._payload[tristateProperty];
|
||||
node[tristateProperty] = value;
|
||||
}
|
||||
const numericalProperties: Array<keyof SerializedAXNode> = [
|
||||
'level'
|
||||
];
|
||||
for (const numericalProperty of numericalProperties) {
|
||||
if (!(numericalProperty in this._payload))
|
||||
continue;
|
||||
node[numericalProperty] = this._payload[numericalProperty];
|
||||
}
|
||||
const tokenProperties: Array<keyof SerializedAXNode> = [
|
||||
'autocomplete',
|
||||
'haspopup',
|
||||
'invalid',
|
||||
'orientation',
|
||||
];
|
||||
for (const tokenProperty of tokenProperties) {
|
||||
const value = this._payload[tokenProperty];
|
||||
if (!value || value === 'false')
|
||||
continue;
|
||||
node[tokenProperty] = value;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
}
|
334
src/firefox/Browser.ts
Normal file
334
src/firefox/Browser.ts
Normal file
@ -0,0 +1,334 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {RegisteredListener, helper, assert} from '../helper';
|
||||
import {Page, Viewport} from './Page';
|
||||
import {EventEmitter} from 'events';
|
||||
import {Connection, ConnectionEvents} from './Connection';
|
||||
import {Events} from '../Events';
|
||||
|
||||
export class Browser extends EventEmitter {
|
||||
private _connection: Connection;
|
||||
_defaultViewport: Viewport;
|
||||
private _process: import('child_process').ChildProcess;
|
||||
private _closeCallback: () => void;
|
||||
_targets: Map<string, Target>;
|
||||
private _defaultContext: BrowserContext;
|
||||
private _contexts: Map<string, BrowserContext>;
|
||||
private _eventListeners: RegisteredListener[];
|
||||
|
||||
static async create(connection: Connection, defaultViewport: Viewport | null, process: import('child_process').ChildProcess | null, closeCallback: () => void) {
|
||||
const {browserContextIds} = await connection.send('Target.getBrowserContexts');
|
||||
const browser = new Browser(connection, browserContextIds, defaultViewport, process, closeCallback);
|
||||
await connection.send('Target.enable');
|
||||
return browser;
|
||||
}
|
||||
|
||||
|
||||
constructor(connection: Connection, browserContextIds: Array<string>, defaultViewport: Viewport | null, process: import('child_process').ChildProcess | null, closeCallback: () => void) {
|
||||
super();
|
||||
this._connection = connection;
|
||||
this._defaultViewport = defaultViewport;
|
||||
this._process = process;
|
||||
this._closeCallback = closeCallback;
|
||||
|
||||
this._targets = new Map();
|
||||
|
||||
this._defaultContext = new BrowserContext(this._connection, this, null);
|
||||
this._contexts = new Map();
|
||||
for (const browserContextId of browserContextIds)
|
||||
this._contexts.set(browserContextId, new BrowserContext(this._connection, this, browserContextId));
|
||||
|
||||
this._connection.on(ConnectionEvents.Disconnected, () => this.emit(Events.Browser.Disconnected));
|
||||
|
||||
this._eventListeners = [
|
||||
helper.addEventListener(this._connection, 'Target.targetCreated', this._onTargetCreated.bind(this)),
|
||||
helper.addEventListener(this._connection, 'Target.targetDestroyed', this._onTargetDestroyed.bind(this)),
|
||||
helper.addEventListener(this._connection, 'Target.targetInfoChanged', this._onTargetInfoChanged.bind(this)),
|
||||
];
|
||||
}
|
||||
|
||||
wsEndpoint() {
|
||||
return this._connection.url();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._connection.dispose();
|
||||
}
|
||||
|
||||
|
||||
isConnected(): boolean {
|
||||
return !this._connection._closed;
|
||||
}
|
||||
|
||||
async createIncognitoBrowserContext(): Promise<BrowserContext> {
|
||||
const {browserContextId} = await this._connection.send('Target.createBrowserContext');
|
||||
const context = new BrowserContext(this._connection, this, browserContextId);
|
||||
this._contexts.set(browserContextId, context);
|
||||
return context;
|
||||
}
|
||||
|
||||
|
||||
browserContexts(): Array<BrowserContext> {
|
||||
return [this._defaultContext, ...Array.from(this._contexts.values())];
|
||||
}
|
||||
|
||||
defaultBrowserContext() {
|
||||
return this._defaultContext;
|
||||
}
|
||||
|
||||
async _disposeContext(browserContextId) {
|
||||
await this._connection.send('Target.removeBrowserContext', {browserContextId});
|
||||
this._contexts.delete(browserContextId);
|
||||
}
|
||||
|
||||
|
||||
async userAgent(): Promise<string> {
|
||||
const info = await this._connection.send('Browser.getInfo');
|
||||
return info.userAgent;
|
||||
}
|
||||
|
||||
|
||||
async version(): Promise<string> {
|
||||
const info = await this._connection.send('Browser.getInfo');
|
||||
return info.version;
|
||||
}
|
||||
|
||||
|
||||
process(): import('child_process').ChildProcess | null {
|
||||
return this._process;
|
||||
}
|
||||
|
||||
|
||||
async waitForTarget(predicate: (target: Target) => boolean, options: { timeout?: number; } = {}): Promise<Target> {
|
||||
const {
|
||||
timeout = 30000
|
||||
} = options;
|
||||
const existingTarget = this.targets().find(predicate);
|
||||
if (existingTarget)
|
||||
return existingTarget;
|
||||
let resolve;
|
||||
const targetPromise = new Promise<Target>(x => resolve = x);
|
||||
this.on(Events.Browser.TargetCreated, check);
|
||||
this.on('targetchanged', check);
|
||||
try {
|
||||
if (!timeout)
|
||||
return await targetPromise;
|
||||
return await helper.waitWithTimeout(targetPromise, 'target', timeout);
|
||||
} finally {
|
||||
this.removeListener(Events.Browser.TargetCreated, check);
|
||||
this.removeListener('targetchanged', check);
|
||||
}
|
||||
|
||||
function check(target: Target) {
|
||||
if (predicate(target))
|
||||
resolve(target);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
newPage(): Promise<Page> {
|
||||
return this._createPageInContext(this._defaultContext._browserContextId);
|
||||
}
|
||||
|
||||
|
||||
async _createPageInContext(browserContextId: string | null): Promise<Page> {
|
||||
const {targetId} = await this._connection.send('Target.newPage', {
|
||||
browserContextId: browserContextId || undefined
|
||||
});
|
||||
const target = this._targets.get(targetId);
|
||||
return await target.page();
|
||||
}
|
||||
|
||||
async pages() {
|
||||
const pageTargets = Array.from(this._targets.values()).filter(target => target.type() === 'page');
|
||||
return await Promise.all(pageTargets.map(target => target.page()));
|
||||
}
|
||||
|
||||
targets() {
|
||||
return Array.from(this._targets.values());
|
||||
}
|
||||
|
||||
target() {
|
||||
return this.targets().find(target => target.type() === 'browser');
|
||||
}
|
||||
|
||||
async _onTargetCreated({targetId, url, browserContextId, openerId, type}) {
|
||||
const context = browserContextId ? this._contexts.get(browserContextId) : this._defaultContext;
|
||||
const target = new Target(this._connection, this, context, targetId, type, url, openerId);
|
||||
this._targets.set(targetId, target);
|
||||
if (target.opener() && target.opener()._pagePromise) {
|
||||
const openerPage = await target.opener()._pagePromise;
|
||||
if (openerPage.listenerCount(Events.Page.Popup)) {
|
||||
const popupPage = await target.page();
|
||||
openerPage.emit(Events.Page.Popup, popupPage);
|
||||
}
|
||||
}
|
||||
this.emit(Events.Browser.TargetCreated, target);
|
||||
context.emit(Events.BrowserContext.TargetCreated, target);
|
||||
}
|
||||
|
||||
_onTargetDestroyed({targetId}) {
|
||||
const target = this._targets.get(targetId);
|
||||
this._targets.delete(targetId);
|
||||
target._closedCallback();
|
||||
this.emit(Events.Browser.TargetDestroyed, target);
|
||||
target.browserContext().emit(Events.BrowserContext.TargetDestroyed, target);
|
||||
}
|
||||
|
||||
_onTargetInfoChanged({targetId, url}) {
|
||||
const target = this._targets.get(targetId);
|
||||
target._url = url;
|
||||
this.emit(Events.Browser.TargetChanged, target);
|
||||
target.browserContext().emit(Events.BrowserContext.TargetChanged, target);
|
||||
}
|
||||
|
||||
async close() {
|
||||
helper.removeEventListeners(this._eventListeners);
|
||||
await this._closeCallback();
|
||||
}
|
||||
}
|
||||
|
||||
export class Target {
|
||||
_pagePromise?: Promise<Page>;
|
||||
private _browser: Browser;
|
||||
_context: BrowserContext;
|
||||
private _connection: any;
|
||||
private _targetId: string;
|
||||
private _type: 'page' | 'browser';
|
||||
_url: string;
|
||||
private _openerId: string;
|
||||
_isClosedPromise: Promise<unknown>;
|
||||
_closedCallback: (value?: unknown) => void;
|
||||
|
||||
constructor(connection: any, browser: Browser, context: BrowserContext, targetId: string, type: 'page' | 'browser', url: string, openerId: string | undefined) {
|
||||
this._browser = browser;
|
||||
this._context = context;
|
||||
this._connection = connection;
|
||||
this._targetId = targetId;
|
||||
this._type = type;
|
||||
this._url = url;
|
||||
this._openerId = openerId;
|
||||
this._isClosedPromise = new Promise(fulfill => this._closedCallback = fulfill);
|
||||
}
|
||||
|
||||
|
||||
opener(): Target | null {
|
||||
return this._openerId ? this._browser._targets.get(this._openerId) : null;
|
||||
}
|
||||
|
||||
|
||||
type(): 'page' | 'browser' {
|
||||
return this._type;
|
||||
}
|
||||
|
||||
url() {
|
||||
return this._url;
|
||||
}
|
||||
|
||||
|
||||
browserContext(): BrowserContext {
|
||||
return this._context;
|
||||
}
|
||||
|
||||
async page() {
|
||||
if (this._type === 'page' && !this._pagePromise) {
|
||||
const session = await this._connection.createSession(this._targetId);
|
||||
this._pagePromise = Page.create(session, this, this._browser._defaultViewport);
|
||||
}
|
||||
return this._pagePromise;
|
||||
}
|
||||
|
||||
browser() {
|
||||
return this._browser;
|
||||
}
|
||||
}
|
||||
|
||||
export class BrowserContext extends EventEmitter {
|
||||
_connection: Connection;
|
||||
_browser: Browser;
|
||||
_browserContextId: string;
|
||||
|
||||
constructor(connection: Connection, browser: Browser, browserContextId: string | null) {
|
||||
super();
|
||||
this._connection = connection;
|
||||
this._browser = browser;
|
||||
this._browserContextId = browserContextId;
|
||||
}
|
||||
|
||||
|
||||
async overridePermissions(origin: string, permissions: Array<string>) {
|
||||
const webPermissionToProtocol = new Map([
|
||||
['geolocation', 'geo'],
|
||||
['microphone', 'microphone'],
|
||||
['camera', 'camera'],
|
||||
['notifications', 'desktop-notifications'],
|
||||
]);
|
||||
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._browserContextId || undefined, permissions});
|
||||
}
|
||||
|
||||
async clearPermissionOverrides() {
|
||||
await this._connection.send('Browser.resetPermissions', {browserContextId: this._browserContextId || undefined});
|
||||
}
|
||||
|
||||
|
||||
targets(): Array<Target> {
|
||||
return this._browser.targets().filter(target => target.browserContext() === this);
|
||||
}
|
||||
|
||||
|
||||
async pages(): Promise<Array<Page>> {
|
||||
const pages = await Promise.all(
|
||||
this.targets()
|
||||
.filter(target => target.type() === 'page')
|
||||
.map(target => target.page())
|
||||
);
|
||||
return pages.filter(page => !!page);
|
||||
}
|
||||
|
||||
|
||||
waitForTarget(predicate: (arg0: Target) => boolean, options: { timeout?: number; } | undefined): Promise<Target> {
|
||||
return this._browser.waitForTarget(target => target.browserContext() === this && predicate(target), options);
|
||||
}
|
||||
|
||||
|
||||
isIncognito(): boolean {
|
||||
return !!this._browserContextId;
|
||||
}
|
||||
|
||||
newPage() {
|
||||
return this._browser._createPageInContext(this._browserContextId);
|
||||
}
|
||||
|
||||
|
||||
browser(): Browser {
|
||||
return this._browser;
|
||||
}
|
||||
|
||||
async close() {
|
||||
assert(this._browserContextId, 'Non-incognito contexts cannot be closed!');
|
||||
await this._browser._disposeContext(this._browserContextId);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {Browser, BrowserContext, Target};
|
283
src/firefox/BrowserFetcher.ts
Normal file
283
src/firefox/BrowserFetcher.ts
Normal file
@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as extract from 'extract-zip';
|
||||
import * as util from 'util';
|
||||
import * as URL from 'url';
|
||||
import {helper, assert} from '../helper';
|
||||
import * as removeRecursive from 'rimraf';
|
||||
// @ts-ignore
|
||||
import * as ProxyAgent from 'https-proxy-agent';
|
||||
// @ts-ignore
|
||||
import {getProxyForUrl} from 'proxy-from-env';
|
||||
const DEFAULT_DOWNLOAD_HOST = 'https://playwrightaccount.blob.core.windows.net/builds';
|
||||
|
||||
const downloadURLs = {
|
||||
chromium: {
|
||||
linux: '%s/chromium-browser-snapshots/Linux_x64/%s/%s.zip',
|
||||
mac: '%s/chromium-browser-snapshots/Mac/%s/%s.zip',
|
||||
win32: '%s/chromium-browser-snapshots/Win/%s/%s.zip',
|
||||
win64: '%s/chromium-browser-snapshots/Win_x64/%s/%s.zip',
|
||||
},
|
||||
firefox: {
|
||||
linux: '%s/firefox/%s/%s.zip',
|
||||
mac: '%s/firefox/%s/%s.zip',
|
||||
win32: '%s/firefox/%s/%s.zip',
|
||||
win64: '%s/firefox/%s/%s.zip',
|
||||
},
|
||||
};
|
||||
|
||||
function archiveName(product: string, platform: string, revision: string): string {
|
||||
if (product === 'chromium') {
|
||||
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') {
|
||||
if (platform === 'linux')
|
||||
return 'firefox-linux';
|
||||
if (platform === 'mac')
|
||||
return 'firefox-mac';
|
||||
if (platform === 'win32' || platform === 'win64')
|
||||
return 'firefox-' + platform;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function downloadURL(product: string, platform: string, host: string, revision: string): string {
|
||||
return util.format(downloadURLs[product][platform], host, revision, archiveName(product, platform, revision));
|
||||
}
|
||||
|
||||
const readdirAsync = helper.promisify(fs.readdir.bind(fs));
|
||||
const mkdirAsync = helper.promisify(fs.mkdir.bind(fs));
|
||||
const unlinkAsync = helper.promisify(fs.unlink.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;
|
||||
}
|
||||
|
||||
export class BrowserFetcher {
|
||||
_product: string;
|
||||
_downloadsFolder: string;
|
||||
_downloadHost: string;
|
||||
_platform: string;
|
||||
constructor(projectRoot: string, options: BrowserFetcherOptions | undefined = {}) {
|
||||
this._product = (options.browser || 'chromium').toLowerCase();
|
||||
assert(this._product === 'chromium' || this._product === 'firefox', `Unkown product: "${options.browser}"`);
|
||||
this._downloadsFolder = options.path || path.join(projectRoot, '.local-browser');
|
||||
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());
|
||||
}
|
||||
assert(downloadURLs[this._product][this._platform], 'Unsupported platform: ' + this._platform);
|
||||
}
|
||||
|
||||
platform(): string {
|
||||
return this._platform;
|
||||
}
|
||||
|
||||
canDownload(revision: string): Promise<boolean> {
|
||||
const url = downloadURL(this._product, this._platform, this._downloadHost, revision);
|
||||
let resolve;
|
||||
const promise = new Promise<boolean>(x => resolve = x);
|
||||
const request = httpRequest(url, 'HEAD', response => {
|
||||
resolve(response.statusCode === 200);
|
||||
});
|
||||
request.on('error', error => {
|
||||
console.error(error);
|
||||
resolve(false);
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
|
||||
async download(revision: string, progressCallback: ((arg0: number, arg1: number) => void) | null): Promise<RevisionInfo> {
|
||||
const url = downloadURL(this._product, this._platform, this._downloadHost, revision);
|
||||
const zipPath = path.join(this._downloadsFolder, `download-${this._product}-${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);
|
||||
}
|
||||
const revisionInfo = this.revisionInfo(revision);
|
||||
if (revisionInfo)
|
||||
await chmodAsync(revisionInfo.executablePath, 0o755);
|
||||
return revisionInfo;
|
||||
}
|
||||
|
||||
async localRevisions(): Promise<Array<string>> {
|
||||
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);
|
||||
}
|
||||
|
||||
async remove(revision: string) {
|
||||
const folderPath = this._getFolderPath(revision);
|
||||
assert(await existsAsync(folderPath), `Failed to remove: revision ${revision} is not downloaded`);
|
||||
await new Promise(fulfill => removeRecursive(folderPath, fulfill));
|
||||
}
|
||||
|
||||
revisionInfo(revision: string): RevisionInfo {
|
||||
const folderPath = this._getFolderPath(revision);
|
||||
let executablePath = '';
|
||||
if (this._product === 'chromium') {
|
||||
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);
|
||||
}
|
||||
const url = downloadURL(this._product, this._platform, this._downloadHost, revision);
|
||||
const local = fs.existsSync(folderPath);
|
||||
return {revision, executablePath, folderPath, local, url};
|
||||
}
|
||||
|
||||
_getFolderPath(revision: string): string {
|
||||
return path.join(this._downloadsFolder, this._product + '-' + this._platform + '-' + revision);
|
||||
}
|
||||
}
|
||||
|
||||
function parseFolderPath(folderPath: string): { platform: string; revision: string; } | null {
|
||||
const name = path.basename(folderPath);
|
||||
const splits = name.split('-');
|
||||
if (splits.length !== 3)
|
||||
return null;
|
||||
const [product, platform, revision] = splits;
|
||||
if (!downloadURLs[product][platform])
|
||||
return null;
|
||||
return {platform, revision};
|
||||
}
|
||||
|
||||
function downloadFile(url: string, destinationPath: string, progressCallback: ((arg0: number, arg1: number) => void) | null): Promise<any> {
|
||||
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(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);
|
||||
}
|
||||
}
|
||||
|
||||
function extractZip(zipPath: string, folderPath: string): Promise<Error | null> {
|
||||
return new Promise((fulfill, reject) => extract(zipPath, {dir: folderPath}, err => {
|
||||
if (err)
|
||||
reject(err);
|
||||
else
|
||||
fulfill();
|
||||
}));
|
||||
}
|
||||
|
||||
function httpRequest(url: string, method: string, onResponse: (response: any) => void) {
|
||||
const options: any = URL.parse(url);
|
||||
options.method = method;
|
||||
|
||||
const proxyURL = getProxyForUrl(url);
|
||||
if (proxyURL) {
|
||||
const parsedProxyURL: any = 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, onResponse);
|
||||
else
|
||||
onResponse(res);
|
||||
};
|
||||
const request = options.protocol === 'https:' ?
|
||||
require('https').request(options, requestCallback) :
|
||||
require('http').request(options, requestCallback);
|
||||
request.end();
|
||||
return request;
|
||||
}
|
||||
|
||||
interface BrowserFetcherOptions {
|
||||
browser?: string;
|
||||
platform?: string;
|
||||
path?: string;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
interface RevisionInfo {
|
||||
folderPath: string;
|
||||
executablePath: string;
|
||||
url: string;
|
||||
local: boolean;
|
||||
revision: string;
|
||||
}
|
203
src/firefox/Connection.ts
Normal file
203
src/firefox/Connection.ts
Normal file
@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {assert} from '../helper';
|
||||
import {EventEmitter} from 'events';
|
||||
import * as debug from 'debug';
|
||||
import { ConnectionTransport } from '../ConnectionTransport';
|
||||
const debugProtocol = debug('playwright:protocol');
|
||||
|
||||
export const ConnectionEvents = {
|
||||
Disconnected: Symbol('Disconnected'),
|
||||
};
|
||||
|
||||
export class Connection extends EventEmitter {
|
||||
private _url: string;
|
||||
private _lastId: number;
|
||||
private _callbacks: Map<number, {resolve: Function, reject: Function, error: Error, method: string}>;
|
||||
private _delay: number;
|
||||
private _transport: ConnectionTransport;
|
||||
private _sessions: Map<string, JugglerSession>;
|
||||
_closed: boolean;
|
||||
constructor(url: string, transport: ConnectionTransport, delay: number | undefined = 0) {
|
||||
super();
|
||||
this._url = url;
|
||||
this._lastId = 0;
|
||||
this._callbacks = new Map();
|
||||
this._delay = delay;
|
||||
|
||||
this._transport = transport;
|
||||
this._transport.onmessage = this._onMessage.bind(this);
|
||||
this._transport.onclose = this._onClose.bind(this);
|
||||
this._sessions = new Map();
|
||||
this._closed = false;
|
||||
}
|
||||
|
||||
static fromSession(session: JugglerSession): Connection {
|
||||
return session._connection;
|
||||
}
|
||||
|
||||
session(sessionId: string): JugglerSession | null {
|
||||
return this._sessions.get(sessionId) || null;
|
||||
}
|
||||
|
||||
url(): string {
|
||||
return this._url;
|
||||
}
|
||||
|
||||
send(method: string, params: object | undefined = {}): Promise<any> {
|
||||
const id = this._rawSend({method, params});
|
||||
return new Promise((resolve, reject) => {
|
||||
this._callbacks.set(id, {resolve, reject, error: new Error(), method});
|
||||
});
|
||||
}
|
||||
|
||||
_rawSend(message: any): number {
|
||||
const id = ++this._lastId;
|
||||
message = JSON.stringify(Object.assign({}, message, {id}));
|
||||
debugProtocol('SEND ► ' + message);
|
||||
this._transport.send(message);
|
||||
return id;
|
||||
}
|
||||
|
||||
async _onMessage(message: string) {
|
||||
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 JugglerSession(this, object.params.targetInfo.type, sessionId);
|
||||
this._sessions.set(sessionId, session);
|
||||
} else if (object.method === 'Browser.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(ConnectionEvents.Disconnected);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._onClose();
|
||||
this._transport.close();
|
||||
}
|
||||
|
||||
async createSession(targetId: string): Promise<JugglerSession> {
|
||||
const {sessionId} = await this.send('Target.attachToTarget', {targetId});
|
||||
return this._sessions.get(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
export const JugglerSessionEvents = {
|
||||
Disconnected: Symbol('Disconnected')
|
||||
};
|
||||
|
||||
export class JugglerSession extends EventEmitter {
|
||||
_connection: Connection;
|
||||
private _callbacks: Map<number, {resolve: Function, reject: Function, error: Error, method: string}>;
|
||||
private _targetType: string;
|
||||
private _sessionId: string;
|
||||
constructor(connection: Connection, targetType: string, sessionId: string) {
|
||||
super();
|
||||
this._callbacks = new Map();
|
||||
this._connection = connection;
|
||||
this._targetType = targetType;
|
||||
this._sessionId = sessionId;
|
||||
}
|
||||
|
||||
send(method: string, params: any = {}): Promise<any> {
|
||||
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});
|
||||
});
|
||||
}
|
||||
|
||||
_onMessage(object: { id?: number; method: string; params: object; error: { message: string; data: any; }; result?: any; }) {
|
||||
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(JugglerSessionEvents.Disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
function createProtocolError(error: Error, method: string, object: { error: { message: string; data: any; }; }): Error {
|
||||
let message = `Protocol error (${method}): ${object.error.message}`;
|
||||
if ('data' in object.error)
|
||||
message += ` ${object.error.data}`;
|
||||
return rewriteError(error, message);
|
||||
}
|
||||
|
||||
function rewriteError(error: Error, message: string): Error {
|
||||
error.message = message;
|
||||
return error;
|
||||
}
|
503
src/firefox/DOMWorld.ts
Normal file
503
src/firefox/DOMWorld.ts
Normal file
@ -0,0 +1,503 @@
|
||||
import {helper, assert} from '../helper';
|
||||
import {TimeoutError} from '../Errors';
|
||||
import * as fs from 'fs';
|
||||
import * as util from 'util';
|
||||
import {ElementHandle, JSHandle} from './JSHandle';
|
||||
|
||||
const readFileAsync = util.promisify(fs.readFile);
|
||||
|
||||
export class DOMWorld {
|
||||
_frame: any;
|
||||
_timeoutSettings: any;
|
||||
_documentPromise: any;
|
||||
_contextPromise: any;
|
||||
_contextResolveCallback: any;
|
||||
_waitTasks: Set<WaitTask>;
|
||||
_detached: boolean;
|
||||
constructor(frame, timeoutSettings) {
|
||||
this._frame = frame;
|
||||
this._timeoutSettings = timeoutSettings;
|
||||
|
||||
this._documentPromise = null;
|
||||
this._contextPromise;
|
||||
this._contextResolveCallback = null;
|
||||
this._setContext(null);
|
||||
|
||||
this._waitTasks = new Set();
|
||||
this._detached = false;
|
||||
}
|
||||
|
||||
frame() {
|
||||
return this._frame;
|
||||
}
|
||||
|
||||
_setContext(context) {
|
||||
if (context) {
|
||||
this._contextResolveCallback.call(null, context);
|
||||
this._contextResolveCallback = null;
|
||||
for (const waitTask of this._waitTasks)
|
||||
waitTask.rerun();
|
||||
} else {
|
||||
this._documentPromise = null;
|
||||
this._contextPromise = new Promise(fulfill => {
|
||||
this._contextResolveCallback = fulfill;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_detach() {
|
||||
this._detached = true;
|
||||
for (const waitTask of this._waitTasks)
|
||||
waitTask.terminate(new Error('waitForFunction failed: frame got detached.'));
|
||||
}
|
||||
|
||||
async executionContext() {
|
||||
if (this._detached)
|
||||
throw new Error(`Execution Context is not available in detached frame "${this.url()}" (are you trying to evaluate?)`);
|
||||
return this._contextPromise;
|
||||
}
|
||||
url() {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
async evaluateHandle(pageFunction, ...args) {
|
||||
const context = await this.executionContext();
|
||||
return context.evaluateHandle(pageFunction, ...args);
|
||||
}
|
||||
|
||||
async evaluate(pageFunction, ...args) {
|
||||
const context = await this.executionContext();
|
||||
return context.evaluate(pageFunction, ...args);
|
||||
}
|
||||
|
||||
async $(selector: string): Promise<ElementHandle | null> {
|
||||
const document = await this._document();
|
||||
return document.$(selector);
|
||||
}
|
||||
|
||||
_document() {
|
||||
if (!this._documentPromise)
|
||||
this._documentPromise = this.evaluateHandle('document').then(handle => handle.asElement());
|
||||
return this._documentPromise;
|
||||
}
|
||||
|
||||
async $x(expression: string): Promise<Array<ElementHandle>> {
|
||||
const document = await this._document();
|
||||
return document.$x(expression);
|
||||
}
|
||||
|
||||
async $eval(selector: string, pageFunction: Function | string, ...args: Array<any>): Promise<(object | undefined)> {
|
||||
const document = await this._document();
|
||||
return document.$eval(selector, pageFunction, ...args);
|
||||
}
|
||||
|
||||
async $$eval(selector: string, pageFunction: Function | string, ...args: Array<any>): Promise<(object | undefined)> {
|
||||
const document = await this._document();
|
||||
return document.$$eval(selector, pageFunction, ...args);
|
||||
}
|
||||
|
||||
async $$(selector: string): Promise<Array<ElementHandle>> {
|
||||
const document = await this._document();
|
||||
return document.$$(selector);
|
||||
}
|
||||
|
||||
async content(): Promise<string> {
|
||||
return await this.evaluate(() => {
|
||||
let retVal = '';
|
||||
if (document.doctype)
|
||||
retVal = new XMLSerializer().serializeToString(document.doctype);
|
||||
if (document.documentElement)
|
||||
retVal += document.documentElement.outerHTML;
|
||||
return retVal;
|
||||
});
|
||||
}
|
||||
|
||||
async setContent(html: string) {
|
||||
await this.evaluate(html => {
|
||||
document.open();
|
||||
document.write(html);
|
||||
document.close();
|
||||
}, html);
|
||||
}
|
||||
|
||||
async addScriptTag(options: { content?: string; path?: string; type?: string; url?: string; }): Promise<ElementHandle> {
|
||||
if (typeof options.url === 'string') {
|
||||
const url = options.url;
|
||||
try {
|
||||
return (await this.evaluateHandle(addScriptUrl, url, options.type)).asElement();
|
||||
} catch (error) {
|
||||
throw new Error(`Loading script from ${url} failed`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof options.path === 'string') {
|
||||
let contents = await readFileAsync(options.path, 'utf8');
|
||||
contents += '//# sourceURL=' + options.path.replace(/\n/g, '');
|
||||
return (await this.evaluateHandle(addScriptContent, contents, options.type)).asElement();
|
||||
}
|
||||
|
||||
if (typeof options.content === 'string')
|
||||
return (await this.evaluateHandle(addScriptContent, options.content, options.type)).asElement();
|
||||
|
||||
|
||||
throw new Error('Provide an object with a `url`, `path` or `content` property');
|
||||
|
||||
async function addScriptUrl(url: string, type: string): Promise<HTMLElement> {
|
||||
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;
|
||||
}
|
||||
|
||||
function addScriptContent(content: string, type: string = 'text/javascript'): HTMLElement {
|
||||
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 addStyleTag(options: { content?: string; path?: string; url?: string; }): Promise<ElementHandle> {
|
||||
if (typeof options.url === 'string') {
|
||||
const url = options.url;
|
||||
try {
|
||||
return (await this.evaluateHandle(addStyleUrl, url)).asElement();
|
||||
} catch (error) {
|
||||
throw new Error(`Loading style from ${url} failed`);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof options.path === 'string') {
|
||||
let contents = await readFileAsync(options.path, 'utf8');
|
||||
contents += '/*# sourceURL=' + options.path.replace(/\n/g, '') + '*/';
|
||||
return (await this.evaluateHandle(addStyleContent, contents)).asElement();
|
||||
}
|
||||
|
||||
if (typeof options.content === 'string')
|
||||
return (await this.evaluateHandle(addStyleContent, options.content)).asElement();
|
||||
|
||||
|
||||
throw new Error('Provide an object with a `url`, `path` or `content` property');
|
||||
|
||||
async function addStyleUrl(url: string): Promise<HTMLElement> {
|
||||
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 function addStyleContent(content: string): Promise<HTMLElement> {
|
||||
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 click(selector: string, options: { delay?: number; button?: string; clickCount?: number; } | undefined = {}) {
|
||||
const handle = await this.$(selector);
|
||||
assert(handle, 'No node found for selector: ' + selector);
|
||||
await handle.click(options);
|
||||
await handle.dispose();
|
||||
}
|
||||
|
||||
async focus(selector: string) {
|
||||
const handle = await this.$(selector);
|
||||
assert(handle, 'No node found for selector: ' + selector);
|
||||
await handle.focus();
|
||||
await handle.dispose();
|
||||
}
|
||||
|
||||
async hover(selector: string) {
|
||||
const handle = await this.$(selector);
|
||||
assert(handle, 'No node found for selector: ' + selector);
|
||||
await handle.hover();
|
||||
await handle.dispose();
|
||||
}
|
||||
|
||||
select(selector: string, ...values: Array<string>): Promise<Array<string>> {
|
||||
for (const value of values)
|
||||
assert(helper.isString(value), 'Values must be strings. Found value "' + value + '" of type "' + (typeof value) + '"');
|
||||
return this.$eval(selector, (element : HTMLSelectElement, values : string[]) => {
|
||||
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) as Promise<string[]>;
|
||||
}
|
||||
|
||||
async tap(selector: string) {
|
||||
const handle = await this.$(selector);
|
||||
assert(handle, 'No node found for selector: ' + selector);
|
||||
await handle.tap();
|
||||
await handle.dispose();
|
||||
}
|
||||
|
||||
async type(selector: string, text: string, options: { delay: (number | undefined); } | undefined) {
|
||||
const handle = await this.$(selector);
|
||||
assert(handle, 'No node found for selector: ' + selector);
|
||||
await handle.type(text, options);
|
||||
await handle.dispose();
|
||||
}
|
||||
|
||||
waitForSelector(selector: string, options: { timeout?: number; visible?: boolean; hidden?: boolean; } | undefined): Promise<ElementHandle> {
|
||||
return this._waitForSelectorOrXPath(selector, false, options);
|
||||
}
|
||||
|
||||
waitForXPath(xpath: string, options: { timeout?: number; visible?: boolean; hidden?: boolean; } | undefined): Promise<ElementHandle> {
|
||||
return this._waitForSelectorOrXPath(xpath, true, options);
|
||||
}
|
||||
|
||||
waitForFunction(pageFunction: Function | string, options: { polling?: string | number; timeout?: number; } | undefined = {}, ...args): Promise<JSHandle> {
|
||||
const {
|
||||
polling = 'raf',
|
||||
timeout = this._timeoutSettings.timeout(),
|
||||
} = options;
|
||||
return new WaitTask(this, pageFunction, 'function', polling, timeout, ...args).promise;
|
||||
}
|
||||
|
||||
async title(): Promise<string> {
|
||||
return this.evaluate(() => document.title);
|
||||
}
|
||||
|
||||
async _waitForSelectorOrXPath(selectorOrXPath: string, isXPath: boolean, options: { timeout?: number; visible?: boolean; hidden?: boolean; } | undefined = {}): Promise<ElementHandle> {
|
||||
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();
|
||||
|
||||
function predicate(selectorOrXPath: string, isXPath: boolean, waitForVisible: boolean, waitForHidden: boolean): (Node | boolean) | null {
|
||||
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: Element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node as Element;
|
||||
|
||||
const style = window.getComputedStyle(element);
|
||||
const isVisible = style && style.visibility !== 'hidden' && hasVisibleBoundingBox();
|
||||
const success = (waitForVisible === isVisible || waitForHidden === !isVisible);
|
||||
return success ? node : null;
|
||||
|
||||
function hasVisibleBoundingBox(): boolean {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return !!(rect.top || rect.bottom || rect.width || rect.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class WaitTask {
|
||||
promise: any;
|
||||
_domWorld: any;
|
||||
_polling: any;
|
||||
_timeout: any;
|
||||
_predicateBody: string;
|
||||
_args: any[];
|
||||
_runCount: number;
|
||||
_resolve: (value?: unknown) => void;
|
||||
_reject: (reason?: any) => void;
|
||||
_timeoutTimer: NodeJS.Timer;
|
||||
_terminated: boolean;
|
||||
_runningTask: any;
|
||||
constructor(domWorld: DOMWorld, predicateBody: Function | string, title, polling: string | number, timeout: number, ...args: Array<any>) {
|
||||
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();
|
||||
}
|
||||
|
||||
terminate(error: Error) {
|
||||
this._terminated = true;
|
||||
this._reject(error);
|
||||
this._cleanup();
|
||||
}
|
||||
|
||||
async rerun() {
|
||||
const runCount = ++this._runCount;
|
||||
let success: JSHandle | null = null;
|
||||
let error = null;
|
||||
try {
|
||||
success = await this._domWorld.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.
|
||||
// Try again right away.
|
||||
if (error && error.message.includes('Execution context was destroyed')) {
|
||||
this.rerun();
|
||||
return;
|
||||
}
|
||||
|
||||
if (error)
|
||||
this._reject(error);
|
||||
else
|
||||
this._resolve(success);
|
||||
|
||||
this._cleanup();
|
||||
}
|
||||
|
||||
_cleanup() {
|
||||
clearTimeout(this._timeoutTimer);
|
||||
this._domWorld._waitTasks.delete(this);
|
||||
this._runningTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForPredicatePageFunction(predicateBody: string, polling: string, timeout: number, ...args): Promise<any> {
|
||||
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);
|
||||
|
||||
function pollMutation(): Promise<any> {
|
||||
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;
|
||||
}
|
||||
|
||||
function pollRaf(): Promise<any> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
function pollInterval(pollInterval: number): Promise<any> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
51
src/firefox/Dialog.ts
Normal file
51
src/firefox/Dialog.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import {assert, debugError} from '../helper';
|
||||
|
||||
export class Dialog {
|
||||
private _client: any;
|
||||
private _dialogId: any;
|
||||
private _type: string;
|
||||
private _message: string;
|
||||
private _handled: boolean;
|
||||
private _defaultValue: string;
|
||||
|
||||
constructor(client, payload) {
|
||||
this._client = client;
|
||||
this._dialogId = payload.dialogId;
|
||||
this._type = payload.type;
|
||||
this._message = payload.message;
|
||||
this._handled = false;
|
||||
this._defaultValue = payload.defaultValue || '';
|
||||
}
|
||||
|
||||
type(): string {
|
||||
return this._type;
|
||||
}
|
||||
|
||||
message(): string {
|
||||
return this._message;
|
||||
}
|
||||
|
||||
defaultValue(): string {
|
||||
return this._defaultValue;
|
||||
}
|
||||
|
||||
async accept(promptText: string | undefined) {
|
||||
assert(!this._handled, 'Cannot accept dialog which is already handled!');
|
||||
this._handled = true;
|
||||
await this._client.send('Page.handleDialog', {
|
||||
dialogId: this._dialogId,
|
||||
accept: true,
|
||||
promptText: promptText
|
||||
}).catch(debugError);
|
||||
}
|
||||
|
||||
async dismiss() {
|
||||
assert(!this._handled, 'Cannot dismiss dialog which is already handled!');
|
||||
this._handled = true;
|
||||
await this._client.send('Page.handleDialog', {
|
||||
dialogId: this._dialogId,
|
||||
accept: false
|
||||
}).catch(debugError);
|
||||
}
|
||||
}
|
||||
|
100
src/firefox/ExecutionContext.ts
Normal file
100
src/firefox/ExecutionContext.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import {helper} from '../helper';
|
||||
import {JSHandle, createHandle} from './JSHandle';
|
||||
import { Frame } from './FrameManager';
|
||||
|
||||
export class ExecutionContext {
|
||||
_session: any;
|
||||
_frame: Frame;
|
||||
_executionContextId: string;
|
||||
constructor(session: any, frame: Frame | null, executionContextId: string) {
|
||||
this._session = session;
|
||||
this._frame = frame;
|
||||
this._executionContextId = executionContextId;
|
||||
}
|
||||
|
||||
async evaluateHandle(pageFunction, ...args) {
|
||||
if (helper.isString(pageFunction)) {
|
||||
const payload = await this._session.send('Runtime.evaluate', {
|
||||
expression: pageFunction,
|
||||
executionContextId: this._executionContextId,
|
||||
}).catch(rewriteError);
|
||||
return createHandle(this, payload.result, payload.exceptionDetails);
|
||||
}
|
||||
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!');
|
||||
}
|
||||
}
|
||||
args = args.map(arg => {
|
||||
if (arg instanceof JSHandle) {
|
||||
if (arg._context !== this)
|
||||
throw new Error('JSHandles can be evaluated only in the context they were created!');
|
||||
if (arg._disposed)
|
||||
throw new Error('JSHandle is disposed!');
|
||||
return arg._protocolValue;
|
||||
}
|
||||
if (Object.is(arg, Infinity))
|
||||
return {unserializableValue: 'Infinity'};
|
||||
if (Object.is(arg, -Infinity))
|
||||
return {unserializableValue: '-Infinity'};
|
||||
if (Object.is(arg, -0))
|
||||
return {unserializableValue: '-0'};
|
||||
if (Object.is(arg, NaN))
|
||||
return {unserializableValue: 'NaN'};
|
||||
return {value: arg};
|
||||
});
|
||||
let callFunctionPromise;
|
||||
try {
|
||||
callFunctionPromise = this._session.send('Runtime.callFunction', {
|
||||
functionDeclaration: functionText,
|
||||
args,
|
||||
executionContextId: this._executionContextId
|
||||
});
|
||||
} 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 payload = await callFunctionPromise.catch(rewriteError);
|
||||
return createHandle(this, payload.result, payload.exceptionDetails);
|
||||
|
||||
function rewriteError(error) {
|
||||
if (error.message.includes('Failed to find execution context with id'))
|
||||
throw new Error('Execution context was destroyed, most likely because of a navigation.');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
frame() {
|
||||
return this._frame;
|
||||
}
|
||||
|
||||
async evaluate(pageFunction, ...args) {
|
||||
try {
|
||||
const handle = await this.evaluateHandle(pageFunction, ...args);
|
||||
const result = await handle.jsonValue();
|
||||
await handle.dispose();
|
||||
return result;
|
||||
} catch (e) {
|
||||
if (e.message.includes('cyclic object value') || e.message.includes('Object is not serializable'))
|
||||
return undefined;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
394
src/firefox/FrameManager.ts
Normal file
394
src/firefox/FrameManager.ts
Normal file
@ -0,0 +1,394 @@
|
||||
import { JugglerSession } from './Connection';
|
||||
import { Page } from './Page';
|
||||
|
||||
import {RegisteredListener, helper, assert} from '../helper';
|
||||
import {TimeoutError} from '../Errors';
|
||||
import {EventEmitter} from 'events';
|
||||
import {ExecutionContext} from './ExecutionContext';
|
||||
import {NavigationWatchdog, NextNavigationWatchdog} from './NavigationWatchdog';
|
||||
import {DOMWorld} from './DOMWorld';
|
||||
import { JSHandle, ElementHandle } from './JSHandle';
|
||||
|
||||
export const FrameManagerEvents = {
|
||||
FrameNavigated: Symbol('FrameManagerEvents.FrameNavigated'),
|
||||
FrameAttached: Symbol('FrameManagerEvents.FrameAttached'),
|
||||
FrameDetached: Symbol('FrameManagerEvents.FrameDetached'),
|
||||
Load: Symbol('FrameManagerEvents.Load'),
|
||||
DOMContentLoaded: Symbol('FrameManagerEvents.DOMContentLoaded'),
|
||||
};
|
||||
export class FrameManager extends EventEmitter {
|
||||
_session: JugglerSession;
|
||||
_page: Page;
|
||||
_networkManager: any;
|
||||
_timeoutSettings: any;
|
||||
_mainFrame: any;
|
||||
_frames: Map<string, Frame>;
|
||||
_contextIdToContext: Map<string, ExecutionContext>;
|
||||
_eventListeners: RegisteredListener[];
|
||||
constructor(session: JugglerSession, page: Page, networkManager, timeoutSettings) {
|
||||
super();
|
||||
this._session = session;
|
||||
this._page = page;
|
||||
this._networkManager = networkManager;
|
||||
this._timeoutSettings = timeoutSettings;
|
||||
this._mainFrame = null;
|
||||
this._frames = new Map();
|
||||
this._contextIdToContext = new Map();
|
||||
this._eventListeners = [
|
||||
helper.addEventListener(this._session, 'Page.eventFired', this._onEventFired.bind(this)),
|
||||
helper.addEventListener(this._session, 'Page.frameAttached', this._onFrameAttached.bind(this)),
|
||||
helper.addEventListener(this._session, 'Page.frameDetached', this._onFrameDetached.bind(this)),
|
||||
helper.addEventListener(this._session, 'Page.navigationCommitted', this._onNavigationCommitted.bind(this)),
|
||||
helper.addEventListener(this._session, 'Page.sameDocumentNavigation', this._onSameDocumentNavigation.bind(this)),
|
||||
helper.addEventListener(this._session, 'Runtime.executionContextCreated', this._onExecutionContextCreated.bind(this)),
|
||||
helper.addEventListener(this._session, 'Runtime.executionContextDestroyed', this._onExecutionContextDestroyed.bind(this)),
|
||||
];
|
||||
}
|
||||
|
||||
executionContextById(executionContextId) {
|
||||
return this._contextIdToContext.get(executionContextId) || null;
|
||||
}
|
||||
|
||||
_onExecutionContextCreated({executionContextId, auxData}) {
|
||||
const frameId = auxData ? auxData.frameId : null;
|
||||
const frame = this._frames.get(frameId) || null;
|
||||
const context = new ExecutionContext(this._session, frame, executionContextId);
|
||||
if (frame)
|
||||
frame._mainWorld._setContext(context);
|
||||
this._contextIdToContext.set(executionContextId, context);
|
||||
}
|
||||
|
||||
_onExecutionContextDestroyed({executionContextId}) {
|
||||
const context = this._contextIdToContext.get(executionContextId);
|
||||
if (!context)
|
||||
return;
|
||||
this._contextIdToContext.delete(executionContextId);
|
||||
if (context._frame)
|
||||
context._frame._mainWorld._setContext(null);
|
||||
}
|
||||
|
||||
frame(frameId) {
|
||||
return this._frames.get(frameId);
|
||||
}
|
||||
|
||||
mainFrame() {
|
||||
return this._mainFrame;
|
||||
}
|
||||
|
||||
frames() {
|
||||
const frames: Array<Frame> = [];
|
||||
collect(this._mainFrame);
|
||||
return frames;
|
||||
|
||||
function collect(frame) {
|
||||
frames.push(frame);
|
||||
for (const subframe of frame._children)
|
||||
collect(subframe);
|
||||
}
|
||||
}
|
||||
|
||||
_onNavigationCommitted(params) {
|
||||
const frame = this._frames.get(params.frameId);
|
||||
frame._navigated(params.url, params.name, params.navigationId);
|
||||
this.emit(FrameManagerEvents.FrameNavigated, frame);
|
||||
}
|
||||
|
||||
_onSameDocumentNavigation(params) {
|
||||
const frame = this._frames.get(params.frameId);
|
||||
frame._url = params.url;
|
||||
this.emit(FrameManagerEvents.FrameNavigated, frame);
|
||||
}
|
||||
|
||||
_onFrameAttached(params) {
|
||||
const frame = new Frame(this._session, this, this._networkManager, this._page, params.frameId, this._timeoutSettings);
|
||||
const parentFrame = this._frames.get(params.parentFrameId) || null;
|
||||
if (parentFrame) {
|
||||
frame._parentFrame = parentFrame;
|
||||
parentFrame._children.add(frame);
|
||||
} else {
|
||||
assert(!this._mainFrame, 'INTERNAL ERROR: re-attaching main frame!');
|
||||
this._mainFrame = frame;
|
||||
}
|
||||
this._frames.set(params.frameId, frame);
|
||||
this.emit(FrameManagerEvents.FrameAttached, frame);
|
||||
}
|
||||
|
||||
_onFrameDetached(params) {
|
||||
const frame = this._frames.get(params.frameId);
|
||||
this._frames.delete(params.frameId);
|
||||
frame._detach();
|
||||
this.emit(FrameManagerEvents.FrameDetached, frame);
|
||||
}
|
||||
|
||||
_onEventFired({frameId, name}) {
|
||||
const frame = this._frames.get(frameId);
|
||||
frame._firedEvents.add(name.toLowerCase());
|
||||
if (frame === this._mainFrame) {
|
||||
if (name === 'load')
|
||||
this.emit(FrameManagerEvents.Load);
|
||||
else if (name === 'DOMContentLoaded')
|
||||
this.emit(FrameManagerEvents.DOMContentLoaded);
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
helper.removeEventListeners(this._eventListeners);
|
||||
}
|
||||
}
|
||||
|
||||
export class Frame {
|
||||
_parentFrame: Frame|null = null;
|
||||
private _session: JugglerSession;
|
||||
_page: any;
|
||||
_frameManager: FrameManager;
|
||||
private _networkManager: any;
|
||||
private _timeoutSettings: any;
|
||||
_frameId: string;
|
||||
_url: string = '';
|
||||
private _name: string = '';
|
||||
_children: Set<Frame>;
|
||||
private _detached: boolean;
|
||||
_firedEvents: Set<string>;
|
||||
_mainWorld: any;
|
||||
_lastCommittedNavigationId: any;
|
||||
constructor(session: JugglerSession, frameManager : FrameManager, networkManager, page: Page, frameId: string, timeoutSettings) {
|
||||
this._session = session;
|
||||
this._page = page;
|
||||
this._frameManager = frameManager;
|
||||
this._networkManager = networkManager;
|
||||
this._timeoutSettings = timeoutSettings;
|
||||
this._frameId = frameId;
|
||||
this._children = new Set();
|
||||
this._detached = false;
|
||||
|
||||
|
||||
this._firedEvents = new Set();
|
||||
|
||||
this._mainWorld = new DOMWorld(this, timeoutSettings);
|
||||
}
|
||||
|
||||
async executionContext() {
|
||||
return this._mainWorld.executionContext();
|
||||
}
|
||||
|
||||
async waitForNavigation(options: { timeout?: number; waitUntil?: string | Array<string>; } = {}) {
|
||||
const {
|
||||
timeout = this._timeoutSettings.navigationTimeout(),
|
||||
waitUntil = ['load'],
|
||||
} = options;
|
||||
const normalizedWaitUntil = normalizeWaitUntil(waitUntil);
|
||||
|
||||
const timeoutError = new TimeoutError('Navigation timeout of ' + timeout + ' ms exceeded');
|
||||
let timeoutCallback;
|
||||
const timeoutPromise = new Promise(resolve => timeoutCallback = resolve.bind(null, timeoutError));
|
||||
const timeoutId = timeout ? setTimeout(timeoutCallback, timeout) : null;
|
||||
|
||||
const nextNavigationDog = new NextNavigationWatchdog(this._session, this);
|
||||
const error1 = await Promise.race([
|
||||
nextNavigationDog.promise(),
|
||||
timeoutPromise,
|
||||
]);
|
||||
nextNavigationDog.dispose();
|
||||
|
||||
// If timeout happened first - throw.
|
||||
if (error1) {
|
||||
clearTimeout(timeoutId);
|
||||
throw error1;
|
||||
}
|
||||
|
||||
const {navigationId, url} = nextNavigationDog.navigation();
|
||||
|
||||
if (!navigationId) {
|
||||
// Same document navigation happened.
|
||||
clearTimeout(timeoutId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const watchDog = new NavigationWatchdog(this._session, this, this._networkManager, navigationId, url, normalizedWaitUntil);
|
||||
const error = await Promise.race([
|
||||
timeoutPromise,
|
||||
watchDog.promise(),
|
||||
]);
|
||||
watchDog.dispose();
|
||||
clearTimeout(timeoutId);
|
||||
if (error)
|
||||
throw error;
|
||||
return watchDog.navigationResponse();
|
||||
}
|
||||
|
||||
async goto(url: string, options: { timeout?: number; waitUntil?: string | Array<string>; referer?: string; } = {}) {
|
||||
const {
|
||||
timeout = this._timeoutSettings.navigationTimeout(),
|
||||
waitUntil = ['load'],
|
||||
referer,
|
||||
} = options;
|
||||
const normalizedWaitUntil = normalizeWaitUntil(waitUntil);
|
||||
const {navigationId} = await this._session.send('Page.navigate', {
|
||||
frameId: this._frameId,
|
||||
referer,
|
||||
url,
|
||||
});
|
||||
if (!navigationId)
|
||||
return;
|
||||
|
||||
const timeoutError = new TimeoutError('Navigation timeout of ' + timeout + ' ms exceeded');
|
||||
let timeoutCallback;
|
||||
const timeoutPromise = new Promise(resolve => timeoutCallback = resolve.bind(null, timeoutError));
|
||||
const timeoutId = timeout ? setTimeout(timeoutCallback, timeout) : null;
|
||||
|
||||
const watchDog = new NavigationWatchdog(this._session, this, this._networkManager, navigationId, url, normalizedWaitUntil);
|
||||
const error = await Promise.race([
|
||||
timeoutPromise,
|
||||
watchDog.promise(),
|
||||
]);
|
||||
watchDog.dispose();
|
||||
clearTimeout(timeoutId);
|
||||
if (error)
|
||||
throw error;
|
||||
return watchDog.navigationResponse();
|
||||
}
|
||||
|
||||
async click(selector: string, options: { delay?: number; button?: string; clickCount?: number; } | undefined = {}) {
|
||||
return this._mainWorld.click(selector, options);
|
||||
}
|
||||
|
||||
async tap(selector: string) {
|
||||
return this._mainWorld.tap(selector);
|
||||
}
|
||||
|
||||
async type(selector: string, text: string, options: { delay: (number | undefined); } | undefined) {
|
||||
return this._mainWorld.type(selector, text, options);
|
||||
}
|
||||
|
||||
async focus(selector: string) {
|
||||
return this._mainWorld.focus(selector);
|
||||
}
|
||||
|
||||
async hover(selector: string) {
|
||||
return this._mainWorld.hover(selector);
|
||||
}
|
||||
|
||||
_detach() {
|
||||
this._parentFrame._children.delete(this);
|
||||
this._parentFrame = null;
|
||||
this._detached = true;
|
||||
this._mainWorld._detach();
|
||||
}
|
||||
|
||||
_navigated(url, name, navigationId) {
|
||||
this._url = url;
|
||||
this._name = name;
|
||||
this._lastCommittedNavigationId = navigationId;
|
||||
this._firedEvents.clear();
|
||||
}
|
||||
|
||||
select(selector: string, ...values: Array<string>): Promise<Array<string>> {
|
||||
return this._mainWorld.select(selector, ...values);
|
||||
}
|
||||
|
||||
waitFor(selectorOrFunctionOrTimeout: (string | number | Function), options: { polling?: string | number; timeout?: number; visible?: boolean; hidden?: boolean; } | undefined, ...args: Array<any>): Promise<JSHandle> {
|
||||
const xPathPattern = '//';
|
||||
|
||||
if (helper.isString(selectorOrFunctionOrTimeout)) {
|
||||
const 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, selectorOrFunctionOrTimeout));
|
||||
if (typeof selectorOrFunctionOrTimeout === 'function')
|
||||
return this.waitForFunction(selectorOrFunctionOrTimeout, options, ...args);
|
||||
return Promise.reject(new Error('Unsupported target type: ' + (typeof selectorOrFunctionOrTimeout)));
|
||||
}
|
||||
|
||||
waitForFunction(pageFunction: Function | string, options: { polling?: string | number; timeout?: number; } | undefined = {}, ...args): Promise<JSHandle> {
|
||||
return this._mainWorld.waitForFunction(pageFunction, options, ...args);
|
||||
}
|
||||
|
||||
waitForSelector(selector: string, options: { timeout?: number; visible?: boolean; hidden?: boolean; } | undefined): Promise<ElementHandle> {
|
||||
return this._mainWorld.waitForSelector(selector, options);
|
||||
}
|
||||
|
||||
waitForXPath(xpath: string, options: { timeout?: number; visible?: boolean; hidden?: boolean; } | undefined): Promise<ElementHandle> {
|
||||
return this._mainWorld.waitForXPath(xpath, options);
|
||||
}
|
||||
|
||||
async content(): Promise<string> {
|
||||
return this._mainWorld.content();
|
||||
}
|
||||
|
||||
async setContent(html: string) {
|
||||
return this._mainWorld.setContent(html);
|
||||
}
|
||||
|
||||
async evaluate(pageFunction, ...args) {
|
||||
return this._mainWorld.evaluate(pageFunction, ...args);
|
||||
}
|
||||
|
||||
async $(selector: string): Promise<ElementHandle | null> {
|
||||
return this._mainWorld.$(selector);
|
||||
}
|
||||
|
||||
async $$(selector: string): Promise<Array<ElementHandle>> {
|
||||
return this._mainWorld.$$(selector);
|
||||
}
|
||||
|
||||
async $eval(selector: string, pageFunction: Function | string, ...args: Array<any>): Promise<(object | undefined)> {
|
||||
return this._mainWorld.$eval(selector, pageFunction, ...args);
|
||||
}
|
||||
|
||||
async $$eval(selector: string, pageFunction: Function | string, ...args: Array<any>): Promise<(object | undefined)> {
|
||||
return this._mainWorld.$$eval(selector, pageFunction, ...args);
|
||||
}
|
||||
|
||||
async $x(expression: string): Promise<Array<ElementHandle>> {
|
||||
return this._mainWorld.$x(expression);
|
||||
}
|
||||
|
||||
async evaluateHandle(pageFunction, ...args) {
|
||||
return this._mainWorld.evaluateHandle(pageFunction, ...args);
|
||||
}
|
||||
|
||||
async addScriptTag(options: { content?: string; path?: string; type?: string; url?: string; }): Promise<ElementHandle> {
|
||||
return this._mainWorld.addScriptTag(options);
|
||||
}
|
||||
|
||||
async addStyleTag(options: { content?: string; path?: string; url?: string; }): Promise<ElementHandle> {
|
||||
return this._mainWorld.addStyleTag(options);
|
||||
}
|
||||
|
||||
async title(): Promise<string> {
|
||||
return this._mainWorld.title();
|
||||
}
|
||||
|
||||
name() {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
isDetached() {
|
||||
return this._detached;
|
||||
}
|
||||
|
||||
childFrames() {
|
||||
return Array.from(this._children);
|
||||
}
|
||||
|
||||
url() {
|
||||
return this._url;
|
||||
}
|
||||
|
||||
parentFrame() {
|
||||
return this._parentFrame;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeWaitUntil(waitUntil) {
|
||||
if (!Array.isArray(waitUntil))
|
||||
waitUntil = [waitUntil];
|
||||
for (const condition of waitUntil) {
|
||||
if (condition !== 'load' && condition !== 'domcontentloaded')
|
||||
throw new Error('Unknown waitUntil condition: ' + condition);
|
||||
}
|
||||
return waitUntil;
|
||||
}
|
292
src/firefox/Input.ts
Normal file
292
src/firefox/Input.ts
Normal file
@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { keyDefinitions } from '../USKeyboardLayout';
|
||||
import { JugglerSession } from './Connection';
|
||||
|
||||
interface KeyDescription {
|
||||
keyCode: number;
|
||||
key: string;
|
||||
text: string;
|
||||
code: string;
|
||||
location: number;
|
||||
}
|
||||
|
||||
export class Keyboard {
|
||||
_client: JugglerSession;
|
||||
_modifiers: number;
|
||||
_pressedKeys: Set<string>;
|
||||
constructor(client: JugglerSession) {
|
||||
this._client = client;
|
||||
this._modifiers = 0;
|
||||
this._pressedKeys = new Set();
|
||||
}
|
||||
|
||||
async down(key: string) {
|
||||
const description = this._keyDescriptionForString(key);
|
||||
|
||||
const repeat = this._pressedKeys.has(description.code);
|
||||
this._pressedKeys.add(description.code);
|
||||
this._modifiers |= this._modifierBit(description.key);
|
||||
|
||||
await this._client.send('Page.dispatchKeyEvent', {
|
||||
type: 'keydown',
|
||||
keyCode: description.keyCode,
|
||||
code: description.code,
|
||||
key: description.key,
|
||||
repeat,
|
||||
location: description.location
|
||||
});
|
||||
}
|
||||
|
||||
_modifierBit(key: string): number {
|
||||
if (key === 'Alt')
|
||||
return 1;
|
||||
if (key === 'Control')
|
||||
return 2;
|
||||
if (key === 'Shift')
|
||||
return 4;
|
||||
if (key === 'Meta')
|
||||
return 8;
|
||||
return 0;
|
||||
}
|
||||
|
||||
_keyDescriptionForString(keyString: string): KeyDescription {
|
||||
const shift = this._modifiers & 8;
|
||||
const description = {
|
||||
key: '',
|
||||
keyCode: 0,
|
||||
code: '',
|
||||
text: '',
|
||||
location: 0
|
||||
};
|
||||
const definition = keyDefinitions[keyString];
|
||||
if (!definition)
|
||||
throw new Error(`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 = '';
|
||||
|
||||
if (description.code === 'MetaLeft')
|
||||
description.code = 'OSLeft';
|
||||
if (description.code === 'MetaRight')
|
||||
description.code = 'OSRight';
|
||||
return description;
|
||||
}
|
||||
|
||||
async up(key: string) {
|
||||
const description = this._keyDescriptionForString(key);
|
||||
|
||||
this._modifiers &= ~this._modifierBit(description.key);
|
||||
this._pressedKeys.delete(description.code);
|
||||
await this._client.send('Page.dispatchKeyEvent', {
|
||||
type: 'keyup',
|
||||
key: description.key,
|
||||
keyCode: description.keyCode,
|
||||
code: description.code,
|
||||
location: description.location,
|
||||
repeat: false
|
||||
});
|
||||
}
|
||||
|
||||
async sendCharacter(char: string) {
|
||||
await this._client.send('Page.insertText', {
|
||||
text: char
|
||||
});
|
||||
}
|
||||
|
||||
async type(text: string, options: { delay?: number; } | undefined = {}) {
|
||||
const {delay = null} = options;
|
||||
for (const char of text) {
|
||||
if (keyDefinitions[char])
|
||||
await this.press(char, {delay});
|
||||
else
|
||||
await this.sendCharacter(char);
|
||||
if (delay !== null)
|
||||
await new Promise(f => setTimeout(f, delay));
|
||||
}
|
||||
}
|
||||
|
||||
async press(key: string, options: { delay?: number; } | undefined = {}) {
|
||||
const {delay = null} = options;
|
||||
await this.down(key);
|
||||
if (delay !== null)
|
||||
await new Promise(f => setTimeout(f, options.delay));
|
||||
await this.up(key);
|
||||
}
|
||||
}
|
||||
|
||||
export class Mouse {
|
||||
_client: any;
|
||||
_keyboard: Keyboard;
|
||||
_x: number;
|
||||
_y: number;
|
||||
_buttons: number;
|
||||
constructor(client, keyboard: Keyboard) {
|
||||
this._client = client;
|
||||
this._keyboard = keyboard;
|
||||
this._x = 0;
|
||||
this._y = 0;
|
||||
this._buttons = 0;
|
||||
}
|
||||
|
||||
async move(x: number, y: number, options: { steps?: number; } | undefined = {}) {
|
||||
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('Page.dispatchMouseEvent', {
|
||||
type: 'mousemove',
|
||||
button: 0,
|
||||
x: fromX + (this._x - fromX) * (i / steps),
|
||||
y: fromY + (this._y - fromY) * (i / steps),
|
||||
modifiers: this._keyboard._modifiers,
|
||||
buttons: this._buttons,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async click(x: number, y: number, options: { delay?: number; button?: string; clickCount?: number; } | undefined = {}) {
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async down(options: { button?: string; clickCount?: number; } | undefined = {}) {
|
||||
const {
|
||||
button = 'left',
|
||||
clickCount = 1
|
||||
} = options;
|
||||
if (button === 'left')
|
||||
this._buttons |= 1;
|
||||
if (button === 'right')
|
||||
this._buttons |= 2;
|
||||
if (button === 'middle')
|
||||
this._buttons |= 4;
|
||||
await this._client.send('Page.dispatchMouseEvent', {
|
||||
type: 'mousedown',
|
||||
button: this._buttonNameToButton(button),
|
||||
x: this._x,
|
||||
y: this._y,
|
||||
modifiers: this._keyboard._modifiers,
|
||||
clickCount,
|
||||
buttons: this._buttons,
|
||||
});
|
||||
}
|
||||
|
||||
_buttonNameToButton(buttonName: string): number {
|
||||
if (buttonName === 'left')
|
||||
return 0;
|
||||
if (buttonName === 'middle')
|
||||
return 1;
|
||||
if (buttonName === 'right')
|
||||
return 2;
|
||||
}
|
||||
|
||||
async up(options: { button?: string; clickCount?: number; } | undefined = {}) {
|
||||
const {
|
||||
button = 'left',
|
||||
clickCount = 1
|
||||
} = options;
|
||||
if (button === 'left')
|
||||
this._buttons &= ~1;
|
||||
if (button === 'right')
|
||||
this._buttons &= ~2;
|
||||
if (button === 'middle')
|
||||
this._buttons &= ~4;
|
||||
await this._client.send('Page.dispatchMouseEvent', {
|
||||
type: 'mouseup',
|
||||
button: this._buttonNameToButton(button),
|
||||
x: this._x,
|
||||
y: this._y,
|
||||
modifiers: this._keyboard._modifiers,
|
||||
clickCount: clickCount,
|
||||
buttons: this._buttons,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class Touchscreen {
|
||||
_client: JugglerSession;
|
||||
_keyboard: Keyboard;
|
||||
_mouse: Mouse;
|
||||
constructor(client: JugglerSession, keyboard: Keyboard, mouse: Mouse) {
|
||||
this._client = client;
|
||||
this._keyboard = keyboard;
|
||||
this._mouse = mouse;
|
||||
}
|
||||
|
||||
async tap(x: number, y: number) {
|
||||
const touchPoints = [{x: Math.round(x), y: Math.round(y)}];
|
||||
let {defaultPrevented} = (await this._client.send('Page.dispatchTouchEvent', {
|
||||
type: 'touchStart',
|
||||
touchPoints,
|
||||
modifiers: this._keyboard._modifiers
|
||||
}));
|
||||
defaultPrevented = (await this._client.send('Page.dispatchTouchEvent', {
|
||||
type: 'touchEnd',
|
||||
touchPoints,
|
||||
modifiers: this._keyboard._modifiers
|
||||
})).defaultPrevented || defaultPrevented;
|
||||
// Do not dispatch related mouse events if either of touch events
|
||||
// were prevented.
|
||||
// See https://developer.mozilla.org/en-US/docs/Web/API/Touch_events/Supporting_both_TouchEvent_and_MouseEvent#Event_order
|
||||
if (defaultPrevented)
|
||||
return;
|
||||
await this._mouse.move(x, y);
|
||||
await this._mouse.down();
|
||||
await this._mouse.up();
|
||||
}
|
||||
}
|
360
src/firefox/JSHandle.ts
Normal file
360
src/firefox/JSHandle.ts
Normal file
@ -0,0 +1,360 @@
|
||||
import {assert, debugError} from '../helper';
|
||||
import * as path from 'path';
|
||||
import {ExecutionContext} from './ExecutionContext';
|
||||
import {Frame} from './FrameManager';
|
||||
|
||||
export class JSHandle {
|
||||
_context: ExecutionContext;
|
||||
_session: any;
|
||||
_executionContextId: any;
|
||||
_objectId: any;
|
||||
_type: any;
|
||||
_subtype: any;
|
||||
_disposed: boolean;
|
||||
_protocolValue: { unserializableValue: any; value: any; objectId: any; };
|
||||
constructor(context: ExecutionContext, payload: any) {
|
||||
this._context = context;
|
||||
this._session = this._context._session;
|
||||
this._executionContextId = this._context._executionContextId;
|
||||
this._objectId = payload.objectId;
|
||||
this._type = payload.type;
|
||||
this._subtype = payload.subtype;
|
||||
this._disposed = false;
|
||||
this._protocolValue = {
|
||||
unserializableValue: payload.unserializableValue,
|
||||
value: payload.value,
|
||||
objectId: payload.objectId,
|
||||
};
|
||||
}
|
||||
|
||||
executionContext(): ExecutionContext {
|
||||
return this._context;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
if (this._objectId)
|
||||
return 'JSHandle@' + (this._subtype || this._type);
|
||||
return 'JSHandle:' + this._deserializeValue(this._protocolValue);
|
||||
}
|
||||
|
||||
async getProperty(propertyName: string): Promise<JSHandle | null> {
|
||||
const objectHandle = await this._context.evaluateHandle((object, propertyName) => {
|
||||
const result = {__proto__: null};
|
||||
result[propertyName] = object[propertyName];
|
||||
return result;
|
||||
}, this, propertyName);
|
||||
const properties = await objectHandle.getProperties();
|
||||
const result = properties.get(propertyName) || null;
|
||||
await objectHandle.dispose();
|
||||
return result;
|
||||
}
|
||||
|
||||
async getProperties(): Promise<Map<string, JSHandle>> {
|
||||
const response = await this._session.send('Runtime.getObjectProperties', {
|
||||
executionContextId: this._executionContextId,
|
||||
objectId: this._objectId,
|
||||
});
|
||||
const result = new Map();
|
||||
for (const property of response.properties)
|
||||
result.set(property.name, createHandle(this._context, property.value, null));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
_deserializeValue({unserializableValue, value}) {
|
||||
if (unserializableValue === 'Infinity')
|
||||
return Infinity;
|
||||
if (unserializableValue === '-Infinity')
|
||||
return -Infinity;
|
||||
if (unserializableValue === '-0')
|
||||
return -0;
|
||||
if (unserializableValue === 'NaN')
|
||||
return NaN;
|
||||
return value;
|
||||
}
|
||||
|
||||
async jsonValue() {
|
||||
if (!this._objectId)
|
||||
return this._deserializeValue(this._protocolValue);
|
||||
const simpleValue = await this._session.send('Runtime.callFunction', {
|
||||
executionContextId: this._executionContextId,
|
||||
returnByValue: true,
|
||||
functionDeclaration: (e => e).toString(),
|
||||
args: [this._protocolValue],
|
||||
});
|
||||
return this._deserializeValue(simpleValue.result);
|
||||
}
|
||||
|
||||
asElement(): ElementHandle | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
if (!this._objectId)
|
||||
return;
|
||||
this._disposed = true;
|
||||
await this._session.send('Runtime.disposeObject', {
|
||||
executionContextId: this._executionContextId,
|
||||
objectId: this._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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class ElementHandle extends JSHandle {
|
||||
_frame: Frame;
|
||||
_frameId: any;
|
||||
constructor(frame: Frame, context: ExecutionContext, payload: any) {
|
||||
super(context, payload);
|
||||
this._frame = frame;
|
||||
this._frameId = frame._frameId;
|
||||
}
|
||||
|
||||
async contentFrame(): Promise<Frame | null> {
|
||||
const {frameId} = await this._session.send('Page.contentFrame', {
|
||||
frameId: this._frameId,
|
||||
objectId: this._objectId,
|
||||
});
|
||||
if (!frameId)
|
||||
return null;
|
||||
const frame = this._frame._frameManager.frame(frameId);
|
||||
return frame;
|
||||
}
|
||||
|
||||
asElement(): ElementHandle {
|
||||
return this;
|
||||
}
|
||||
|
||||
async boundingBox(): Promise<{ width: number; height: number; x: number; y: number; }> {
|
||||
return await this._session.send('Page.getBoundingBox', {
|
||||
frameId: this._frameId,
|
||||
objectId: this._objectId,
|
||||
});
|
||||
}
|
||||
|
||||
async screenshot(options: { encoding?: string; path?: string; } = {}) {
|
||||
const clip = await this._session.send('Page.getBoundingBox', {
|
||||
frameId: this._frameId,
|
||||
objectId: this._objectId,
|
||||
});
|
||||
if (!clip)
|
||||
throw new Error('Node is either not visible or not an HTMLElement');
|
||||
assert(clip.width, 'Node has 0 width.');
|
||||
assert(clip.height, 'Node has 0 height.');
|
||||
await this._scrollIntoViewIfNeeded();
|
||||
|
||||
return await this._frame._page.screenshot(Object.assign({}, options, {
|
||||
clip: {
|
||||
x: clip.x,
|
||||
y: clip.y,
|
||||
width: clip.width,
|
||||
height: clip.height,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
isIntersectingViewport(): Promise<boolean> {
|
||||
return this._frame.evaluate(async element => {
|
||||
const visibleRatio = await new Promise(resolve => {
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
resolve(entries[0].intersectionRatio);
|
||||
observer.disconnect();
|
||||
});
|
||||
observer.observe(element);
|
||||
// Firefox doesn't call IntersectionObserver callback unless
|
||||
// there are rafs.
|
||||
requestAnimationFrame(() => {});
|
||||
});
|
||||
return visibleRatio > 0;
|
||||
}, this);
|
||||
}
|
||||
|
||||
async $(selector: string): Promise<ElementHandle | null> {
|
||||
const handle = await this._frame.evaluateHandle(
|
||||
(element, selector) => element.querySelector(selector),
|
||||
this, selector
|
||||
);
|
||||
const element = handle.asElement();
|
||||
if (element)
|
||||
return element;
|
||||
await handle.dispose();
|
||||
return null;
|
||||
}
|
||||
|
||||
async $$(selector: string): Promise<Array<ElementHandle>> {
|
||||
const arrayHandle = await this._frame.evaluateHandle(
|
||||
(element, selector) => element.querySelectorAll(selector),
|
||||
this, selector
|
||||
);
|
||||
const properties = await arrayHandle.getProperties();
|
||||
await arrayHandle.dispose();
|
||||
const result = [];
|
||||
for (const property of properties.values()) {
|
||||
const elementHandle = property.asElement();
|
||||
if (elementHandle)
|
||||
result.push(elementHandle);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async $eval(selector: string, pageFunction: Function | string, ...args: Array<any>): Promise<(object | undefined)> {
|
||||
const elementHandle = await this.$(selector);
|
||||
if (!elementHandle)
|
||||
throw new Error(`Error: failed to find element matching selector "${selector}"`);
|
||||
const result = await this._frame.evaluate(pageFunction, elementHandle, ...args);
|
||||
await elementHandle.dispose();
|
||||
return result;
|
||||
}
|
||||
|
||||
async $$eval(selector: string, pageFunction: Function | string, ...args: Array<any>): Promise<(object | undefined)> {
|
||||
const arrayHandle = await this._frame.evaluateHandle(
|
||||
(element, selector) => Array.from(element.querySelectorAll(selector)),
|
||||
this, selector
|
||||
);
|
||||
|
||||
const result = await this._frame.evaluate(pageFunction, arrayHandle, ...args);
|
||||
await arrayHandle.dispose();
|
||||
return result;
|
||||
}
|
||||
|
||||
async $x(expression: string): Promise<Array<ElementHandle>> {
|
||||
const arrayHandle = await this._frame.evaluateHandle(
|
||||
(element, expression) => {
|
||||
const document = element.ownerDocument || element;
|
||||
const iterator = document.evaluate(expression, element, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE);
|
||||
const array = [];
|
||||
let item;
|
||||
while ((item = iterator.iterateNext()))
|
||||
array.push(item);
|
||||
return array;
|
||||
},
|
||||
this, expression
|
||||
);
|
||||
const properties = await arrayHandle.getProperties();
|
||||
await arrayHandle.dispose();
|
||||
const result = [];
|
||||
for (const property of properties.values()) {
|
||||
const elementHandle = property.asElement();
|
||||
if (elementHandle)
|
||||
result.push(elementHandle);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async _scrollIntoViewIfNeeded() {
|
||||
const error = await this._frame.evaluate(async element => {
|
||||
if (!element.isConnected)
|
||||
return 'Node is detached from document';
|
||||
if (element.nodeType !== Node.ELEMENT_NODE)
|
||||
return 'Node is not of type HTMLElement';
|
||||
const visibleRatio = await new Promise(resolve => {
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
resolve(entries[0].intersectionRatio);
|
||||
observer.disconnect();
|
||||
});
|
||||
observer.observe(element);
|
||||
// Firefox doesn't call IntersectionObserver callback unless
|
||||
// there are rafs.
|
||||
requestAnimationFrame(() => {});
|
||||
});
|
||||
if (visibleRatio !== 1.0)
|
||||
element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
|
||||
return false;
|
||||
}, this);
|
||||
if (error)
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
async click(options: { delay?: number; button?: string; clickCount?: number; } | undefined) {
|
||||
await this._scrollIntoViewIfNeeded();
|
||||
const {x, y} = await this._clickablePoint();
|
||||
await this._frame._page.mouse.click(x, y, options);
|
||||
}
|
||||
|
||||
async tap() {
|
||||
await this._scrollIntoViewIfNeeded();
|
||||
const {x, y} = await this._clickablePoint();
|
||||
await this._frame._page.touchscreen.tap(x, y);
|
||||
}
|
||||
|
||||
async uploadFile(...filePaths: Array<string>) {
|
||||
const files = filePaths.map(filePath => path.resolve(filePath));
|
||||
await this._session.send('Page.setFileInputFiles', {
|
||||
frameId: this._frameId,
|
||||
objectId: this._objectId,
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
async hover() {
|
||||
await this._scrollIntoViewIfNeeded();
|
||||
const {x, y} = await this._clickablePoint();
|
||||
await this._frame._page.mouse.move(x, y);
|
||||
}
|
||||
|
||||
async focus() {
|
||||
await this._frame.evaluate(element => element.focus(), this);
|
||||
}
|
||||
|
||||
async type(text: string, options: { delay: (number | undefined); } | undefined) {
|
||||
await this.focus();
|
||||
await this._frame._page.keyboard.type(text, options);
|
||||
}
|
||||
|
||||
async press(key: string, options: { delay?: number; } | undefined) {
|
||||
await this.focus();
|
||||
await this._frame._page.keyboard.press(key, options);
|
||||
}
|
||||
|
||||
|
||||
async _clickablePoint(): Promise<{ x: number; y: number; }> {
|
||||
const result = await this._session.send('Page.getContentQuads', {
|
||||
frameId: this._frameId,
|
||||
objectId: this._objectId,
|
||||
}).catch(debugError);
|
||||
if (!result || !result.quads.length)
|
||||
throw new Error('Node is either not visible or not an HTMLElement');
|
||||
// Filter out quads that have too small area to click into.
|
||||
const quads = result.quads.filter(quad => computeQuadArea(quad) > 1);
|
||||
if (!quads.length)
|
||||
throw new Error('Node is either not visible or not an HTMLElement');
|
||||
// Return the middle point of the first quad.
|
||||
return computeQuadCenter(quads[0]);
|
||||
}
|
||||
}
|
||||
|
||||
export function createHandle(context: ExecutionContext, result: any, exceptionDetails?: any) {
|
||||
const frame = context.frame();
|
||||
if (exceptionDetails) {
|
||||
if (exceptionDetails.value)
|
||||
throw new Error('Evaluation failed: ' + JSON.stringify(exceptionDetails.value));
|
||||
else
|
||||
throw new Error('Evaluation failed: ' + exceptionDetails.text + '\n' + exceptionDetails.stack);
|
||||
}
|
||||
return result.subtype === 'node' ? new ElementHandle(frame, context, result) : new JSHandle(context, result);
|
||||
}
|
||||
|
||||
function computeQuadArea(quad) {
|
||||
// Compute sum of all directed areas of adjacent triangles
|
||||
// https://en.wikipedia.org/wiki/Polygon#Simple_polygons
|
||||
let area = 0;
|
||||
const points = [quad.p1, quad.p2, quad.p3, quad.p4];
|
||||
for (let i = 0; i < points.length; ++i) {
|
||||
const p1 = points[i];
|
||||
const p2 = points[(i + 1) % points.length];
|
||||
area += (p1.x * p2.y - p2.x * p1.y) / 2;
|
||||
}
|
||||
return Math.abs(area);
|
||||
}
|
||||
|
||||
function computeQuadCenter(quad) {
|
||||
let x = 0, y = 0;
|
||||
for (const point of [quad.p1, quad.p2, quad.p3, quad.p4]) {
|
||||
x += point.x;
|
||||
y += point.y;
|
||||
}
|
||||
return {x: x / 4, y: y / 4};
|
||||
}
|
268
src/firefox/Launcher.ts
Normal file
268
src/firefox/Launcher.ts
Normal file
@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import * as removeFolder from 'rimraf';
|
||||
import * as childProcess from 'child_process';
|
||||
import {Connection} from './Connection';
|
||||
import {Browser} from './Browser';
|
||||
import {BrowserFetcher} from './BrowserFetcher';
|
||||
import * as readline from 'readline';
|
||||
import * as fs from 'fs';
|
||||
import * as util from 'util';
|
||||
import {helper, debugError} from '../helper';
|
||||
import {TimeoutError} from '../Errors';
|
||||
import {WebSocketTransport} from './WebSocketTransport';
|
||||
|
||||
const mkdtempAsync = util.promisify(fs.mkdtemp);
|
||||
const removeFolderAsync = util.promisify(removeFolder);
|
||||
|
||||
const FIREFOX_PROFILE_PATH = path.join(os.tmpdir(), 'playwright_firefox_profile-');
|
||||
|
||||
const DEFAULT_ARGS = [
|
||||
'-no-remote',
|
||||
'-foreground',
|
||||
];
|
||||
|
||||
export class Launcher {
|
||||
private _projectRoot: string;
|
||||
private _preferredRevision: string;
|
||||
constructor(projectRoot, preferredRevision) {
|
||||
this._projectRoot = projectRoot;
|
||||
this._preferredRevision = preferredRevision;
|
||||
}
|
||||
|
||||
defaultArgs(options: any = {}) {
|
||||
const {
|
||||
headless = true,
|
||||
args = [],
|
||||
userDataDir = null,
|
||||
} = options;
|
||||
const firefoxArguments = [...DEFAULT_ARGS];
|
||||
if (userDataDir)
|
||||
firefoxArguments.push('-profile', userDataDir);
|
||||
if (headless)
|
||||
firefoxArguments.push('-headless');
|
||||
firefoxArguments.push(...args);
|
||||
if (args.every(arg => arg.startsWith('-')))
|
||||
firefoxArguments.push('about:blank');
|
||||
return firefoxArguments;
|
||||
}
|
||||
|
||||
async launch(options: any = {}): Promise<Browser> {
|
||||
const {
|
||||
ignoreDefaultArgs = false,
|
||||
args = [],
|
||||
dumpio = false,
|
||||
executablePath = null,
|
||||
env = process.env,
|
||||
handleSIGHUP = true,
|
||||
handleSIGINT = true,
|
||||
handleSIGTERM = true,
|
||||
ignoreHTTPSErrors = false,
|
||||
defaultViewport = {width: 800, height: 600},
|
||||
slowMo = 0,
|
||||
timeout = 30000,
|
||||
} = 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.includes('-juggler'))
|
||||
firefoxArguments.push('-juggler', '0');
|
||||
|
||||
let temporaryProfileDir = null;
|
||||
if (!firefoxArguments.includes('-profile') && !firefoxArguments.includes('--profile')) {
|
||||
temporaryProfileDir = await mkdtempAsync(FIREFOX_PROFILE_PATH);
|
||||
firefoxArguments.push(`-profile`, temporaryProfileDir);
|
||||
}
|
||||
|
||||
let firefoxExecutable = executablePath;
|
||||
if (!firefoxExecutable) {
|
||||
const {missingText, executablePath} = this._resolveExecutablePath();
|
||||
if (missingText)
|
||||
throw new Error(missingText);
|
||||
firefoxExecutable = executablePath;
|
||||
}
|
||||
const stdio = ['pipe', 'pipe', 'pipe'];
|
||||
const firefoxProcess = childProcess.spawn(
|
||||
firefoxExecutable,
|
||||
firefoxArguments,
|
||||
{
|
||||
// On non-windows platforms, `detached: false` 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',
|
||||
stdio,
|
||||
// On linux Juggler ships the libstdc++ it was linked against.
|
||||
env: os.platform() === 'linux' ? {
|
||||
...env,
|
||||
LD_LIBRARY_PATH: `${path.dirname(firefoxExecutable)}:${process.env.LD_LIBRARY_PATH}`,
|
||||
} : env,
|
||||
}
|
||||
);
|
||||
|
||||
if (dumpio) {
|
||||
firefoxProcess.stderr.pipe(process.stderr);
|
||||
firefoxProcess.stdout.pipe(process.stdout);
|
||||
}
|
||||
|
||||
let firefoxClosed = false;
|
||||
const waitForFirefoxToClose = new Promise((fulfill, reject) => {
|
||||
firefoxProcess.once('close', () => {
|
||||
firefoxClosed = true;
|
||||
// Cleanup as processes exit.
|
||||
if (temporaryProfileDir) {
|
||||
removeFolderAsync(temporaryProfileDir)
|
||||
.then(() => fulfill())
|
||||
.catch(err => console.error(err));
|
||||
} else {
|
||||
fulfill();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const listeners = [ helper.addEventListener(process, 'close', killFirefox) ];
|
||||
if (handleSIGINT)
|
||||
listeners.push(helper.addEventListener(process, 'SIGINT', () => { killFirefox(); process.exit(130); }));
|
||||
if (handleSIGTERM)
|
||||
listeners.push(helper.addEventListener(process, 'SIGTERM', gracefullyCloseFirefox));
|
||||
if (handleSIGHUP)
|
||||
listeners.push(helper.addEventListener(process, 'SIGHUP', gracefullyCloseFirefox));
|
||||
let connection: Connection | null = null;
|
||||
try {
|
||||
const url = await waitForWSEndpoint(firefoxProcess, timeout);
|
||||
const transport = await WebSocketTransport.create(url);
|
||||
connection = new Connection(url, transport, slowMo);
|
||||
const browser = await Browser.create(connection, defaultViewport, firefoxProcess, gracefullyCloseFirefox);
|
||||
if (ignoreHTTPSErrors)
|
||||
await connection.send('Browser.setIgnoreHTTPSErrors', {enabled: true});
|
||||
await browser.waitForTarget(t => t.type() === 'page');
|
||||
return browser;
|
||||
} catch (e) {
|
||||
killFirefox();
|
||||
throw e;
|
||||
}
|
||||
|
||||
function gracefullyCloseFirefox() {
|
||||
helper.removeEventListeners(listeners);
|
||||
if (temporaryProfileDir) {
|
||||
killFirefox();
|
||||
} else if (connection) {
|
||||
connection.send('Browser.close').catch(error => {
|
||||
debugError(error);
|
||||
killFirefox();
|
||||
});
|
||||
}
|
||||
return waitForFirefoxToClose;
|
||||
}
|
||||
|
||||
// This method has to be sync to be used as 'exit' event handler.
|
||||
function killFirefox() {
|
||||
helper.removeEventListeners(listeners);
|
||||
if (firefoxProcess.pid && !firefoxProcess.killed && !firefoxClosed) {
|
||||
// Force kill chrome.
|
||||
try {
|
||||
if (process.platform === 'win32')
|
||||
childProcess.execSync(`taskkill /pid ${firefoxProcess.pid} /T /F`);
|
||||
else
|
||||
process.kill(-firefoxProcess.pid, 'SIGKILL');
|
||||
} catch (e) {
|
||||
// the process might have already stopped
|
||||
}
|
||||
}
|
||||
// Attempt to remove temporary profile directory to avoid littering.
|
||||
try {
|
||||
removeFolder.sync(temporaryProfileDir);
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
|
||||
async connect(options: any = {}): Promise<Browser> {
|
||||
const {
|
||||
browserWSEndpoint,
|
||||
slowMo = 0,
|
||||
defaultViewport = {width: 800, height: 600},
|
||||
ignoreHTTPSErrors = false,
|
||||
} = options;
|
||||
let connection = null;
|
||||
const transport = await WebSocketTransport.create(browserWSEndpoint);
|
||||
connection = new Connection(browserWSEndpoint, transport, slowMo);
|
||||
const browser = await Browser.create(connection, defaultViewport, null, () => connection.send('Browser.close').catch(debugError));
|
||||
if (ignoreHTTPSErrors)
|
||||
await connection.send('Browser.setIgnoreHTTPSErrors', {enabled: true});
|
||||
return browser;
|
||||
}
|
||||
|
||||
executablePath(): string {
|
||||
return this._resolveExecutablePath().executablePath;
|
||||
}
|
||||
|
||||
_resolveExecutablePath() {
|
||||
const browserFetcher = new BrowserFetcher(this._projectRoot, { browser: 'firefox' });
|
||||
const revisionInfo = browserFetcher.revisionInfo(this._preferredRevision);
|
||||
const missingText = !revisionInfo.local ? `Firefox revision is not downloaded. Run "npm install" or "yarn install"` : null;
|
||||
return {executablePath: revisionInfo.executablePath, missingText};
|
||||
}
|
||||
}
|
||||
|
||||
function waitForWSEndpoint(firefoxProcess: import('child_process').ChildProcess, timeout: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const rl = readline.createInterface({ input: firefoxProcess.stdout });
|
||||
let stderr = '';
|
||||
const listeners = [
|
||||
helper.addEventListener(rl, 'line', onLine),
|
||||
helper.addEventListener(rl, 'close', () => onClose()),
|
||||
helper.addEventListener(firefoxProcess, 'error', error => onClose(error))
|
||||
];
|
||||
const timeoutId = timeout ? setTimeout(onTimeout, timeout) : 0;
|
||||
|
||||
function onClose(error?: Error) {
|
||||
cleanup();
|
||||
reject(new Error([
|
||||
'Failed to launch Firefox!' + (error ? ' ' + error.message : ''),
|
||||
stderr,
|
||||
'',
|
||||
].join('\n')));
|
||||
}
|
||||
|
||||
function onTimeout() {
|
||||
cleanup();
|
||||
reject(new TimeoutError(`Timed out after ${timeout} ms while trying to connect to Firefox!`));
|
||||
}
|
||||
|
||||
function onLine(line: string) {
|
||||
stderr += line + '\n';
|
||||
const match = line.match(/^Juggler listening on (ws:\/\/.*)$/);
|
||||
if (!match)
|
||||
return;
|
||||
cleanup();
|
||||
resolve(match[1]);
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (timeoutId)
|
||||
clearTimeout(timeoutId);
|
||||
helper.removeEventListeners(listeners);
|
||||
}
|
||||
});
|
||||
}
|
125
src/firefox/NavigationWatchdog.ts
Normal file
125
src/firefox/NavigationWatchdog.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { helper, RegisteredListener } from '../helper';
|
||||
import { JugglerSession, JugglerSessionEvents } from './Connection';
|
||||
import { Frame, FrameManagerEvents } from './FrameManager';
|
||||
import { NetworkManager, NetworkManagerEvents } from './NetworkManager';
|
||||
|
||||
export class NextNavigationWatchdog {
|
||||
private _navigatedFrame: Frame;
|
||||
private _promise: Promise<unknown>;
|
||||
private _resolveCallback: (value?: unknown) => void;
|
||||
private _navigation: {navigationId: number|null, url?: string} = null;
|
||||
private _eventListeners: RegisteredListener[];
|
||||
constructor(session : JugglerSession, navigatedFrame : Frame) {
|
||||
this._navigatedFrame = navigatedFrame;
|
||||
this._promise = new Promise(x => this._resolveCallback = x);
|
||||
this._eventListeners = [
|
||||
helper.addEventListener(session, 'Page.navigationStarted', this._onNavigationStarted.bind(this)),
|
||||
helper.addEventListener(session, 'Page.sameDocumentNavigation', this._onSameDocumentNavigation.bind(this)),
|
||||
];
|
||||
}
|
||||
|
||||
promise() {
|
||||
return this._promise;
|
||||
}
|
||||
|
||||
navigation() {
|
||||
return this._navigation;
|
||||
}
|
||||
|
||||
_onNavigationStarted(params) {
|
||||
if (params.frameId === this._navigatedFrame._frameId) {
|
||||
this._navigation = {
|
||||
navigationId: params.navigationId,
|
||||
url: params.url,
|
||||
};
|
||||
this._resolveCallback();
|
||||
}
|
||||
}
|
||||
|
||||
_onSameDocumentNavigation(params) {
|
||||
if (params.frameId === this._navigatedFrame._frameId) {
|
||||
this._navigation = {
|
||||
navigationId: null,
|
||||
};
|
||||
this._resolveCallback();
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
helper.removeEventListeners(this._eventListeners);
|
||||
}
|
||||
}
|
||||
|
||||
export class NavigationWatchdog {
|
||||
private _navigatedFrame: Frame;
|
||||
private _targetNavigationId: any;
|
||||
private _firedEvents: any;
|
||||
private _targetURL: any;
|
||||
private _promise: Promise<unknown>;
|
||||
private _resolveCallback: (value?: unknown) => void;
|
||||
private _navigationRequest: any;
|
||||
private _eventListeners: RegisteredListener[];
|
||||
constructor(session : JugglerSession, navigatedFrame : Frame, networkManager : NetworkManager, targetNavigationId, targetURL, firedEvents) {
|
||||
this._navigatedFrame = navigatedFrame;
|
||||
this._targetNavigationId = targetNavigationId;
|
||||
this._firedEvents = firedEvents;
|
||||
this._targetURL = targetURL;
|
||||
|
||||
this._promise = new Promise(x => this._resolveCallback = x);
|
||||
this._navigationRequest = null;
|
||||
|
||||
const check = this._checkNavigationComplete.bind(this);
|
||||
this._eventListeners = [
|
||||
helper.addEventListener(session, JugglerSessionEvents.Disconnected, () => this._resolveCallback(new Error('Navigation failed because browser has disconnected!'))),
|
||||
helper.addEventListener(session, 'Page.eventFired', check),
|
||||
helper.addEventListener(session, 'Page.frameAttached', check),
|
||||
helper.addEventListener(session, 'Page.frameDetached', check),
|
||||
helper.addEventListener(session, 'Page.navigationStarted', check),
|
||||
helper.addEventListener(session, 'Page.navigationCommitted', check),
|
||||
helper.addEventListener(session, 'Page.navigationAborted', this._onNavigationAborted.bind(this)),
|
||||
helper.addEventListener(networkManager, NetworkManagerEvents.Request, this._onRequest.bind(this)),
|
||||
helper.addEventListener(navigatedFrame._frameManager, FrameManagerEvents.FrameDetached, check),
|
||||
];
|
||||
check();
|
||||
}
|
||||
|
||||
_onRequest(request) {
|
||||
if (request.frame() !== this._navigatedFrame || !request.isNavigationRequest())
|
||||
return;
|
||||
this._navigationRequest = request;
|
||||
}
|
||||
|
||||
navigationResponse() {
|
||||
return this._navigationRequest ? this._navigationRequest.response() : null;
|
||||
}
|
||||
|
||||
_checkNavigationComplete() {
|
||||
if (this._navigatedFrame.isDetached())
|
||||
this._resolveCallback(new Error('Navigating frame was detached'));
|
||||
else if (this._navigatedFrame._lastCommittedNavigationId === this._targetNavigationId
|
||||
&& checkFiredEvents(this._navigatedFrame, this._firedEvents))
|
||||
this._resolveCallback(null);
|
||||
|
||||
|
||||
function checkFiredEvents(frame, firedEvents) {
|
||||
for (const subframe of frame._children) {
|
||||
if (!checkFiredEvents(subframe, firedEvents))
|
||||
return false;
|
||||
}
|
||||
return firedEvents.every(event => frame._firedEvents.has(event));
|
||||
}
|
||||
}
|
||||
|
||||
_onNavigationAborted(params) {
|
||||
if (params.frameId === this._navigatedFrame._frameId && params.navigationId === this._targetNavigationId)
|
||||
this._resolveCallback(new Error('Navigation to ' + this._targetURL + ' failed: ' + params.errorText));
|
||||
}
|
||||
|
||||
promise() {
|
||||
return this._promise;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
helper.removeEventListeners(this._eventListeners);
|
||||
}
|
||||
}
|
365
src/firefox/NetworkManager.ts
Normal file
365
src/firefox/NetworkManager.ts
Normal file
@ -0,0 +1,365 @@
|
||||
import {helper, assert, debugError} from '../helper';
|
||||
import {EventEmitter} from 'events';
|
||||
import { JugglerSession } from './Connection';
|
||||
import { FrameManager } from './FrameManager';
|
||||
|
||||
export const NetworkManagerEvents = {
|
||||
RequestFailed: Symbol('NetworkManagerEvents.RequestFailed'),
|
||||
RequestFinished: Symbol('NetworkManagerEvents.RequestFinished'),
|
||||
Request: Symbol('NetworkManagerEvents.Request'),
|
||||
Response: Symbol('NetworkManagerEvents.Response'),
|
||||
};
|
||||
|
||||
export class NetworkManager extends EventEmitter {
|
||||
private _session: JugglerSession;
|
||||
private _requests: Map<any, any>;
|
||||
private _frameManager: FrameManager;
|
||||
private _eventListeners: any[];
|
||||
constructor(session) {
|
||||
super();
|
||||
this._session = session;
|
||||
|
||||
this._requests = new Map();
|
||||
this._frameManager = null;
|
||||
|
||||
this._eventListeners = [
|
||||
helper.addEventListener(session, 'Network.requestWillBeSent', this._onRequestWillBeSent.bind(this)),
|
||||
helper.addEventListener(session, 'Network.responseReceived', this._onResponseReceived.bind(this)),
|
||||
helper.addEventListener(session, 'Network.requestFinished', this._onRequestFinished.bind(this)),
|
||||
helper.addEventListener(session, 'Network.requestFailed', this._onRequestFailed.bind(this)),
|
||||
];
|
||||
}
|
||||
|
||||
dispose() {
|
||||
helper.removeEventListeners(this._eventListeners);
|
||||
}
|
||||
|
||||
setFrameManager(frameManager: FrameManager) {
|
||||
this._frameManager = frameManager;
|
||||
}
|
||||
|
||||
async setExtraHTTPHeaders(headers) {
|
||||
const array = [];
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
assert(helper.isString(value), `Expected value of header "${name}" to be String, but "${typeof value}" is found.`);
|
||||
array.push({name, value});
|
||||
}
|
||||
await this._session.send('Network.setExtraHTTPHeaders', {headers: array});
|
||||
}
|
||||
|
||||
async setRequestInterception(enabled) {
|
||||
await this._session.send('Network.setRequestInterception', {enabled});
|
||||
}
|
||||
|
||||
_onRequestWillBeSent(event) {
|
||||
const redirected = event.redirectedFrom ? this._requests.get(event.redirectedFrom) : null;
|
||||
const frame = redirected ? redirected.frame() : (this._frameManager && event.frameId ? this._frameManager.frame(event.frameId) : null);
|
||||
if (!frame)
|
||||
return;
|
||||
let redirectChain = [];
|
||||
if (redirected) {
|
||||
redirectChain = redirected._redirectChain;
|
||||
redirectChain.push(redirected);
|
||||
this._requests.delete(redirected._id);
|
||||
}
|
||||
const request = new Request(this._session, frame, redirectChain, event);
|
||||
this._requests.set(request._id, request);
|
||||
this.emit(NetworkManagerEvents.Request, request);
|
||||
}
|
||||
|
||||
_onResponseReceived(event) {
|
||||
const request = this._requests.get(event.requestId);
|
||||
if (!request)
|
||||
return;
|
||||
const response = new Response(this._session, request, event);
|
||||
request._response = response;
|
||||
this.emit(NetworkManagerEvents.Response, response);
|
||||
}
|
||||
|
||||
_onRequestFinished(event) {
|
||||
const request = this._requests.get(event.requestId);
|
||||
if (!request)
|
||||
return;
|
||||
// Keep redirected requests in the map for future reference in redirectChain.
|
||||
const isRedirected = request.response().status() >= 300 && request.response().status() <= 399;
|
||||
if (isRedirected) {
|
||||
request.response()._bodyLoadedPromiseFulfill.call(null, new Error('Response body is unavailable for redirect responses'));
|
||||
} else {
|
||||
this._requests.delete(request._id);
|
||||
request.response()._bodyLoadedPromiseFulfill.call(null);
|
||||
}
|
||||
this.emit(NetworkManagerEvents.RequestFinished, request);
|
||||
}
|
||||
|
||||
_onRequestFailed(event) {
|
||||
const request = this._requests.get(event.requestId);
|
||||
if (!request)
|
||||
return;
|
||||
this._requests.delete(request._id);
|
||||
if (request.response())
|
||||
request.response()._bodyLoadedPromiseFulfill.call(null);
|
||||
request._errorText = event.errorCode;
|
||||
this.emit(NetworkManagerEvents.RequestFailed, request);
|
||||
}
|
||||
}
|
||||
|
||||
const causeToResourceType = {
|
||||
TYPE_INVALID: 'other',
|
||||
TYPE_OTHER: 'other',
|
||||
TYPE_SCRIPT: 'script',
|
||||
TYPE_IMAGE: 'image',
|
||||
TYPE_STYLESHEET: 'stylesheet',
|
||||
TYPE_OBJECT: 'other',
|
||||
TYPE_DOCUMENT: 'document',
|
||||
TYPE_SUBDOCUMENT: 'document',
|
||||
TYPE_REFRESH: 'document',
|
||||
TYPE_XBL: 'other',
|
||||
TYPE_PING: 'other',
|
||||
TYPE_XMLHTTPREQUEST: 'xhr',
|
||||
TYPE_OBJECT_SUBREQUEST: 'other',
|
||||
TYPE_DTD: 'other',
|
||||
TYPE_FONT: 'font',
|
||||
TYPE_MEDIA: 'media',
|
||||
TYPE_WEBSOCKET: 'websocket',
|
||||
TYPE_CSP_REPORT: 'other',
|
||||
TYPE_XSLT: 'other',
|
||||
TYPE_BEACON: 'other',
|
||||
TYPE_FETCH: 'fetch',
|
||||
TYPE_IMAGESET: 'images',
|
||||
TYPE_WEB_MANIFEST: 'manifest',
|
||||
};
|
||||
|
||||
export class Request {
|
||||
_id(_id: any, request: Request) {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
_session: any;
|
||||
_frame: any;
|
||||
_redirectChain: any;
|
||||
_url: any;
|
||||
_postData: any;
|
||||
_suspended: any;
|
||||
_response: any;
|
||||
_errorText: any;
|
||||
_isNavigationRequest: any;
|
||||
_method: any;
|
||||
_resourceType: any;
|
||||
_headers: {};
|
||||
_interceptionHandled: boolean;
|
||||
constructor(session, frame, redirectChain, payload) {
|
||||
this._session = session;
|
||||
this._frame = frame;
|
||||
this._id = payload.requestId;
|
||||
this._redirectChain = redirectChain;
|
||||
this._url = payload.url;
|
||||
this._postData = payload.postData;
|
||||
this._suspended = payload.suspended;
|
||||
this._response = null;
|
||||
this._errorText = null;
|
||||
this._isNavigationRequest = payload.isNavigationRequest;
|
||||
this._method = payload.method;
|
||||
this._resourceType = causeToResourceType[payload.cause] || 'other';
|
||||
this._headers = {};
|
||||
this._interceptionHandled = false;
|
||||
for (const {name, value} of payload.headers)
|
||||
this._headers[name.toLowerCase()] = value;
|
||||
}
|
||||
|
||||
failure() {
|
||||
return this._errorText ? {errorText: this._errorText} : null;
|
||||
}
|
||||
|
||||
async continue(overrides: any = {}) {
|
||||
assert(!overrides.url, 'Playwright-Firefox does not support overriding URL');
|
||||
assert(!overrides.method, 'Playwright-Firefox does not support overriding method');
|
||||
assert(!overrides.postData, 'Playwright-Firefox does not support overriding postData');
|
||||
assert(this._suspended, 'Request Interception is not enabled!');
|
||||
assert(!this._interceptionHandled, 'Request is already handled!');
|
||||
this._interceptionHandled = true;
|
||||
const {
|
||||
headers,
|
||||
} = overrides;
|
||||
await this._session.send('Network.resumeSuspendedRequest', {
|
||||
requestId: this._id,
|
||||
headers: headers ? Object.entries(headers).filter(([, value]) => !Object.is(value, undefined)).map(([name, value]) => ({name, value})) : undefined,
|
||||
}).catch(error => {
|
||||
debugError(error);
|
||||
});
|
||||
}
|
||||
|
||||
async abort() {
|
||||
assert(this._suspended, 'Request Interception is not enabled!');
|
||||
assert(!this._interceptionHandled, 'Request is already handled!');
|
||||
this._interceptionHandled = true;
|
||||
await this._session.send('Network.abortSuspendedRequest', {
|
||||
requestId: this._id,
|
||||
}).catch(error => {
|
||||
debugError(error);
|
||||
});
|
||||
}
|
||||
|
||||
postData() {
|
||||
return this._postData;
|
||||
}
|
||||
|
||||
headers() {
|
||||
return {...this._headers};
|
||||
}
|
||||
|
||||
redirectChain() {
|
||||
return this._redirectChain.slice();
|
||||
}
|
||||
|
||||
resourceType() {
|
||||
return this._resourceType;
|
||||
}
|
||||
|
||||
url() {
|
||||
return this._url;
|
||||
}
|
||||
|
||||
method() {
|
||||
return this._method;
|
||||
}
|
||||
|
||||
isNavigationRequest() {
|
||||
return this._isNavigationRequest;
|
||||
}
|
||||
|
||||
frame() {
|
||||
return this._frame;
|
||||
}
|
||||
|
||||
response() {
|
||||
return this._response;
|
||||
}
|
||||
}
|
||||
|
||||
export class Response {
|
||||
_session: any;
|
||||
_request: any;
|
||||
_remoteIPAddress: any;
|
||||
_remotePort: any;
|
||||
_status: any;
|
||||
_statusText: any;
|
||||
_headers: {};
|
||||
_securityDetails: SecurityDetails;
|
||||
_bodyLoadedPromise: Promise<unknown>;
|
||||
_bodyLoadedPromiseFulfill: (value?: unknown) => void;
|
||||
_contentPromise: any;
|
||||
constructor(session, request, payload) {
|
||||
this._session = session;
|
||||
this._request = request;
|
||||
this._remoteIPAddress = payload.remoteIPAddress;
|
||||
this._remotePort = payload.remotePort;
|
||||
this._status = payload.status;
|
||||
this._statusText = payload.statusText;
|
||||
this._headers = {};
|
||||
this._securityDetails = payload.securityDetails ? new SecurityDetails(payload.securityDetails) : null;
|
||||
for (const {name, value} of payload.headers)
|
||||
this._headers[name.toLowerCase()] = value;
|
||||
this._bodyLoadedPromise = new Promise(fulfill => {
|
||||
this._bodyLoadedPromiseFulfill = fulfill;
|
||||
});
|
||||
}
|
||||
|
||||
buffer(): Promise<Buffer> {
|
||||
if (!this._contentPromise) {
|
||||
this._contentPromise = this._bodyLoadedPromise.then(async error => {
|
||||
if (error)
|
||||
throw error;
|
||||
const response = await this._session.send('Network.getResponseBody', {
|
||||
requestId: this._request._id
|
||||
});
|
||||
if (response.evicted)
|
||||
throw new Error(`Response body for ${this._request.method()} ${this._request.url()} was evicted!`);
|
||||
return Buffer.from(response.base64body, 'base64');
|
||||
});
|
||||
}
|
||||
return this._contentPromise;
|
||||
}
|
||||
|
||||
async text(): Promise<string> {
|
||||
const content = await this.buffer();
|
||||
return content.toString('utf8');
|
||||
}
|
||||
|
||||
async json(): Promise<object> {
|
||||
const content = await this.text();
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
securityDetails() {
|
||||
return this._securityDetails;
|
||||
}
|
||||
|
||||
headers() {
|
||||
return {...this._headers};
|
||||
}
|
||||
|
||||
status() {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
statusText() {
|
||||
return this._statusText;
|
||||
}
|
||||
|
||||
ok() {
|
||||
return this._status >= 200 && this._status <= 299;
|
||||
}
|
||||
|
||||
remoteAddress() {
|
||||
return {
|
||||
ip: this._remoteIPAddress,
|
||||
port: this._remotePort,
|
||||
};
|
||||
}
|
||||
|
||||
frame() {
|
||||
return this._request.frame();
|
||||
}
|
||||
|
||||
url() {
|
||||
return this._request.url();
|
||||
}
|
||||
|
||||
request() {
|
||||
return this._request;
|
||||
}
|
||||
}
|
||||
|
||||
export class SecurityDetails {
|
||||
_subjectName: string;
|
||||
_issuer: string;
|
||||
_validFrom: number;
|
||||
_validTo: number;
|
||||
_protocol: string;
|
||||
constructor(securityPayload: any) {
|
||||
this._subjectName = securityPayload['subjectName'];
|
||||
this._issuer = securityPayload['issuer'];
|
||||
this._validFrom = securityPayload['validFrom'];
|
||||
this._validTo = securityPayload['validTo'];
|
||||
this._protocol = securityPayload['protocol'];
|
||||
}
|
||||
|
||||
subjectName(): string {
|
||||
return this._subjectName;
|
||||
}
|
||||
|
||||
issuer(): string {
|
||||
return this._issuer;
|
||||
}
|
||||
|
||||
validFrom(): number {
|
||||
return this._validFrom;
|
||||
}
|
||||
|
||||
validTo(): number {
|
||||
return this._validTo;
|
||||
}
|
||||
|
||||
protocol(): string {
|
||||
return this._protocol;
|
||||
}
|
||||
}
|
640
src/firefox/Page.ts
Normal file
640
src/firefox/Page.ts
Normal file
@ -0,0 +1,640 @@
|
||||
import { JSHandle, ElementHandle } from './JSHandle';
|
||||
|
||||
import {RegisteredListener, helper, debugError, assert} from '../helper';
|
||||
import {Keyboard, Mouse, Touchscreen} from './Input';
|
||||
import {Dialog} from './Dialog';
|
||||
import {TimeoutError} from '../Errors';
|
||||
import * as fs from 'fs';
|
||||
import * as mime from 'mime';
|
||||
import {EventEmitter} from 'events';
|
||||
import {createHandle} from './JSHandle';
|
||||
import {Connection, JugglerSession, JugglerSessionEvents} from './Connection';
|
||||
import {FrameManager, normalizeWaitUntil, FrameManagerEvents} from './FrameManager';
|
||||
import {NetworkManager, Request, Response, NetworkManagerEvents} from './NetworkManager';
|
||||
import {TimeoutSettings} from '../TimeoutSettings';
|
||||
import {NavigationWatchdog} from './NavigationWatchdog';
|
||||
import {Accessibility} from './Accessibility';
|
||||
import { Target, BrowserContext } from './Browser';
|
||||
import { Events } from '../Events';
|
||||
|
||||
const writeFileAsync = helper.promisify(fs.writeFile);
|
||||
|
||||
export class Page extends EventEmitter {
|
||||
private _timeoutSettings: TimeoutSettings;
|
||||
private _session: JugglerSession;
|
||||
private _target: Target;
|
||||
private _keyboard: Keyboard;
|
||||
private _mouse: Mouse;
|
||||
private _touchscreen: Touchscreen;
|
||||
private _accessibility: Accessibility;
|
||||
private _closed: boolean;
|
||||
private _pageBindings: Map<string, Function>;
|
||||
private _networkManager: NetworkManager;
|
||||
private _frameManager: FrameManager;
|
||||
private _eventListeners: RegisteredListener[];
|
||||
private _viewport: Viewport;
|
||||
private _disconnectPromise: Promise<Error>;
|
||||
emulateMedia: (type: string) => Promise<void>;
|
||||
static async create(session, target: Target, defaultViewport: Viewport | null) {
|
||||
const page = new Page(session, target);
|
||||
await Promise.all([
|
||||
session.send('Runtime.enable'),
|
||||
session.send('Network.enable'),
|
||||
session.send('Page.enable'),
|
||||
]);
|
||||
|
||||
if (defaultViewport)
|
||||
await page.setViewport(defaultViewport);
|
||||
return page;
|
||||
}
|
||||
|
||||
constructor(session: JugglerSession, target: Target) {
|
||||
super();
|
||||
this._timeoutSettings = new TimeoutSettings();
|
||||
this._session = session;
|
||||
this._target = target;
|
||||
this._keyboard = new Keyboard(session);
|
||||
this._mouse = new Mouse(session, this._keyboard);
|
||||
this._touchscreen = new Touchscreen(session, this._keyboard, this._mouse);
|
||||
this._accessibility = new Accessibility(session);
|
||||
this._closed = false;
|
||||
this._pageBindings = new Map();
|
||||
this._networkManager = new NetworkManager(session);
|
||||
this._frameManager = new FrameManager(session, this, this._networkManager, this._timeoutSettings);
|
||||
this._networkManager.setFrameManager(this._frameManager);
|
||||
this._eventListeners = [
|
||||
helper.addEventListener(this._session, 'Page.uncaughtError', this._onUncaughtError.bind(this)),
|
||||
helper.addEventListener(this._session, 'Runtime.console', this._onConsole.bind(this)),
|
||||
helper.addEventListener(this._session, 'Page.dialogOpened', this._onDialogOpened.bind(this)),
|
||||
helper.addEventListener(this._session, 'Page.bindingCalled', this._onBindingCalled.bind(this)),
|
||||
helper.addEventListener(this._frameManager, FrameManagerEvents.Load, () => this.emit(Events.Page.Load)),
|
||||
helper.addEventListener(this._frameManager, FrameManagerEvents.DOMContentLoaded, () => this.emit(Events.Page.DOMContentLoaded)),
|
||||
helper.addEventListener(this._frameManager, FrameManagerEvents.FrameAttached, frame => this.emit(Events.Page.FrameAttached, frame)),
|
||||
helper.addEventListener(this._frameManager, FrameManagerEvents.FrameDetached, frame => this.emit(Events.Page.FrameDetached, frame)),
|
||||
helper.addEventListener(this._frameManager, FrameManagerEvents.FrameNavigated, frame => this.emit(Events.Page.FrameNavigated, frame)),
|
||||
helper.addEventListener(this._networkManager, NetworkManagerEvents.Request, request => this.emit(Events.Page.Request, request)),
|
||||
helper.addEventListener(this._networkManager, NetworkManagerEvents.Response, response => this.emit(Events.Page.Response, response)),
|
||||
helper.addEventListener(this._networkManager, NetworkManagerEvents.RequestFinished, request => this.emit(Events.Page.RequestFinished, request)),
|
||||
helper.addEventListener(this._networkManager, NetworkManagerEvents.RequestFailed, request => this.emit(Events.Page.RequestFailed, request)),
|
||||
];
|
||||
this._viewport = null;
|
||||
this._target._isClosedPromise.then(() => {
|
||||
this._closed = true;
|
||||
this._frameManager.dispose();
|
||||
this._networkManager.dispose();
|
||||
helper.removeEventListeners(this._eventListeners);
|
||||
this.emit(Events.Page.Close);
|
||||
});
|
||||
}
|
||||
|
||||
async cookies(...urls: Array<string>): Promise<Array<any>> {
|
||||
const connection = Connection.fromSession(this._session);
|
||||
const {cookies} = await connection.send('Browser.getCookies', {
|
||||
browserContextId: this._target._context._browserContextId || undefined,
|
||||
urls: urls.length ? urls : [this.url()]
|
||||
});
|
||||
// Firefox's cookies are missing sameSite when it is 'None'
|
||||
return cookies.map(cookie => ({sameSite: 'None', ...cookie}));
|
||||
}
|
||||
|
||||
async deleteCookie(...cookies: Array<any>) {
|
||||
const pageURL = this.url();
|
||||
const items = [];
|
||||
for (const cookie of cookies) {
|
||||
const item = {
|
||||
url: cookie.url,
|
||||
domain: cookie.domain,
|
||||
path: cookie.path,
|
||||
name: cookie.name,
|
||||
};
|
||||
if (!item.url && pageURL.startsWith('http'))
|
||||
item.url = pageURL;
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
const connection = Connection.fromSession(this._session);
|
||||
await connection.send('Browser.deleteCookies', {
|
||||
browserContextId: this._target._context._browserContextId || undefined,
|
||||
cookies: items,
|
||||
});
|
||||
}
|
||||
|
||||
async setCookie(...cookies: Array<any>) {
|
||||
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) {
|
||||
const connection = Connection.fromSession(this._session);
|
||||
await connection.send('Browser.setCookies', {
|
||||
browserContextId: this._target._context._browserContextId || undefined,
|
||||
cookies: items
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async setRequestInterception(enabled) {
|
||||
await this._networkManager.setRequestInterception(enabled);
|
||||
}
|
||||
|
||||
async setExtraHTTPHeaders(headers) {
|
||||
await this._networkManager.setExtraHTTPHeaders(headers);
|
||||
}
|
||||
|
||||
async emulateMediaType(type: string | null) {
|
||||
assert(type === 'screen' || type === 'print' || type === null, 'Unsupported media type: ' + type);
|
||||
await this._session.send('Page.setEmulatedMedia', {media: type || ''});
|
||||
}
|
||||
|
||||
async exposeFunction(name: string, playwrightFunction: Function) {
|
||||
if (this._pageBindings.has(name))
|
||||
throw new Error(`Failed to add page binding with name ${name}: window['${name}'] already exists!`);
|
||||
this._pageBindings.set(name, playwrightFunction);
|
||||
|
||||
const expression = helper.evaluationString(addPageBinding, name);
|
||||
await this._session.send('Page.addBinding', {name: name});
|
||||
await this._session.send('Page.addScriptToEvaluateOnNewDocument', {script: expression});
|
||||
await Promise.all(this.frames().map(frame => frame.evaluate(expression).catch(debugError)));
|
||||
|
||||
function addPageBinding(bindingName: string) {
|
||||
const binding: (string) => void = window[bindingName];
|
||||
window[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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async _onBindingCalled(event: any) {
|
||||
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._session.send('Runtime.evaluate', { expression, executionContextId: event.executionContextId }).catch(debugError);
|
||||
|
||||
function deliverResult(name: string, seq: number, result: any) {
|
||||
window[name]['callbacks'].get(seq).resolve(result);
|
||||
window[name]['callbacks'].delete(seq);
|
||||
}
|
||||
|
||||
function deliverError(name: string, seq: number, message: string, stack: string) {
|
||||
const error = new Error(message);
|
||||
error.stack = stack;
|
||||
window[name]['callbacks'].get(seq).reject(error);
|
||||
window[name]['callbacks'].delete(seq);
|
||||
}
|
||||
|
||||
function deliverErrorValue(name: string, seq: number, value: any) {
|
||||
window[name]['callbacks'].get(seq).reject(value);
|
||||
window[name]['callbacks'].delete(seq);
|
||||
}
|
||||
}
|
||||
|
||||
_sessionClosePromise() {
|
||||
if (!this._disconnectPromise)
|
||||
this._disconnectPromise = new Promise<Error>(fulfill => this._session.once(JugglerSessionEvents.Disconnected, () => fulfill(new Error('Target closed'))));
|
||||
return this._disconnectPromise;
|
||||
}
|
||||
|
||||
async waitForRequest(urlOrPredicate: (string | Function), options: { timeout?: number; } | undefined = {}): Promise<Request> {
|
||||
const {
|
||||
timeout = this._timeoutSettings.timeout(),
|
||||
} = options;
|
||||
return helper.waitForEvent(this._networkManager, NetworkManagerEvents.Request, request => {
|
||||
if (helper.isString(urlOrPredicate))
|
||||
return (urlOrPredicate === request.url());
|
||||
if (typeof urlOrPredicate === 'function')
|
||||
return !!(urlOrPredicate(request));
|
||||
return false;
|
||||
}, timeout, this._sessionClosePromise());
|
||||
}
|
||||
|
||||
async waitForResponse(urlOrPredicate: (string | Function), options: { timeout?: number; } | undefined = {}): Promise<Response> {
|
||||
const {
|
||||
timeout = this._timeoutSettings.timeout(),
|
||||
} = options;
|
||||
return helper.waitForEvent(this._networkManager, NetworkManagerEvents.Response, response => {
|
||||
if (helper.isString(urlOrPredicate))
|
||||
return (urlOrPredicate === response.url());
|
||||
if (typeof urlOrPredicate === 'function')
|
||||
return !!(urlOrPredicate(response));
|
||||
return false;
|
||||
}, timeout, this._sessionClosePromise());
|
||||
}
|
||||
|
||||
setDefaultNavigationTimeout(timeout: number) {
|
||||
this._timeoutSettings.setDefaultNavigationTimeout(timeout);
|
||||
}
|
||||
|
||||
setDefaultTimeout(timeout: number) {
|
||||
this._timeoutSettings.setDefaultTimeout(timeout);
|
||||
}
|
||||
|
||||
async setUserAgent(userAgent: string) {
|
||||
await this._session.send('Page.setUserAgent', {userAgent});
|
||||
}
|
||||
|
||||
async setJavaScriptEnabled(enabled) {
|
||||
await this._session.send('Page.setJavascriptEnabled', {enabled});
|
||||
}
|
||||
|
||||
async setCacheEnabled(enabled) {
|
||||
await this._session.send('Page.setCacheDisabled', {cacheDisabled: !enabled});
|
||||
}
|
||||
|
||||
async emulate(options: { viewport: Viewport; userAgent: string; }) {
|
||||
await Promise.all([
|
||||
this.setViewport(options.viewport),
|
||||
this.setUserAgent(options.userAgent),
|
||||
]);
|
||||
}
|
||||
|
||||
browserContext(): BrowserContext {
|
||||
return this._target.browserContext();
|
||||
}
|
||||
|
||||
_onUncaughtError(params) {
|
||||
const error = new Error(params.message);
|
||||
error.stack = params.stack;
|
||||
this.emit(Events.Page.PageError, error);
|
||||
}
|
||||
|
||||
viewport() {
|
||||
return this._viewport;
|
||||
}
|
||||
|
||||
async setViewport(viewport: Viewport) {
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
isMobile = false,
|
||||
deviceScaleFactor = 1,
|
||||
hasTouch = false,
|
||||
isLandscape = false,
|
||||
} = viewport;
|
||||
await this._session.send('Page.setViewport', {
|
||||
viewport: { width, height, isMobile, deviceScaleFactor, hasTouch, isLandscape },
|
||||
});
|
||||
const oldIsMobile = this._viewport ? this._viewport.isMobile : false;
|
||||
const oldHasTouch = this._viewport ? this._viewport.hasTouch : false;
|
||||
this._viewport = viewport;
|
||||
if (oldIsMobile !== isMobile || oldHasTouch !== hasTouch)
|
||||
await this.reload();
|
||||
}
|
||||
|
||||
async evaluateOnNewDocument(pageFunction: Function | string, ...args: Array<any>) {
|
||||
const script = helper.evaluationString(pageFunction, ...args);
|
||||
await this._session.send('Page.addScriptToEvaluateOnNewDocument', { script });
|
||||
}
|
||||
|
||||
browser() {
|
||||
return this._target.browser();
|
||||
}
|
||||
|
||||
target() {
|
||||
return this._target;
|
||||
}
|
||||
|
||||
url() {
|
||||
return this._frameManager.mainFrame().url();
|
||||
}
|
||||
|
||||
frames() {
|
||||
return this._frameManager.frames();
|
||||
}
|
||||
|
||||
_onDialogOpened(params) {
|
||||
this.emit(Events.Page.Dialog, new Dialog(this._session, params));
|
||||
}
|
||||
|
||||
mainFrame() {
|
||||
return this._frameManager.mainFrame();
|
||||
}
|
||||
|
||||
get accessibility() {
|
||||
return this._accessibility;
|
||||
}
|
||||
|
||||
get keyboard(){
|
||||
return this._keyboard;
|
||||
}
|
||||
|
||||
get mouse(){
|
||||
return this._mouse;
|
||||
}
|
||||
|
||||
get touchscreen(){
|
||||
return this._touchscreen;
|
||||
}
|
||||
|
||||
async waitForNavigation(options: { timeout?: number; waitUntil?: string | Array<string>; } = {}) {
|
||||
return this._frameManager.mainFrame().waitForNavigation(options);
|
||||
}
|
||||
|
||||
async goto(url: string, options: { timeout?: number; waitUntil?: string | Array<string>; } = {}) {
|
||||
return this._frameManager.mainFrame().goto(url, options);
|
||||
}
|
||||
|
||||
async goBack(options: { timeout?: number; waitUntil?: string | Array<string>; } = {}) {
|
||||
const {
|
||||
timeout = this._timeoutSettings.navigationTimeout(),
|
||||
waitUntil = ['load'],
|
||||
} = options;
|
||||
const frame = this._frameManager.mainFrame();
|
||||
const normalizedWaitUntil = normalizeWaitUntil(waitUntil);
|
||||
const {navigationId, navigationURL} = await this._session.send('Page.goBack', {
|
||||
frameId: frame._frameId,
|
||||
});
|
||||
if (!navigationId)
|
||||
return null;
|
||||
|
||||
const timeoutError = new TimeoutError('Navigation timeout of ' + timeout + ' ms exceeded');
|
||||
let timeoutCallback;
|
||||
const timeoutPromise = new Promise(resolve => timeoutCallback = resolve.bind(null, timeoutError));
|
||||
const timeoutId = timeout ? setTimeout(timeoutCallback, timeout) : null;
|
||||
|
||||
const watchDog = new NavigationWatchdog(this._session, frame, this._networkManager, navigationId, navigationURL, normalizedWaitUntil);
|
||||
const error = await Promise.race([
|
||||
timeoutPromise,
|
||||
watchDog.promise(),
|
||||
]);
|
||||
watchDog.dispose();
|
||||
clearTimeout(timeoutId);
|
||||
if (error)
|
||||
throw error;
|
||||
return watchDog.navigationResponse();
|
||||
}
|
||||
|
||||
async goForward(options: { timeout?: number; waitUntil?: string | Array<string>; } = {}) {
|
||||
const {
|
||||
timeout = this._timeoutSettings.navigationTimeout(),
|
||||
waitUntil = ['load'],
|
||||
} = options;
|
||||
const frame = this._frameManager.mainFrame();
|
||||
const normalizedWaitUntil = normalizeWaitUntil(waitUntil);
|
||||
const {navigationId, navigationURL} = await this._session.send('Page.goForward', {
|
||||
frameId: frame._frameId,
|
||||
});
|
||||
if (!navigationId)
|
||||
return null;
|
||||
|
||||
const timeoutError = new TimeoutError('Navigation timeout of ' + timeout + ' ms exceeded');
|
||||
let timeoutCallback;
|
||||
const timeoutPromise = new Promise(resolve => timeoutCallback = resolve.bind(null, timeoutError));
|
||||
const timeoutId = timeout ? setTimeout(timeoutCallback, timeout) : null;
|
||||
|
||||
const watchDog = new NavigationWatchdog(this._session, frame, this._networkManager, navigationId, navigationURL, normalizedWaitUntil);
|
||||
const error = await Promise.race([
|
||||
timeoutPromise,
|
||||
watchDog.promise(),
|
||||
]);
|
||||
watchDog.dispose();
|
||||
clearTimeout(timeoutId);
|
||||
if (error)
|
||||
throw error;
|
||||
return watchDog.navigationResponse();
|
||||
}
|
||||
|
||||
async reload(options: { timeout?: number; waitUntil?: string | Array<string>; } = {}) {
|
||||
const {
|
||||
timeout = this._timeoutSettings.navigationTimeout(),
|
||||
waitUntil = ['load'],
|
||||
} = options;
|
||||
const frame = this._frameManager.mainFrame();
|
||||
const normalizedWaitUntil = normalizeWaitUntil(waitUntil);
|
||||
const {navigationId, navigationURL} = await this._session.send('Page.reload', {
|
||||
frameId: frame._frameId,
|
||||
});
|
||||
if (!navigationId)
|
||||
return null;
|
||||
|
||||
const timeoutError = new TimeoutError('Navigation timeout of ' + timeout + ' ms exceeded');
|
||||
let timeoutCallback;
|
||||
const timeoutPromise = new Promise(resolve => timeoutCallback = resolve.bind(null, timeoutError));
|
||||
const timeoutId = timeout ? setTimeout(timeoutCallback, timeout) : null;
|
||||
|
||||
const watchDog = new NavigationWatchdog(this._session, frame, this._networkManager, navigationId, navigationURL, normalizedWaitUntil);
|
||||
const error = await Promise.race([
|
||||
timeoutPromise,
|
||||
watchDog.promise(),
|
||||
]);
|
||||
watchDog.dispose();
|
||||
clearTimeout(timeoutId);
|
||||
if (error)
|
||||
throw error;
|
||||
return watchDog.navigationResponse();
|
||||
}
|
||||
|
||||
async screenshot(options: { fullPage?: boolean; clip?: { width: number; height: number; x: number; y: number; }; encoding?: string; path?: string; } = {}): Promise<string | Buffer> {
|
||||
const {data} = await this._session.send('Page.screenshot', {
|
||||
mimeType: getScreenshotMimeType(options),
|
||||
fullPage: options.fullPage,
|
||||
clip: processClip(options.clip),
|
||||
});
|
||||
const buffer = options.encoding === 'base64' ? data : Buffer.from(data, 'base64');
|
||||
if (options.path)
|
||||
await writeFileAsync(options.path, buffer);
|
||||
return buffer;
|
||||
|
||||
function processClip(clip) {
|
||||
if (!clip)
|
||||
return undefined;
|
||||
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};
|
||||
}
|
||||
}
|
||||
|
||||
async evaluate(pageFunction, ...args) {
|
||||
return await this._frameManager.mainFrame().evaluate(pageFunction, ...args);
|
||||
}
|
||||
|
||||
async addScriptTag(options: { content?: string; path?: string; type?: string; url?: string; }): Promise<ElementHandle> {
|
||||
return await this._frameManager.mainFrame().addScriptTag(options);
|
||||
}
|
||||
|
||||
async addStyleTag(options: { content?: string; path?: string; url?: string; }): Promise<ElementHandle> {
|
||||
return await this._frameManager.mainFrame().addStyleTag(options);
|
||||
}
|
||||
|
||||
async click(selector: string, options: { delay?: number; button?: string; clickCount?: number; } | undefined = {}) {
|
||||
return await this._frameManager.mainFrame().click(selector, options);
|
||||
}
|
||||
|
||||
tap(selector: string) {
|
||||
return this.mainFrame().tap(selector);
|
||||
}
|
||||
|
||||
async type(selector: string, text: string, options: { delay: (number | undefined); } | undefined) {
|
||||
return await this._frameManager.mainFrame().type(selector, text, options);
|
||||
}
|
||||
|
||||
async focus(selector: string) {
|
||||
return await this._frameManager.mainFrame().focus(selector);
|
||||
}
|
||||
|
||||
async hover(selector: string) {
|
||||
return await this._frameManager.mainFrame().hover(selector);
|
||||
}
|
||||
|
||||
async waitFor(selectorOrFunctionOrTimeout: (string | number | Function), options: { polling?: string | number; timeout?: number; visible?: boolean; hidden?: boolean; } | undefined = {}, ...args: Array<any>): Promise<JSHandle> {
|
||||
return await this._frameManager.mainFrame().waitFor(selectorOrFunctionOrTimeout, options, ...args);
|
||||
}
|
||||
|
||||
async waitForFunction(pageFunction: Function | string, options: { polling?: string | number; timeout?: number; } | undefined = {}, ...args): Promise<JSHandle> {
|
||||
return await this._frameManager.mainFrame().waitForFunction(pageFunction, options, ...args);
|
||||
}
|
||||
|
||||
async waitForSelector(selector: string, options: { timeout?: number; visible?: boolean; hidden?: boolean; } | undefined = {}): Promise<ElementHandle> {
|
||||
return await this._frameManager.mainFrame().waitForSelector(selector, options);
|
||||
}
|
||||
|
||||
async waitForXPath(xpath: string, options: { timeout?: number; visible?: boolean; hidden?: boolean; } | undefined = {}): Promise<ElementHandle> {
|
||||
return await this._frameManager.mainFrame().waitForXPath(xpath, options);
|
||||
}
|
||||
|
||||
async title(): Promise<string> {
|
||||
return await this._frameManager.mainFrame().title();
|
||||
}
|
||||
|
||||
async $(selector: string): Promise<ElementHandle | null> {
|
||||
return await this._frameManager.mainFrame().$(selector);
|
||||
}
|
||||
|
||||
async $$(selector: string): Promise<Array<ElementHandle>> {
|
||||
return await this._frameManager.mainFrame().$$(selector);
|
||||
}
|
||||
|
||||
async $eval(selector: string, pageFunction: Function | string, ...args: Array<any>): Promise<(object | undefined)> {
|
||||
return await this._frameManager.mainFrame().$eval(selector, pageFunction, ...args);
|
||||
}
|
||||
|
||||
async $$eval(selector: string, pageFunction: Function | string, ...args: Array<any>): Promise<(object | undefined)> {
|
||||
return await this._frameManager.mainFrame().$$eval(selector, pageFunction, ...args);
|
||||
}
|
||||
|
||||
async $x(expression: string): Promise<Array<ElementHandle>> {
|
||||
return await this._frameManager.mainFrame().$x(expression);
|
||||
}
|
||||
|
||||
async evaluateHandle(pageFunction, ...args) {
|
||||
return await this._frameManager.mainFrame().evaluateHandle(pageFunction, ...args);
|
||||
}
|
||||
|
||||
async select(selector: string, ...values: Array<string>): Promise<Array<string>> {
|
||||
return await this._frameManager.mainFrame().select(selector, ...values);
|
||||
}
|
||||
|
||||
async close(options: any = {}) {
|
||||
const {
|
||||
runBeforeUnload = false,
|
||||
} = options;
|
||||
await this._session.send('Page.close', { runBeforeUnload });
|
||||
if (!runBeforeUnload)
|
||||
await this._target._isClosedPromise;
|
||||
}
|
||||
|
||||
async content() {
|
||||
return await this._frameManager.mainFrame().content();
|
||||
}
|
||||
|
||||
async setContent(html: string) {
|
||||
return await this._frameManager.mainFrame().setContent(html);
|
||||
}
|
||||
|
||||
_onConsole({type, args, executionContextId, location}) {
|
||||
const context = this._frameManager.executionContextById(executionContextId);
|
||||
this.emit(Events.Page.Console, new ConsoleMessage(type, args.map(arg => createHandle(context, arg)), location));
|
||||
}
|
||||
|
||||
isClosed(): boolean {
|
||||
return this._closed;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose alias for deprecated method.
|
||||
Page.prototype.emulateMedia = Page.prototype.emulateMediaType;
|
||||
|
||||
export class ConsoleMessage {
|
||||
private _type: string;
|
||||
private _args: any[];
|
||||
private _location: any;
|
||||
constructor(type: string, args: Array<JSHandle>, location) {
|
||||
this._type = type;
|
||||
this._args = args;
|
||||
this._location = location;
|
||||
}
|
||||
|
||||
location() {
|
||||
return this._location;
|
||||
}
|
||||
|
||||
type(): string {
|
||||
return this._type;
|
||||
}
|
||||
|
||||
args(): Array<JSHandle> {
|
||||
return this._args;
|
||||
}
|
||||
|
||||
text(): string {
|
||||
return this._args.map(arg => {
|
||||
if (arg._objectId)
|
||||
return arg.toString();
|
||||
return arg._deserializeValue(arg._protocolValue);
|
||||
}).join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
function getScreenshotMimeType(options) {
|
||||
// options.type takes precedence over inferring the type from options.path
|
||||
// because it may be a 0-length file with no extension created beforehand (i.e. as a temp file).
|
||||
if (options.type) {
|
||||
if (options.type === 'png')
|
||||
return 'image/png';
|
||||
if (options.type === 'jpeg')
|
||||
return 'image/jpeg';
|
||||
throw new Error('Unknown options.type value: ' + options.type);
|
||||
}
|
||||
if (options.path) {
|
||||
const fileType = mime.getType(options.path);
|
||||
if (fileType === 'image/png' || fileType === 'image/jpeg')
|
||||
return fileType;
|
||||
throw new Error('Unsupported screenshot mime type: ' + fileType);
|
||||
}
|
||||
return 'image/png';
|
||||
}
|
||||
|
||||
export type Viewport = {
|
||||
width: number;
|
||||
height: number;
|
||||
deviceScaleFactor?: number;
|
||||
isMobile?: boolean;
|
||||
isLandscape?: boolean;
|
||||
hasTouch?: boolean;
|
||||
}
|
66
src/firefox/Playwright.ts
Normal file
66
src/firefox/Playwright.ts
Normal file
@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Copyright 2017 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Browser } from './Browser';
|
||||
import { BrowserFetcher } from './BrowserFetcher';
|
||||
import { ConnectionTransport } from '../ConnectionTransport';
|
||||
import { DeviceDescriptors } from '../DeviceDescriptors';
|
||||
import * as Errors from '../Errors';
|
||||
import { Launcher } from './Launcher';
|
||||
|
||||
export class Playwright {
|
||||
private _projectRoot: string;
|
||||
private _launcher: Launcher;
|
||||
|
||||
constructor(projectRoot: string, preferredRevision: string) {
|
||||
this._projectRoot = projectRoot;
|
||||
this._launcher = new Launcher(projectRoot, preferredRevision);
|
||||
}
|
||||
|
||||
launch(options: any): Promise<Browser> {
|
||||
return this._launcher.launch(options);
|
||||
}
|
||||
|
||||
connect(options: (any & {
|
||||
browserWSEndpoint?: string;
|
||||
browserURL?: string;
|
||||
transport?: ConnectionTransport; })): Promise<Browser> {
|
||||
return this._launcher.connect(options);
|
||||
}
|
||||
|
||||
executablePath(): string {
|
||||
return this._launcher.executablePath();
|
||||
}
|
||||
|
||||
get devices(): any {
|
||||
const result = DeviceDescriptors.slice();
|
||||
for (const device of DeviceDescriptors)
|
||||
result[device.name] = device;
|
||||
return result;
|
||||
}
|
||||
|
||||
get errors(): any {
|
||||
return Errors;
|
||||
}
|
||||
|
||||
defaultArgs(options: any | undefined): string[] {
|
||||
return this._launcher.defaultArgs(options);
|
||||
}
|
||||
|
||||
createBrowserFetcher(options: any | undefined): BrowserFetcher {
|
||||
return new BrowserFetcher(this._projectRoot, { browser: 'firefox', ...options });
|
||||
}
|
||||
}
|
89
src/firefox/WebSocketTransport.ts
Normal file
89
src/firefox/WebSocketTransport.ts
Normal file
@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Copyright 2018 Google Inc. All rights reserved.
|
||||
* Modifications copyright (c) Microsoft Corporation.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ConnectionTransport } from '../ConnectionTransport';
|
||||
import * as WebSocket from 'ws';
|
||||
|
||||
export class WebSocketTransport implements ConnectionTransport {
|
||||
_ws: WebSocket;
|
||||
_dispatchQueue: DispatchQueue;
|
||||
onclose?: () => void;
|
||||
onmessage?: (message: string) => void;
|
||||
static create(url: string): Promise<WebSocketTransport> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(url, [], { perMessageDeflate: false });
|
||||
ws.addEventListener('open', () => resolve(new WebSocketTransport(ws)));
|
||||
ws.addEventListener('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
constructor(ws: WebSocket) {
|
||||
this._ws = ws;
|
||||
this._dispatchQueue = new DispatchQueue(this);
|
||||
this._ws.addEventListener('message', event => {
|
||||
this._dispatchQueue.enqueue(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', () => {});
|
||||
}
|
||||
|
||||
send(message: string) {
|
||||
this._ws.send(message);
|
||||
}
|
||||
|
||||
close() {
|
||||
this._ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
// We want to dispatch all "message" events in separate tasks
|
||||
// to make sure all message-related promises are resolved first
|
||||
// before dispatching next message.
|
||||
//
|
||||
// We cannot just use setTimeout() in Node.js here like we would
|
||||
// do in Browser - see https://github.com/nodejs/node/issues/23773
|
||||
// Thus implement a dispatch queue that enforces new tasks manually.
|
||||
class DispatchQueue {
|
||||
_transport: ConnectionTransport;
|
||||
_timeoutId: NodeJS.Timer = null;
|
||||
_queue: string[] = [];
|
||||
constructor(transport : ConnectionTransport) {
|
||||
this._transport = transport;
|
||||
this._dispatch = this._dispatch.bind(this);
|
||||
}
|
||||
|
||||
enqueue(message: string) {
|
||||
this._queue.push(message);
|
||||
if (!this._timeoutId)
|
||||
this._timeoutId = setTimeout(this._dispatch, 0);
|
||||
}
|
||||
|
||||
_dispatch() {
|
||||
const message = this._queue.shift();
|
||||
if (this._queue.length)
|
||||
this._timeoutId = setTimeout(this._dispatch, 0);
|
||||
else
|
||||
this._timeoutId = null;
|
||||
|
||||
if (this._transport.onmessage)
|
||||
this._transport.onmessage.call(null, message);
|
||||
}
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user