Initial commit

This commit is contained in:
Pavel Feldman 2019-11-18 18:18:28 -08:00 committed by Andrey Lushnikov
parent 615c2bc21d
commit 9ba375c063
388 changed files with 50843 additions and 61 deletions

19
.appveyor.yml Normal file
View 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
)

View 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

View 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

View 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
View 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
View 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
View 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
View 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
View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1 @@
/checkout

View File

@ -0,0 +1 @@
46ca28eadfe840021e2ea496fa6b26f924fa135b

View File

@ -0,0 +1,2 @@
1

View 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
```

View 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

View 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

File diff suppressed because it is too large Load Diff

68
browser_patches/upload.sh Executable file
View 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
View File

@ -0,0 +1 @@
/checkout

View File

@ -0,0 +1 @@
cadee71e3e832cc0b78184a714ade07d9a6d3173

View File

@ -0,0 +1,2 @@
1

View 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
View 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

View 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));

File diff suppressed because it is too large Load Diff

View 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
View 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

File diff suppressed because it is too large Load Diff

36
examples/block-images.js Normal file
View 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
View 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
View 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
View 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
View 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();
})();

View 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
View 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
View 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
View 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
View 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);
}

View 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);

View 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
View 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
View 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
View 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,
}
};

View 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
View 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
View 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
View 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
View 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
View 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
View 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,
}
};

View 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
View 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');
}
}

View 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);
}
}

View 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
View 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
View 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
View 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
View 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'
};

View 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;
}
}

View 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
View 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;
}
}

View 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
View 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
View 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
View 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,
};

View 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
View 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();
}
}

View 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

File diff suppressed because it is too large Load Diff

View 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);
}
}

View 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
View 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
View 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
View 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;
}
}

View 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
View 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);
}
}

View 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;
}
}

View 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
View 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};

View 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
View 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
View 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
View 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);
}
}

View 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
View 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
View 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
View 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
View 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);
}
});
}

View 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);
}
}

View 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
View 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
View 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 });
}
}

View 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