Merge branch 'master' into focus_boolean

This commit is contained in:
Joel Einbinder 2019-12-09 14:51:03 -08:00 committed by GitHub
commit 46aabf64ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
125 changed files with 2902 additions and 3278 deletions

View file

@ -9,6 +9,6 @@ node6-testrunner/*
lib/
*.js
src/generated/*
src/chromium/protocol.d.ts
src/firefox/protocol.d.ts
src/webkit/protocol.d.ts
src/chromium/protocol.ts
src/firefox/protocol.ts
src/webkit/protocol.ts

9
.gitignore vendored
View file

@ -4,7 +4,7 @@
/test/output-webkit
/test/test-user-data-dir*
/.local-chromium/
/.local-browser/
/.local-firefox/
/.local-webkit/
/.dev_profile*
.DS_Store
@ -15,10 +15,9 @@ package-lock.json
yarn.lock
/node6
/src/generated/*
/src/chromium/protocol.d.ts
/src/firefox/protocol.d.ts
/src/webkit/protocol.d.ts
/src/chromium/protocol.ts
/src/firefox/protocol.ts
/src/webkit/protocol.ts
/utils/browser/playwright-web.js
/index.d.ts
lib/
playwright-*.tgz

View file

@ -6,6 +6,9 @@
!lib/**/*.js
# Injected files are included via lib/generated, see src/injected/README.md
lib/injected/
#types
!lib/**/*.d.ts
!index.d.ts
# root for "playwright" package
!index.js

View file

@ -1,10 +1,20 @@
language: node_js
dist: trusty
dist: bionic
addons:
apt:
packages:
# This is required to run new chrome on old trusty
- libnss3
# - libnss3
# This is required to run webkit
- libwoff1
- libopus0
- libwebp6
- libwebpdemux2
- libenchant1c2a
- libgudev-1.0-0
- libsecret-1-0
- libhyphen0
- libgdk-pixbuf2.0-0
notifications:
email: false
cache:

View file

@ -63,6 +63,13 @@ fi
echo "-- preparing checkout"
./prepare_checkout.sh $BROWSER_NAME
cd ./$BROWSER_NAME/checkout
if ! [[ $(git rev-parse --abbrev-ref HEAD) == "playwright-build" ]]; then
echo "ERROR: Default branch is not playwright-build!"
exit 1
fi
cd -
echo "-- cleaning"
./$BROWSER_NAME/clean.sh

View file

@ -11,15 +11,16 @@ and develop from there.
From the `playwright` repo, run the following command:
```sh
$ ./browser_patches/prepare_checkout.sh firefox
$ ./browser_patches/prepare_checkout.sh firefox <path to checkout>
```
(you can optionally pass "webkit" for a webkit checkout)
If you don't have a checkout, don't pass a path and one will be created for you in `./browser_patches/firefox/checkout`
> **NOTE:** this command downloads GBs of data.
This command will:
- create a git browser checkout at `./browser_patches/firefox/checkout`
- create a `browser_upstream` remote in the checkout
- create a `playwright-build` branch and apply all playwright-required patches to it.
@ -41,7 +42,7 @@ Once you're happy with the work you did in the browser-land, you want to export
Assuming that you're in the root of the `playwright` repo and that your browser checkout has your feature branch checked out:
```sh
$ ./browser_patches/export.sh firefox
$ ./browser_patches/export.sh firefox <path to checkout>
```
This script will:
@ -49,6 +50,8 @@ This script will:
- update the `./browser_patches/firefox/UPSTREAM_CONFIG.sh` if necessary
- bump the `./browser_patches/firefox/BUILD_NUMBER` number.
If you omit the path to your checkout, the script will assume one is located at `./browser_patches/firefox/checkout`
Send a PR to the PlayWright repo to be reviewed.
## 4. Rolling PlayWright to the new browser build
@ -62,12 +65,3 @@ $ ./browser_patches/tools/check_cdn.sh
```
As the builds appear, you can roll to a new browser version in the `./package.json` file.
# FAQ
## Q: Can I reuse my other browser checkout?
Yes, you can. For this:
- pass path to your browser checkout as a second argument to `prepare_checkout.sh` script.
- pass path to your browser checkout as a second argument to `export.sh` when exporting changes.

View file

@ -74,8 +74,8 @@ cd $CHECKOUT_PATH
# Setting up |$REMOTE_BROWSER_UPSTREAM| remote and fetch the $BASE_BRANCH
if git remote get-url $REMOTE_BROWSER_UPSTREAM >/dev/null; then
if ! [[ $(git remote get-url $REMOTE_BROWSER_UPSTREAM) == "$REMOTE_URL" ]]; then
echo "ERROR: remote $REMOTE_BROWSER_UPSTREAM is not pointng to '$REMOTE_URL'! run `prepare_checkout.sh` first"
if ! [[ $(git config --get remote.$REMOTE_BROWSER_UPSTREAM.url || echo "") == "$REMOTE_URL" ]]; then
echo "ERROR: remote $REMOTE_BROWSER_UPSTREAM is not pointing to '$REMOTE_URL'! run `prepare_checkout.sh` first"
exit 1
fi
else

View file

@ -6,15 +6,6 @@ trap "cd $(pwd -P)" EXIT
cd "$(dirname $0)"
cd "checkout"
BUILD_BRANCH="playwright-build"
if ! [[ $(git rev-parse --abbrev-ref HEAD) == "$BUILD_BRANCH" ]]; then
echo "ERROR: Cannot build any branch other than $BUILD_BRANCH"
exit 1;
else
echo "-- checking git branch is $BUILD_BRANCH - 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.

View file

@ -1 +1 @@
1012
1021

View file

@ -1,3 +1,3 @@
REMOTE_URL="https://github.com/webkit/webkit"
BASE_BRANCH="master"
BASE_REVISION="031545c904ac108f0063861f58a3e4e2a299b0c0"
BASE_REVISION="131efe8ad014ffa190946fea083b8f96b16f6e89"

View file

@ -6,15 +6,6 @@ trap "cd $(pwd -P)" EXIT
cd "$(dirname $0)"
cd "checkout"
BUILD_BRANCH="playwright-build"
if ! [[ $(git rev-parse --abbrev-ref HEAD) == "$BUILD_BRANCH" ]]; then
echo "ERROR: Cannot build any branch other than $BUILD_BRANCH"
exit 1;
else
echo "-- checking git branch is $BUILD_BRANCH - OK"
fi
if [[ "$(uname)" == "Darwin" ]]; then
./Tools/Scripts/build-webkit --release
elif [[ "$(uname)" == "Linux" ]]; then

File diff suppressed because it is too large Load diff

View file

@ -1506,8 +1506,7 @@ Page is guaranteed to have a main frame which persists during navigations.
- `width` <[number]> width of clipping area
- `height` <[number]> height of clipping area
- `omitBackground` <[boolean]> Hides default white background and allows capturing screenshots with transparency. Defaults to `false`.
- `encoding` <[string]> The encoding of the image, can be either `base64` or `binary`. Defaults to `binary`.
- returns: <[Promise]<[string]|[Buffer]>> Promise which resolves to buffer or a base64 string (depending on the value of `encoding`) with captured screenshot.
- returns: <[Promise]<[Buffer]>> Promise which resolves to buffer with the captured screenshot.
> **NOTE** Screenshots take at least 1/6 second on OS X. See https://crbug.com/741689 for discussion.
@ -3456,8 +3455,12 @@ If `key` is a single character and no modifier keys besides `Shift` are being he
> **NOTE** Modifier keys DO effect `elementHandle.press`. Holding down `Shift` will type the text in upper case.
#### elementHandle.screenshot([options])
- `options` <[Object]> Same options as in [page.screenshot](#pagescreenshotoptions).
- returns: <[Promise]<[string]|[Buffer]>> Promise which resolves to buffer or a base64 string (depending on the value of `options.encoding`) with captured screenshot.
- `options` <[Object]> Screenshot options.
- `path` <[string]> The file path to save the image to. The screenshot type will be inferred from file extension. If `path` is a relative path, then it is resolved relative to [current working directory](https://nodejs.org/api/process.html#process_process_cwd). If no path is provided, the image won't be saved to the disk.
- `type` <"png"|"jpeg"> Specify screenshot type, defaults to 'png'.
- `quality` <[number]> The quality of the image, between 0-100. Not applicable to `png` images.
- `omitBackground` <[boolean]> Hides default white background and allows capturing screenshots with transparency. Defaults to `false`.
- returns: <[Promise]<|[Buffer]>> Promise which resolves to buffer with the captured screenshot.
This method scrolls element into view if needed, and then uses [page.screenshot](#pagescreenshotoptions) to take a screenshot of the element.
If the element is detached from DOM, the method throws an error.

9
index.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import * as chromium from './chromium';
import * as firefox from './firefox';
import * as webkit from './webkit';
declare function pickBrowser(browser: 'chromium'): typeof chromium;
declare function pickBrowser(browser: 'firefox'): typeof firefox;
declare function pickBrowser(browser: 'webkit'): typeof webkit;
export = pickBrowser;

View file

@ -10,7 +10,7 @@
"playwright": {
"chromium_revision": "719491",
"firefox_revision": "1004",
"webkit_revision": "1011"
"webkit_revision": "1016"
},
"scripts": {
"unit": "node test/test.js",
@ -18,7 +18,7 @@
"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",
"test": "npm run lint --silent && npm run coverage && npm run test-doclint && node utils/testrunner/test/test.js",
"prepare": "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",
@ -28,7 +28,6 @@
"watch": "node utils/runWebpack.js --mode='development' --watch --silent | 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": {
@ -39,7 +38,9 @@
"debug": "^4.1.0",
"extract-zip": "^1.6.6",
"https-proxy-agent": "^3.0.0",
"jpeg-js": "^0.3.6",
"mime": "^2.0.3",
"pngjs": "^3.4.0",
"proxy-from-env": "^1.0.0",
"rimraf": "^2.6.1",
"ws": "^6.1.0"
@ -47,8 +48,10 @@
"devDependencies": {
"@types/debug": "0.0.31",
"@types/extract-zip": "^1.6.2",
"@types/jpeg-js": "^0.3.7",
"@types/mime": "^2.0.0",
"@types/node": "^8.10.34",
"@types/pngjs": "^3.4.0",
"@types/rimraf": "^2.0.2",
"@types/ws": "^6.0.1",
"@typescript-eslint/eslint-plugin": "^2.6.1",
@ -57,12 +60,10 @@
"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",
"node-stream-zip": "^1.8.2",
"pixelmatch": "^4.0.2",
"pngjs": "^3.3.3",
"progress": "^2.0.1",
"text-diff": "^1.0.1",
"ts-loader": "^6.1.2",

View file

@ -18,36 +18,12 @@
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';
import {execSync} from 'child_process';
const DEFAULT_DOWNLOAD_HOST = 'https://playwrightaccount.blob.core.windows.net';
const supportedPlatforms = ['linux', 'mac'];
const downloadURLs = {
linux: '%s/builds/webkit/%s/minibrowser-linux.zip',
mac: '%s/builds/webkit/%s/minibrowser-mac-%s.zip',
};
let cachedMacVersion = undefined;
function getMacVersion() {
if (!cachedMacVersion) {
const [major, minor] = execSync('sw_vers -productVersion').toString('utf8').trim().split('.');
cachedMacVersion = major + '.' + minor;
}
return cachedMacVersion;
}
function downloadURL(platform: string, host: string, revision: string): string {
if (platform === 'mac')
return util.format(downloadURLs['mac'], host, revision, getMacVersion());
return util.format(downloadURLs[platform], host, revision);
}
import { assert, helper } from './helper';
const readdirAsync = helper.promisify(fs.readdir.bind(fs));
const mkdirAsync = helper.promisify(fs.mkdir.bind(fs));
@ -61,34 +37,23 @@ function existsAsync(filePath) {
return promise;
}
type ParamsGetter = (platform: string, revision: string) => { downloadUrl: string, executablePath: string };
export type OnProgressCallback = (downloadedBytes: number, totalBytes: number) => void;
export class BrowserFetcher {
private _downloadsFolder: string;
private _downloadHost: string;
private _platform: string;
private _params: ParamsGetter;
constructor(projectRoot: string, options: BrowserFetcherOptions = {}) {
this._downloadsFolder = options.path || path.join(projectRoot, '.local-webkit');
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 = 'linux'; // Windows gets linux binaries and uses WSL
assert(this._platform, 'Unsupported platform: ' + os.platform());
}
assert(supportedPlatforms.includes(this._platform), 'Unsupported platform: ' + this._platform);
}
platform(): string {
return this._platform;
constructor(downloadsFolder: string, platform: string, params: ParamsGetter) {
this._downloadsFolder = downloadsFolder;
this._platform = platform;
this._params = params;
}
canDownload(revision: string): Promise<boolean> {
const url = downloadURL(this._platform, this._downloadHost, revision);
const url = this._params(this._platform, revision).downloadUrl;
let resolve;
const promise = new Promise<boolean>(x => resolve = x);
const request = httpRequest(url, 'HEAD', response => {
@ -100,8 +65,9 @@ export class BrowserFetcher {
});
return promise;
}
async download(revision: string, progressCallback: ((arg0: number, arg1: number) => void) | null): Promise<BrowserFetcherRevisionInfo> {
const url = downloadURL(this._platform, this._downloadHost, revision);
async download(revision: string, progressCallback: OnProgressCallback | null): Promise<BrowserFetcherRevisionInfo> {
const url = this._params(this._platform, revision).downloadUrl;
const zipPath = path.join(this._downloadsFolder, `download-${this._platform}-${revision}.zip`);
const folderPath = this._getFolderPath(revision);
if (await existsAsync(folderPath))
@ -136,14 +102,9 @@ export class BrowserFetcher {
revisionInfo(revision: string): BrowserFetcherRevisionInfo {
const folderPath = this._getFolderPath(revision);
let executablePath = '';
if (this._platform === 'linux' || this._platform === 'mac')
executablePath = path.join(folderPath, 'pw_run.sh');
else
throw new Error('Unsupported platform: ' + this._platform);
const url = downloadURL(this._platform, this._downloadHost, revision);
const params = this._params(this._platform, revision);
const local = fs.existsSync(folderPath);
return {revision, executablePath, folderPath, local, url};
return {revision, executablePath: path.join(folderPath, params.executablePath), folderPath, local, url: params.downloadUrl};
}
_getFolderPath(revision: string): string {
@ -157,12 +118,10 @@ function parseFolderPath(folderPath: string): { platform: string; revision: stri
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> {
function downloadFile(url: string, destinationPath: string, progressCallback: OnProgressCallback | null): Promise<any> {
let fulfill, reject;
let downloadedBytes = 0;
let totalBytes = 0;
@ -244,7 +203,7 @@ export type BrowserFetcherOptions = {
host ?: string,
};
type BrowserFetcherRevisionInfo = {
export type BrowserFetcherRevisionInfo = {
folderPath: string,
executablePath: string,
url: string,

View file

@ -21,17 +21,17 @@ import { Events } from './events';
import { assert, helper } from '../helper';
import { BrowserContext } from './BrowserContext';
import { Connection, ConnectionEvents, CDPSession } from './Connection';
import { Page, Viewport } from './Page';
import { Page } from '../page';
import { Target } from './Target';
import { Protocol } from './protocol';
import { Chromium } from './features/chromium';
import { Screenshotter } from './Screenshotter';
import * as types from '../types';
import { FrameManager } from './FrameManager';
export class Browser extends EventEmitter {
private _ignoreHTTPSErrors: boolean;
private _defaultViewport: Viewport;
private _defaultViewport: types.Viewport;
private _process: childProcess.ChildProcess;
private _screenshotter = new Screenshotter();
_connection: Connection;
_client: CDPSession;
private _closeCallback: () => Promise<void>;
@ -44,7 +44,7 @@ export class Browser extends EventEmitter {
connection: Connection,
contextIds: string[],
ignoreHTTPSErrors: boolean,
defaultViewport: Viewport | null,
defaultViewport: types.Viewport | null,
process: childProcess.ChildProcess | null,
closeCallback?: (() => Promise<void>)) {
const browser = new Browser(connection, contextIds, ignoreHTTPSErrors, defaultViewport, process, closeCallback);
@ -56,7 +56,7 @@ export class Browser extends EventEmitter {
connection: Connection,
contextIds: string[],
ignoreHTTPSErrors: boolean,
defaultViewport: Viewport | null,
defaultViewport: types.Viewport | null,
process: childProcess.ChildProcess | null,
closeCallback?: (() => Promise<void>)) {
super();
@ -107,7 +107,7 @@ export class Browser extends EventEmitter {
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._screenshotter);
const target = new Target(targetInfo, context, () => this._connection.createSession(targetInfo), this._ignoreHTTPSErrors, this._defaultViewport);
assert(!this._targets.has(event.targetInfo.targetId), 'Target should not exist before targetCreated');
this._targets.set(event.targetInfo.targetId, target);
@ -119,7 +119,7 @@ export class Browser extends EventEmitter {
const target = this._targets.get(event.targetId);
target._initializedCallback(false);
this._targets.delete(event.targetId);
target._closedCallback();
target._didClose();
if (await target._initializedPromise)
this.chromium.emit(Events.Chromium.TargetDestroyed, target);
}
@ -134,11 +134,11 @@ export class Browser extends EventEmitter {
this.chromium.emit(Events.Chromium.TargetChanged, target);
}
async newPage(): Promise<Page> {
async newPage(): Promise<Page<Browser, BrowserContext>> {
return this._defaultContext.newPage();
}
async _createPageInContext(contextId: string | null): Promise<Page> {
async _createPageInContext(contextId: string | null): Promise<Page<Browser, BrowserContext>> {
const { targetId } = await this._client.send('Target.createTarget', { url: 'about:blank', browserContextId: contextId || undefined });
const target = this._targets.get(targetId);
assert(await target._initializedPromise, 'Failed to create target for page');
@ -146,14 +146,24 @@ export class Browser extends EventEmitter {
return page;
}
async _closeTarget(target: Target) {
await this._client.send('Target.closeTarget', { targetId: target._targetId });
async _closePage(page: Page<Browser, BrowserContext>) {
await this._client.send('Target.closeTarget', { targetId: Target.fromPage(page)._targetId });
}
_allTargets(): Target[] {
return Array.from(this._targets.values()).filter(target => target._isInitialized);
}
async _pages(context: BrowserContext): Promise<Page<Browser, BrowserContext>[]> {
const targets = this._allTargets().filter(target => target.browserContext() === context && target.type() === 'page');
const pages = await Promise.all(targets.map(target => target.page()));
return pages.filter(page => !!page);
}
async _activatePage(page: Page<Browser, BrowserContext>) {
await (page._delegate as FrameManager)._client.send('Target.activateTarget', {targetId: Target.fromPage(page)._targetId});
}
async _waitForTarget(predicate: (arg0: Target) => boolean, options: { timeout?: number; } | undefined = {}): Promise<Target> {
const {
timeout = 30000
@ -180,7 +190,7 @@ export class Browser extends EventEmitter {
}
}
async pages(): Promise<Page[]> {
async pages(): Promise<Page<Browser, BrowserContext>[]> {
const contextPages = await Promise.all(this.browserContexts().map(context => context.pages()));
// Flatten array.
return contextPages.reduce((acc, x) => acc.concat(x), []);

View file

@ -20,8 +20,7 @@ import { filterCookies, NetworkCookie, rewriteCookies, SetNetworkCookieParam } f
import { Browser } from './Browser';
import { CDPSession } from './Connection';
import { Permissions } from './features/permissions';
import { Page } from './Page';
import { Target } from './Target';
import { Page } from '../page';
export class BrowserContext {
readonly permissions: Permissions;
@ -35,24 +34,15 @@ export class BrowserContext {
this.permissions = new Permissions(client, contextId);
}
_targets(): Target[] {
return this._browser._allTargets().filter(target => target.browserContext() === this);
}
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);
pages(): Promise<Page<Browser, BrowserContext>[]> {
return this._browser._pages(this);
}
isIncognito(): boolean {
return !!this._id;
}
newPage(): Promise<Page> {
newPage(): Promise<Page<Browser, BrowserContext>> {
return this._browser._createPageInContext(this._id);
}

View file

@ -1,261 +0,0 @@
/**
* 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,
};

View file

@ -1,51 +0,0 @@
/**
* 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

@ -149,7 +149,7 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
await releaseObject(this._client, toRemoteObject(handle));
}
async handleJSONValue(handle: js.JSHandle): Promise<any> {
async handleJSONValue<T>(handle: js.JSHandle<T>): Promise<T> {
const remoteObject = toRemoteObject(handle);
if (remoteObject.objectId) {
const response = await this._client.send('Runtime.callFunctionOn', {

View file

@ -21,14 +21,30 @@ import * as frames from '../frames';
import { assert, debugError } from '../helper';
import * as js from '../javascript';
import * as network from '../network';
import { TimeoutSettings } from '../TimeoutSettings';
import { CDPSession } from './Connection';
import { EVALUATION_SCRIPT_URL, ExecutionContextDelegate } from './ExecutionContext';
import { DOMWorldDelegate } from './JSHandle';
import { LifecycleWatcher } from './LifecycleWatcher';
import { NetworkManager } from './NetworkManager';
import { Page } from './Page';
import { NetworkManager, NetworkManagerEvents } from './NetworkManager';
import { Page } from '../page';
import { Protocol } from './protocol';
import { Events as CommonEvents } from '../events';
import { toConsoleMessageLocation, exceptionToError, releaseObject } from './protocolHelper';
import * as dialog from '../dialog';
import * as console from '../console';
import { PageDelegate } from '../page';
import { RawMouseImpl, RawKeyboardImpl } from './Input';
import { CRScreenshotDelegate } from './Screenshotter';
import { Accessibility } from './features/accessibility';
import { Coverage } from './features/coverage';
import { PDF } from './features/pdf';
import { Workers } from './features/workers';
import { Overrides } from './features/overrides';
import { Interception } from './features/interception';
import { Browser } from './Browser';
import { BrowserContext } from './BrowserContext';
import * as types from '../types';
import * as input from '../input';
const UTILITY_WORLD_NAME = '__playwright_utility_world__';
@ -47,32 +63,56 @@ type FrameData = {
lifecycleEvents: Set<string>,
};
export class FrameManager extends EventEmitter implements frames.FrameDelegate {
export class FrameManager extends EventEmitter implements frames.FrameDelegate, PageDelegate {
_client: CDPSession;
private _page: Page;
private _page: Page<Browser, BrowserContext>;
private _networkManager: NetworkManager;
_timeoutSettings: TimeoutSettings;
private _frames = new Map<string, frames.Frame>();
private _contextIdToContext = new Map<number, js.ExecutionContext>();
private _isolatedWorlds = new Set<string>();
private _mainFrame: frames.Frame;
rawMouse: RawMouseImpl;
rawKeyboard: RawKeyboardImpl;
screenshotterDelegate: CRScreenshotDelegate;
constructor(client: CDPSession, page: Page, ignoreHTTPSErrors: boolean, timeoutSettings: TimeoutSettings) {
constructor(client: CDPSession, browserContext: BrowserContext, ignoreHTTPSErrors: boolean) {
super();
this._client = client;
this._page = page;
this.rawKeyboard = new RawKeyboardImpl(client);
this.rawMouse = new RawMouseImpl(client);
this.screenshotterDelegate = new CRScreenshotDelegate(client);
this._networkManager = new NetworkManager(client, ignoreHTTPSErrors, this);
this._timeoutSettings = timeoutSettings;
this._page = new Page(this, browserContext, ignoreHTTPSErrors);
(this._page as any).accessibility = new Accessibility(client);
(this._page as any).coverage = new Coverage(client);
(this._page as any).pdf = new PDF(client);
(this._page as any).workers = new Workers(client, this._page._addConsoleMessage.bind(this._page), error => this._page.emit(CommonEvents.Page.PageError, error));
(this._page as any).overrides = new Overrides(client);
(this._page as any).interception = new Interception(this._networkManager);
this._networkManager.on(NetworkManagerEvents.Request, event => this._page.emit(CommonEvents.Page.Request, event));
this._networkManager.on(NetworkManagerEvents.Response, event => this._page.emit(CommonEvents.Page.Response, event));
this._networkManager.on(NetworkManagerEvents.RequestFailed, event => this._page.emit(CommonEvents.Page.RequestFailed, event));
this._networkManager.on(NetworkManagerEvents.RequestFinished, event => this._page.emit(CommonEvents.Page.RequestFinished, event));
this._client.on('Inspector.targetCrashed', event => this._onTargetCrashed());
this._client.on('Log.entryAdded', event => this._onLogEntryAdded(event));
this._client.on('Page.domContentEventFired', event => this._page.emit(CommonEvents.Page.DOMContentLoaded));
this._client.on('Page.fileChooserOpened', event => this._onFileChooserOpened(event));
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.frameNavigated', event => this._onFrameNavigated(event.frame));
this._client.on('Page.frameStoppedLoading', event => this._onFrameStoppedLoading(event.frameId));
this._client.on('Page.javascriptDialogOpening', event => this._onDialog(event));
this._client.on('Page.lifecycleEvent', event => this._onLifecycleEvent(event));
this._client.on('Page.loadEventFired', event => this._page.emit(CommonEvents.Page.Load));
this._client.on('Page.navigatedWithinDocument', event => this._onFrameNavigatedWithinDocument(event.frameId, event.url));
this._client.on('Runtime.bindingCalled', event => this._onBindingCalled(event));
this._client.on('Runtime.consoleAPICalled', event => this._onConsoleAPI(event));
this._client.on('Runtime.exceptionThrown', exception => this._handleException(exception.exceptionDetails));
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() {
@ -82,6 +122,8 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
]);
this._handleFrameTree(frameTree);
await Promise.all([
this._client.send('Log.enable', {}),
this._client.send('Page.setInterceptFileChooserDialog', {enabled: true}),
this._client.send('Page.setLifecycleEventsEnabled', { enabled: true }),
this._client.send('Runtime.enable', {}).then(() => this._ensureIsolatedWorld(UTILITY_WORLD_NAME)),
this._networkManager.initialize(),
@ -104,7 +146,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
const {
referer = this._networkManager.extraHTTPHeaders()['referer'],
waitUntil = ['load'],
timeout = this._timeoutSettings.navigationTimeout(),
timeout = this._page._timeoutSettings.navigationTimeout(),
} = options;
const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout);
@ -142,7 +184,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
assertNoLegacyNavigationOptions(options);
const {
waitUntil = ['load'],
timeout = this._timeoutSettings.navigationTimeout(),
timeout = this._page._timeoutSettings.navigationTimeout(),
} = options;
const watcher = new LifecycleWatcher(this, frame, waitUntil, timeout);
const error = await Promise.race([
@ -159,7 +201,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
async setFrameContent(frame: frames.Frame, html: string, options: frames.NavigateOptions = {}) {
const {
waitUntil = ['load'],
timeout = this._timeoutSettings.navigationTimeout(),
timeout = this._page._timeoutSettings.navigationTimeout(),
} = options;
const context = await frame._utilityContext();
// We rely upon the fact that document.open() will reset frame lifecycle with "init"
@ -213,7 +255,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
this._handleFrameTree(child);
}
page(): Page {
page(): Page<Browser, BrowserContext> {
return this._page;
}
@ -234,7 +276,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
return;
assert(parentFrameId);
const parentFrame = this._frames.get(parentFrameId);
const frame = new frames.Frame(this, this._timeoutSettings, parentFrame);
const frame = new frames.Frame(this, this._page._timeoutSettings, parentFrame);
const data: FrameData = {
id: frameId,
loaderId: '',
@ -243,6 +285,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
frame[frameDataSymbol] = data;
this._frames.set(frameId, frame);
this.emit(FrameManagerEvents.FrameAttached, frame);
this._page.emit(CommonEvents.Page.FrameAttached, frame);
}
_onFrameNavigated(framePayload: Protocol.Page.Frame) {
@ -265,7 +308,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
data.id = framePayload.id;
} else {
// Initial main frame navigation.
frame = new frames.Frame(this, this._timeoutSettings, null);
frame = new frames.Frame(this, this._page._timeoutSettings, null);
const data: FrameData = {
id: framePayload.id,
loaderId: '',
@ -281,6 +324,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
frame._navigated(framePayload.url, framePayload.name);
this.emit(FrameManagerEvents.FrameNavigated, frame);
this._page.emit(CommonEvents.Page.FrameNavigated, frame);
}
async _ensureIsolatedWorld(name: string) {
@ -305,6 +349,7 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
frame._navigated(url, frame.name());
this.emit(FrameManagerEvents.FrameNavigatedWithinDocument, frame);
this.emit(FrameManagerEvents.FrameNavigated, frame);
this._page.emit(CommonEvents.Page.FrameNavigated, frame);
}
_onFrameDetached(frameId: string) {
@ -356,6 +401,155 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
frame._detach();
this._frames.delete(this._frameData(frame).id);
this.emit(FrameManagerEvents.FrameDetached, frame);
this._page.emit(CommonEvents.Page.FrameDetached, frame);
}
async _onConsoleAPI(event: Protocol.Runtime.consoleAPICalledPayload) {
if (event.executionContextId === 0) {
// DevTools protocol stores the last 1000 console messages. These
// messages are always reported even for removed execution contexts. In
// this case, they are marked with executionContextId = 0 and are
// reported upon enabling Runtime agent.
//
// Ignore these messages since:
// - there's no execution context we can use to operate with message
// arguments
// - these messages are reported before Playwright clients can subscribe
// to the 'console'
// page event.
//
// @see https://github.com/GoogleChrome/puppeteer/issues/3865
return;
}
const context = this.executionContextById(event.executionContextId);
const values = event.args.map(arg => context._createHandle(arg));
this._page._addConsoleMessage(event.type, values, toConsoleMessageLocation(event.stackTrace));
}
async exposeBinding(name: string, bindingFunction: string) {
await this._client.send('Runtime.addBinding', {name: name});
await this._client.send('Page.addScriptToEvaluateOnNewDocument', {source: bindingFunction});
await Promise.all(this.frames().map(frame => frame.evaluate(bindingFunction).catch(debugError)));
}
_onBindingCalled(event: Protocol.Runtime.bindingCalledPayload) {
const context = this.executionContextById(event.executionContextId);
this._page._onBindingCalled(event.payload, context);
}
_onDialog(event : Protocol.Page.javascriptDialogOpeningPayload) {
this._page.emit(CommonEvents.Page.Dialog, new dialog.Dialog(
event.type as dialog.DialogType,
event.message,
async (accept: boolean, promptText?: string) => {
await this._client.send('Page.handleJavaScriptDialog', { accept, promptText });
},
event.defaultPrompt));
}
_handleException(exceptionDetails: Protocol.Runtime.ExceptionDetails) {
this._page.emit(CommonEvents.Page.PageError, exceptionToError(exceptionDetails));
}
_onTargetCrashed() {
this._page.emit('error', new Error('Page crashed!'));
}
_onLogEntryAdded(event: Protocol.Log.entryAddedPayload) {
const {level, text, args, source, url, lineNumber} = event.entry;
if (args)
args.map(arg => releaseObject(this._client, arg));
if (source !== 'worker')
this._page.emit(CommonEvents.Page.Console, new console.ConsoleMessage(level, text, [], {url, lineNumber}));
}
async _onFileChooserOpened(event: Protocol.Page.fileChooserOpenedPayload) {
const frame = this.frame(event.frameId);
const utilityWorld = await frame._utilityDOMWorld();
const handle = await (utilityWorld.delegate as DOMWorldDelegate).adoptBackendNodeId(event.backendNodeId, utilityWorld);
this._page._onFileChooserOpened(handle);
}
setExtraHTTPHeaders(extraHTTPHeaders: network.Headers): Promise<void> {
return this._networkManager.setExtraHTTPHeaders(extraHTTPHeaders);
}
setUserAgent(userAgent: string): Promise<void> {
return this._networkManager.setUserAgent(userAgent);
}
async setJavaScriptEnabled(enabled: boolean): Promise<void> {
await this._client.send('Emulation.setScriptExecutionDisabled', { value: !enabled });
}
async setBypassCSP(enabled: boolean): Promise<void> {
await this._client.send('Page.setBypassCSP', { enabled });
}
async setViewport(viewport: types.Viewport): Promise<void> {
const {
width,
height,
isMobile = false,
deviceScaleFactor = 1,
hasTouch = false,
isLandscape = false,
} = viewport;
const screenOrientation: Protocol.Emulation.ScreenOrientation = isLandscape ? { angle: 90, type: 'landscapePrimary' } : { angle: 0, type: 'portraitPrimary' };
await Promise.all([
this._client.send('Emulation.setDeviceMetricsOverride', { mobile: isMobile, width, height, deviceScaleFactor, screenOrientation }),
this._client.send('Emulation.setTouchEmulationEnabled', {
enabled: hasTouch
})
]);
}
async setEmulateMedia(mediaType: input.MediaType | null, mediaColorScheme: input.MediaColorScheme | null): Promise<void> {
const features = mediaColorScheme ? [{ name: 'prefers-color-scheme', value: mediaColorScheme }] : [];
await this._client.send('Emulation.setEmulatedMedia', { media: mediaType || '', features });
}
setCacheEnabled(enabled: boolean): Promise<void> {
return this._networkManager.setCacheEnabled(enabled);
}
async reload(options?: frames.NavigateOptions): Promise<network.Response | null> {
const [response] = await Promise.all([
this._page.waitForNavigation(options),
this._client.send('Page.reload')
]);
return response;
}
private async _go(delta: number, options?: frames.NavigateOptions): Promise<network.Response | null> {
const history = await this._client.send('Page.getNavigationHistory');
const entry = history.entries[history.currentIndex + delta];
if (!entry)
return null;
const [response] = await Promise.all([
this._page.waitForNavigation(options),
this._client.send('Page.navigateToHistoryEntry', {entryId: entry.id}),
]);
return response;
}
goBack(options?: frames.NavigateOptions): Promise<network.Response | null> {
return this._go(-1, options);
}
goForward(options?: frames.NavigateOptions): Promise<network.Response | null> {
return this._go(+1, options);
}
async evaluateOnNewDocument(source: string): Promise<void> {
await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source });
}
async closePage(runBeforeUnload: boolean): Promise<void> {
if (runBeforeUnload)
await this._client.send('Page.close');
else
await this._page.browser()._closePage(this._page);
}
}

View file

@ -23,7 +23,6 @@ import * as frames from '../frames';
import { CDPSession } from './Connection';
import { FrameManager } from './FrameManager';
import { Protocol } from './protocol';
import { ScreenshotOptions } from './Screenshotter';
import { ExecutionContextDelegate } from './ExecutionContext';
export class DOMWorldDelegate implements dom.DOMWorldDelegate {
@ -51,7 +50,7 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate {
}
isJavascriptEnabled(): boolean {
return this._frameManager.page()._javascriptEnabled;
return this._frameManager.page()._state.javascriptEnabled;
}
isElement(remoteObject: any): boolean {
@ -91,20 +90,20 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate {
return { width: layoutMetrics.layoutViewport.clientWidth, height: layoutMetrics.layoutViewport.clientHeight };
}
screenshot(handle: dom.ElementHandle, options: ScreenshotOptions = {}): Promise<string | Buffer> {
screenshot(handle: dom.ElementHandle, options?: types.ElementScreenshotOptions): Promise<Buffer> {
const page = this._frameManager.page();
return page._screenshotter.screenshotElement(page, handle, options);
return page._screenshotter.screenshotElement(handle, options);
}
async setInputFiles(handle: dom.ElementHandle, files: input.FilePayload[]): Promise<void> {
await handle.evaluate(input.setFileInputFunction, files);
}
async adoptElementHandle(handle: dom.ElementHandle, to: dom.DOMWorld): Promise<dom.ElementHandle> {
async adoptElementHandle<T extends Node>(handle: dom.ElementHandle<T>, to: dom.DOMWorld): Promise<dom.ElementHandle<T>> {
const nodeInfo = await this._client.send('DOM.describeNode', {
objectId: toRemoteObject(handle).objectId,
});
return this.adoptBackendNodeId(nodeInfo.node.backendNodeId, to);
return this.adoptBackendNodeId(nodeInfo.node.backendNodeId, to) as Promise<dom.ElementHandle<T>>;
}
async adoptBackendNodeId(backendNodeId: Protocol.DOM.BackendNodeId, to: dom.DOMWorld): Promise<dom.ElementHandle> {

View file

@ -24,14 +24,15 @@ import * as readline from 'readline';
import * as removeFolder from 'rimraf';
import * as URL from 'url';
import { Browser } from './Browser';
import { BrowserFetcher } from './BrowserFetcher';
import { BrowserFetcher, BrowserFetcherOptions } from '../browserFetcher';
import { Connection } from './Connection';
import { TimeoutError } from '../Errors';
import { assert, debugError, helper } from '../helper';
import { Viewport } from './Page';
import * as types from '../types';
import { PipeTransport } from './PipeTransport';
import { WebSocketTransport } from './WebSocketTransport';
import { ConnectionTransport } from '../ConnectionTransport';
import * as util from 'util';
const mkdtempAsync = helper.promisify(fs.mkdtemp);
const removeFolderAsync = helper.promisify(removeFolder);
@ -289,7 +290,7 @@ export class Launcher {
}
_resolveExecutablePath(): { executablePath: string; missingText: string | null; } {
const browserFetcher = new BrowserFetcher(this._projectRoot);
const browserFetcher = createBrowserFetcher(this._projectRoot);
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};
@ -392,6 +393,55 @@ export type LauncherLaunchOptions = {
export type LauncherBrowserOptions = {
ignoreHTTPSErrors?: boolean,
defaultViewport?: Viewport | null,
defaultViewport?: types.Viewport | null,
slowMo?: number,
};
export function createBrowserFetcher(projectRoot: string, options: BrowserFetcherOptions = {}): BrowserFetcher {
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',
};
const defaultOptions = {
path: path.join(projectRoot, '.local-chromium'),
host: 'https://storage.googleapis.com',
platform: (() => {
const platform = os.platform();
if (platform === 'darwin')
return 'mac';
if (platform === 'linux')
return 'linux';
if (platform === 'win32')
return os.arch() === 'x64' ? 'win64' : 'win32';
return platform;
})()
};
options = {
...defaultOptions,
...options,
};
assert(!!downloadURLs[options.platform], 'Unsupported platform: ' + options.platform);
return new BrowserFetcher(options.path, options.platform, (platform: string, revision: string) => {
let archiveName = '';
let executablePath = '';
if (platform === 'linux') {
archiveName = 'chrome-linux';
executablePath = path.join(archiveName, 'chrome');
} else if (platform === 'mac') {
archiveName = 'chrome-mac';
executablePath = path.join(archiveName, 'Chromium.app', 'Contents', 'MacOS', 'Chromium');
} else if (platform === 'win32' || platform === 'win64') {
// Windows archive name changed at r591479.
archiveName = parseInt(revision, 10) > 591479 ? 'chrome-win' : 'chrome-win32';
executablePath = path.join(archiveName, 'chrome.exe');
}
return {
downloadUrl: util.format(downloadURLs[platform], options.host, revision, archiveName),
executablePath
};
});
}

View file

@ -1,591 +0,0 @@
/**
* 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, helper } from '../helper';
import { ClickOptions, MultiClickOptions, PointerActionOptions, SelectOption, mediaTypes, mediaColorSchemes } from '../input';
import { TimeoutSettings } from '../TimeoutSettings';
import { Browser } from './Browser';
import { BrowserContext } from './BrowserContext';
import { CDPSession, CDPSessionEvents } from './Connection';
import { EmulationManager } from './EmulationManager';
import { Events } from './events';
import { Accessibility } from './features/accessibility';
import { Coverage } from './features/coverage';
import { Overrides } from './features/overrides';
import { Interception } from './features/interception';
import { PDF } from './features/pdf';
import { Workers } from './features/workers';
import { FrameManager, FrameManagerEvents } from './FrameManager';
import { RawMouseImpl, RawKeyboardImpl } from './Input';
import { NetworkManagerEvents } from './NetworkManager';
import { Protocol } from './protocol';
import { getExceptionMessage, releaseObject } from './protocolHelper';
import { Target } from './Target';
import * as input from '../input';
import * as types from '../types';
import * as frames from '../frames';
import * as js from '../javascript';
import * as dom from '../dom';
import * as network from '../network';
import * as dialog from '../dialog';
import * as console from '../console';
import { DOMWorldDelegate } from './JSHandle';
import { Screenshotter, ScreenshotOptions } from './Screenshotter';
export type Viewport = {
width: number;
height: number;
deviceScaleFactor?: number;
isMobile?: boolean;
isLandscape?: boolean;
hasTouch?: boolean;
}
export class Page extends EventEmitter {
private _closed = false;
_client: CDPSession;
_target: Target;
private _keyboard: input.Keyboard;
private _mouse: input.Mouse;
private _timeoutSettings: TimeoutSettings;
private _frameManager: FrameManager;
private _emulationManager: EmulationManager;
readonly accessibility: Accessibility;
readonly coverage: Coverage;
readonly overrides: Overrides;
readonly interception: Interception;
readonly pdf: PDF;
readonly workers: Workers;
private _pageBindings = new Map<string, Function>();
_javascriptEnabled = true;
private _viewport: Viewport | null = null;
_screenshotter: Screenshotter;
private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>();
private _disconnectPromise: Promise<Error> | undefined;
private _emulatedMediaType: string | undefined;
static async create(client: CDPSession, target: Target, ignoreHTTPSErrors: boolean, defaultViewport: Viewport | null, screenshotter: Screenshotter): Promise<Page> {
const page = new Page(client, target, ignoreHTTPSErrors, screenshotter);
await page._initialize();
if (defaultViewport)
await page.setViewport(defaultViewport);
return page;
}
constructor(client: CDPSession, target: Target, ignoreHTTPSErrors: boolean, screenshotter: Screenshotter) {
super();
this._client = client;
this._target = target;
this._keyboard = new input.Keyboard(new RawKeyboardImpl(client));
this._mouse = new input.Mouse(new RawMouseImpl(client), this._keyboard);
this._timeoutSettings = new TimeoutSettings();
this.accessibility = new Accessibility(client);
this._frameManager = new FrameManager(client, this, ignoreHTTPSErrors, this._timeoutSettings);
this._emulationManager = new EmulationManager(client);
this.coverage = new Coverage(client);
this.pdf = new PDF(client);
this.workers = new Workers(client, this._addConsoleMessage.bind(this), this._handleException.bind(this));
this.overrides = new Overrides(client);
this.interception = new Interception(this._frameManager.networkManager());
this._screenshotter = screenshotter;
client.on('Target.attachedToTarget', event => {
if (event.targetInfo.type !== 'worker') {
// If we don't detach from service workers, they will never die.
client.send('Target.detachFromTarget', {
sessionId: event.sessionId
}).catch(debugError);
return;
}
});
this._frameManager.on(FrameManagerEvents.FrameAttached, event => this.emit(Events.Page.FrameAttached, event));
this._frameManager.on(FrameManagerEvents.FrameDetached, event => this.emit(Events.Page.FrameDetached, event));
this._frameManager.on(FrameManagerEvents.FrameNavigated, event => this.emit(Events.Page.FrameNavigated, event));
const networkManager = this._frameManager.networkManager();
networkManager.on(NetworkManagerEvents.Request, event => this.emit(Events.Page.Request, event));
networkManager.on(NetworkManagerEvents.Response, event => this.emit(Events.Page.Response, event));
networkManager.on(NetworkManagerEvents.RequestFailed, event => this.emit(Events.Page.RequestFailed, event));
networkManager.on(NetworkManagerEvents.RequestFinished, event => this.emit(Events.Page.RequestFinished, event));
client.on('Page.domContentEventFired', event => this.emit(Events.Page.DOMContentLoaded));
client.on('Page.loadEventFired', event => this.emit(Events.Page.Load));
client.on('Runtime.consoleAPICalled', event => this._onConsoleAPI(event));
client.on('Runtime.bindingCalled', event => this._onBindingCalled(event));
client.on('Page.javascriptDialogOpening', event => this._onDialog(event));
client.on('Runtime.exceptionThrown', exception => this._handleException(exception.exceptionDetails));
client.on('Inspector.targetCrashed', event => this._onTargetCrashed());
client.on('Log.entryAdded', event => this._onLogEntryAdded(event));
client.on('Page.fileChooserOpened', event => this._onFileChooserOpened(event));
this._target._isClosedPromise.then(() => {
this.emit(Events.Page.Close);
this._closed = true;
});
}
async _initialize() {
await Promise.all([
this._frameManager.initialize(),
this._client.send('Target.setAutoAttach', {autoAttach: true, waitForDebuggerOnStart: false, flatten: true}),
this._client.send('Performance.enable', {}),
this._client.send('Log.enable', {}),
this._client.send('Page.setInterceptFileChooserDialog', {enabled: true})
]);
}
async _onFileChooserOpened(event: Protocol.Page.fileChooserOpenedPayload) {
if (!this._fileChooserInterceptors.size)
return;
const frame = this._frameManager.frame(event.frameId);
const utilityWorld = await frame._utilityDOMWorld();
const handle = await (utilityWorld.delegate as DOMWorldDelegate).adoptBackendNodeId(event.backendNodeId, utilityWorld);
const interceptors = Array.from(this._fileChooserInterceptors);
this._fileChooserInterceptors.clear();
const multiple = await handle.evaluate((element: HTMLInputElement) => !!element.multiple);
const fileChooser = { element: handle, multiple };
for (const interceptor of interceptors)
interceptor.call(null, fileChooser);
this.emit(Events.Page.FileChooser, fileChooser);
}
async waitForFileChooser(options: { timeout?: number; } = {}): Promise<FileChooser> {
const {
timeout = this._timeoutSettings.timeout(),
} = options;
let callback;
const promise = new Promise<FileChooser>(x => callback = x);
this._fileChooserInterceptors.add(callback);
return helper.waitWithTimeout<FileChooser>(promise, 'waiting for file chooser', timeout).catch(e => {
this._fileChooserInterceptors.delete(callback);
throw e;
});
}
browser(): Browser {
return this._target.browser();
}
browserContext(): BrowserContext {
return this._target.browserContext();
}
_onTargetCrashed() {
this.emit('error', new Error('Page crashed!'));
}
_onLogEntryAdded(event: Protocol.Log.entryAddedPayload) {
const {level, text, args, source, url, lineNumber} = event.entry;
if (args)
args.map(arg => releaseObject(this._client, arg));
if (source !== 'worker')
this.emit(Events.Page.Console, new console.ConsoleMessage(level, text, [], {url, lineNumber}));
}
mainFrame(): frames.Frame {
return this._frameManager.mainFrame();
}
get keyboard(): input.Keyboard {
return this._keyboard;
}
frames(): frames.Frame[] {
return this._frameManager.frames();
}
setDefaultNavigationTimeout(timeout: number) {
this._timeoutSettings.setDefaultNavigationTimeout(timeout);
}
setDefaultTimeout(timeout: number) {
this._timeoutSettings.setDefaultTimeout(timeout);
}
async $(selector: string | types.Selector): Promise<dom.ElementHandle | null> {
return this.mainFrame().$(selector);
}
evaluateHandle: types.EvaluateHandle = async (pageFunction, ...args) => {
const context = await this.mainFrame().executionContext();
return context.evaluateHandle(pageFunction, ...args as any);
}
$eval: types.$Eval = (selector, pageFunction, ...args) => {
return this.mainFrame().$eval(selector, pageFunction, ...args as any);
}
$$eval: types.$$Eval = (selector, pageFunction, ...args) => {
return this.mainFrame().$$eval(selector, pageFunction, ...args as any);
}
async $$(selector: string | types.Selector): Promise<dom.ElementHandle[]> {
return this.mainFrame().$$(selector);
}
async $x(expression: string): Promise<dom.ElementHandle[]> {
return this.mainFrame().$x(expression);
}
async addScriptTag(options: { url?: string; path?: string; content?: string; type?: string; }): Promise<dom.ElementHandle> {
return this.mainFrame().addScriptTag(options);
}
async addStyleTag(options: { url?: string; path?: string; content?: string; }): Promise<dom.ElementHandle> {
return this.mainFrame().addStyleTag(options);
}
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._client.send('Runtime.addBinding', {name: name});
await this._client.send('Page.addScriptToEvaluateOnNewDocument', {source: expression});
await Promise.all(this.frames().map(frame => frame.evaluate(expression).catch(debugError)));
function addPageBinding(bindingName: string) {
const binding = 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 setExtraHTTPHeaders(headers: { [s: string]: string; }) {
return this._frameManager.networkManager().setExtraHTTPHeaders(headers);
}
async setUserAgent(userAgent: string) {
return this._frameManager.networkManager().setUserAgent(userAgent);
}
_handleException(exceptionDetails: Protocol.Runtime.ExceptionDetails) {
const message = getExceptionMessage(exceptionDetails);
const err = new Error(message);
err.stack = ''; // Don't report clientside error with a node stack attached
this.emit(Events.Page.PageError, err);
}
async _onConsoleAPI(event: Protocol.Runtime.consoleAPICalledPayload) {
if (event.executionContextId === 0) {
// DevTools protocol stores the last 1000 console messages. These
// messages are always reported even for removed execution contexts. In
// this case, they are marked with executionContextId = 0 and are
// reported upon enabling Runtime agent.
//
// Ignore these messages since:
// - there's no execution context we can use to operate with message
// arguments
// - these messages are reported before Playwright clients can subscribe
// to the 'console'
// page event.
//
// @see https://github.com/GoogleChrome/puppeteer/issues/3865
return;
}
const context = this._frameManager.executionContextById(event.executionContextId);
const values = event.args.map(arg => context._createHandle(arg));
this._addConsoleMessage(event.type, values, event.stackTrace);
}
async _onBindingCalled(event: Protocol.Runtime.bindingCalledPayload) {
const {name, seq, args} = JSON.parse(event.payload);
let expression = null;
try {
const result = await this._pageBindings.get(name)(...args);
expression = helper.evaluationString(deliverResult, name, seq, result);
} catch (error) {
if (error instanceof Error)
expression = helper.evaluationString(deliverError, name, seq, error.message, error.stack);
else
expression = helper.evaluationString(deliverErrorValue, name, seq, error);
}
this._client.send('Runtime.evaluate', { expression, contextId: event.executionContextId }).catch(debugError);
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);
}
}
_addConsoleMessage(type: string, args: js.JSHandle[], stackTrace: Protocol.Runtime.StackTrace | undefined) {
if (!this.listenerCount(Events.Page.Console)) {
args.forEach(arg => arg.dispose());
return;
}
const location = stackTrace && stackTrace.callFrames.length ? {
url: stackTrace.callFrames[0].url,
lineNumber: stackTrace.callFrames[0].lineNumber,
columnNumber: stackTrace.callFrames[0].columnNumber,
} : {};
this.emit(Events.Page.Console, new console.ConsoleMessage(type, undefined, args, location));
}
_onDialog(event : Protocol.Page.javascriptDialogOpeningPayload) {
this.emit(Events.Page.Dialog, new dialog.Dialog(
event.type as dialog.DialogType,
event.message,
async (accept: boolean, promptText?: string) => {
await this._client.send('Page.handleJavaScriptDialog', { accept, promptText });
},
event.defaultPrompt));
}
url(): string {
return this.mainFrame().url();
}
async content(): Promise<string> {
return await this._frameManager.mainFrame().content();
}
async setContent(html: string, options: { timeout?: number; waitUntil?: string | string[]; } | undefined) {
await this._frameManager.mainFrame().setContent(html, options);
}
async goto(url: string, options: { referer?: string; timeout?: number; waitUntil?: string | string[]; } | undefined): Promise<network.Response | null> {
return await this._frameManager.mainFrame().goto(url, options);
}
async reload(options: { timeout?: number; waitUntil?: string | string[]; } = {}): Promise<network.Response | null> {
const [response] = await Promise.all([
this.waitForNavigation(options),
this._client.send('Page.reload')
]);
return response;
}
async waitForNavigation(options: { timeout?: number; waitUntil?: string | string[]; } = {}): Promise<network.Response | null> {
return await this._frameManager.mainFrame().waitForNavigation(options);
}
_sessionClosePromise() {
if (!this._disconnectPromise)
this._disconnectPromise = new Promise(fulfill => this._client.once(CDPSessionEvents.Disconnected, () => fulfill(new Error('Target closed'))));
return this._disconnectPromise;
}
async waitForRequest(urlOrPredicate: (string | Function), options: { timeout?: number; } = {}): Promise<Request> {
const {
timeout = this._timeoutSettings.timeout(),
} = options;
return helper.waitForEvent(this._frameManager.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; } = {}): Promise<network.Response> {
const {
timeout = this._timeoutSettings.timeout(),
} = options;
return helper.waitForEvent(this._frameManager.networkManager(), NetworkManagerEvents.Response, response => {
if (helper.isString(urlOrPredicate))
return (urlOrPredicate === response.url());
if (typeof urlOrPredicate === 'function')
return !!(urlOrPredicate(response));
return false;
}, timeout, this._sessionClosePromise());
}
async goBack(options: { timeout?: number; waitUntil?: string | string[]; } | undefined): Promise<network.Response | null> {
return this._go(-1, options);
}
async goForward(options: { timeout?: number; waitUntil?: string | string[]; } | undefined): Promise<network.Response | null> {
return this._go(+1, options);
}
async _go(delta, options: { timeout?: number; waitUntil?: string | string[]; } | undefined): Promise<network.Response | null> {
const history = await this._client.send('Page.getNavigationHistory');
const entry = history.entries[history.currentIndex + delta];
if (!entry)
return null;
const [response] = await Promise.all([
this.waitForNavigation(options),
this._client.send('Page.navigateToHistoryEntry', {entryId: entry.id}),
]);
return response;
}
async emulate(options: { viewport: Viewport; userAgent: string; }) {
await Promise.all([
this.setViewport(options.viewport),
this.setUserAgent(options.userAgent)
]);
}
async setJavaScriptEnabled(enabled: boolean) {
if (this._javascriptEnabled === enabled)
return;
this._javascriptEnabled = enabled;
await this._client.send('Emulation.setScriptExecutionDisabled', { value: !enabled });
}
async setBypassCSP(enabled: boolean) {
await this._client.send('Page.setBypassCSP', { enabled });
}
async emulateMedia(options: {
type?: string,
colorScheme?: 'dark' | 'light' | 'no-preference' }) {
assert(!options.type || mediaTypes.has(options.type), 'Unsupported media type: ' + options.type);
assert(!options.colorScheme || mediaColorSchemes.has(options.colorScheme), 'Unsupported color scheme: ' + options.colorScheme);
const media = typeof options.type === 'undefined' ? this._emulatedMediaType : options.type;
const features = typeof options.colorScheme === 'undefined' ? [] : [{ name: 'prefers-color-scheme', value: options.colorScheme }];
await this._client.send('Emulation.setEmulatedMedia', { media: media || '', features });
this._emulatedMediaType = options.type;
}
async setViewport(viewport: Viewport) {
const needsReload = await this._emulationManager.emulateViewport(viewport);
this._viewport = viewport;
if (needsReload)
await this.reload();
}
viewport(): Viewport | null {
return this._viewport;
}
evaluate: types.Evaluate = (pageFunction, ...args) => {
return this._frameManager.mainFrame().evaluate(pageFunction, ...args as any);
}
async evaluateOnNewDocument(pageFunction: Function | string, ...args: any[]) {
const source = helper.evaluationString(pageFunction, ...args);
await this._client.send('Page.addScriptToEvaluateOnNewDocument', { source });
}
async setCacheEnabled(enabled: boolean = true) {
await this._frameManager.networkManager().setCacheEnabled(enabled);
}
screenshot(options: ScreenshotOptions = {}): Promise<Buffer | string> {
return this._screenshotter.screenshotPage(this, options);
}
async title(): Promise<string> {
return this.mainFrame().title();
}
async close(options: { runBeforeUnload: (boolean | undefined); } = {runBeforeUnload: undefined}) {
assert(!!this._client._connection, 'Protocol error: Connection closed. Most likely the page has been closed.');
const runBeforeUnload = !!options.runBeforeUnload;
if (runBeforeUnload) {
await this._client.send('Page.close');
} else {
await this.browser()._closeTarget(this._target);
await this._target._isClosedPromise;
}
}
isClosed(): boolean {
return this._closed;
}
get mouse(): input.Mouse {
return this._mouse;
}
click(selector: string | types.Selector, options?: ClickOptions) {
return this.mainFrame().click(selector, options);
}
dblclick(selector: string | types.Selector, options?: MultiClickOptions) {
return this.mainFrame().dblclick(selector, options);
}
tripleclick(selector: string | types.Selector, options?: MultiClickOptions) {
return this.mainFrame().tripleclick(selector, options);
}
fill(selector: string | types.Selector, value: string) {
return this.mainFrame().fill(selector, value);
}
focus(selector: string | types.Selector) {
return this.mainFrame().focus(selector);
}
hover(selector: string | types.Selector, options?: PointerActionOptions) {
return this.mainFrame().hover(selector, options);
}
select(selector: string | types.Selector, ...values: (string | dom.ElementHandle | SelectOption)[]): Promise<string[]> {
return this.mainFrame().select(selector, ...values);
}
type(selector: string | types.Selector, text: string, options: { delay: (number | undefined); } | undefined) {
return this.mainFrame().type(selector, text, options);
}
waitFor(selectorOrFunctionOrTimeout: (string | number | Function), options: { visible?: boolean; hidden?: boolean; timeout?: number; polling?: string | number; } = {}, ...args: any[]): Promise<js.JSHandle> {
return this.mainFrame().waitFor(selectorOrFunctionOrTimeout, options, ...args);
}
waitForSelector(selector: string | types.Selector, options: types.TimeoutOptions = {}): Promise<dom.ElementHandle | null> {
return this.mainFrame().waitForSelector(selector, options);
}
waitForXPath(xpath: string, options: types.TimeoutOptions = {}): Promise<dom.ElementHandle | null> {
return this.mainFrame().waitForXPath(xpath, options);
}
waitForFunction(pageFunction: Function | string, options: types.WaitForFunctionOptions, ...args: any[]): Promise<js.JSHandle> {
return this.mainFrame().waitForFunction(pageFunction, options, ...args);
}
}
type MediaFeature = {
name: string,
value: string
}
type FileChooser = {
element: dom.ElementHandle,
multiple: boolean
};

View file

@ -15,27 +15,31 @@
* limitations under the License.
*/
import { Browser } from './Browser';
import { BrowserFetcher, BrowserFetcherOptions } from './BrowserFetcher';
import { BrowserFetcher, BrowserFetcherOptions, BrowserFetcherRevisionInfo, OnProgressCallback } from '../browserFetcher';
import { ConnectionTransport } from '../ConnectionTransport';
import { DeviceDescriptors } from '../DeviceDescriptors';
import * as Errors from '../Errors';
import { Launcher, LauncherBrowserOptions, LauncherChromeArgOptions, LauncherLaunchOptions } from './Launcher';
import {download, RevisionInfo} from '../download';
import { Launcher, LauncherBrowserOptions, LauncherChromeArgOptions, LauncherLaunchOptions, createBrowserFetcher } from './Launcher';
export class Playwright {
private _projectRoot: string;
private _launcher: Launcher;
readonly _revision: string;
downloadBrowser: (options?: { onProgress?: (downloadedBytes: number, totalBytes: number) => void; }) => Promise<RevisionInfo>;
constructor(projectRoot: string, preferredRevision: string) {
this._projectRoot = projectRoot;
this._launcher = new Launcher(projectRoot, preferredRevision);
this._revision = preferredRevision;
this.downloadBrowser = download.bind(null, this.createBrowserFetcher(), preferredRevision, 'Chromium');
}
launch(options: (LauncherLaunchOptions & LauncherChromeArgOptions & LauncherBrowserOptions) | undefined): Promise<Browser> {
async downloadBrowser(options?: BrowserFetcherOptions & { onProgress?: OnProgressCallback }): Promise<BrowserFetcherRevisionInfo> {
const fetcher = this.createBrowserFetcher(options);
const revisionInfo = fetcher.revisionInfo(this._revision);
await fetcher.download(this._revision, options ? options.onProgress : undefined);
return revisionInfo;
}
launch(options?: (LauncherLaunchOptions & LauncherChromeArgOptions & LauncherBrowserOptions) | undefined): Promise<Browser> {
return this._launcher.launch(options);
}
@ -65,7 +69,7 @@ export class Playwright {
return this._launcher.defaultArgs(options);
}
createBrowserFetcher(options?: BrowserFetcherOptions | undefined): BrowserFetcher {
return new BrowserFetcher(this._projectRoot, options);
createBrowserFetcher(options?: BrowserFetcherOptions): BrowserFetcher {
return createBrowserFetcher(this._projectRoot, options);
}
}

View file

@ -1,181 +1,40 @@
/**
* 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.
*/
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import * as fs from 'fs';
import { Page } from './Page';
import { assert, helper } from '../helper';
import * as mime from 'mime';
import { Protocol } from './protocol';
import * as dom from '../dom';
import { ScreenshotterDelegate } from '../screenshotter';
import * as types from '../types';
import { CDPSession } from './api';
const writeFileAsync = helper.promisify(fs.writeFile);
export class CRScreenshotDelegate implements ScreenshotterDelegate {
private _session: CDPSession;
export type ScreenshotOptions = {
type?: 'png' | 'jpeg',
path?: string,
fullPage?: boolean,
clip?: {x: number, y: number, width: number, height: number},
quality?: number,
omitBackground?: boolean,
encoding?: string,
}
export class Screenshotter {
private _queue = new TaskQueue();
async screenshotPage(page: Page, options: ScreenshotOptions = {}): Promise<Buffer | string> {
const format = this._format(options);
return this._queue.postTask(() => this._screenshot(page, format, options));
constructor(session: CDPSession) {
this._session = session;
}
async screenshotElement(page: Page, handle: dom.ElementHandle, options: ScreenshotOptions = {}): Promise<string | Buffer> {
const format = this._format(options);
return this._queue.postTask(async () => {
let needsViewportReset = false;
let boundingBox = await handle.boundingBox();
assert(boundingBox, 'Node is either not visible or not an HTMLElement');
const viewport = 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 page.setViewport(Object.assign({}, viewport, newViewport));
needsViewportReset = true;
}
await handle._scrollIntoViewIfNeeded();
boundingBox = await handle.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 page._client.send('Page.getLayoutMetrics');
const clip = Object.assign({}, boundingBox);
clip.x += pageX;
clip.y += pageY;
const imageData = await this._screenshot(page, format, {...options, clip});
if (needsViewportReset)
await page.setViewport(viewport);
return imageData;
});
async getBoundingBox(handle: dom.ElementHandle<Node>): Promise<types.Rect | null> {
const rect = await handle.boundingBox();
if (!rect)
return rect;
const { layoutViewport: { pageX, pageY } } = await this._session.send('Page.getLayoutMetrics');
rect.x += pageX;
rect.y += pageY;
return rect;
}
private async _screenshot(page: Page, format: 'png' | 'jpeg', options: ScreenshotOptions): Promise<Buffer | string> {
await page._client.send('Target.activateTarget', {targetId: page._target._targetId});
let clip = options.clip ? processClip(options.clip) : undefined;
const viewport = page.viewport();
if (options.fullPage) {
const metrics = await page._client.send('Page.getLayoutMetrics');
const width = Math.ceil(metrics.contentSize.width);
const height = Math.ceil(metrics.contentSize.height);
// Overwrite clip for full page at all times.
clip = { x: 0, y: 0, width, height, scale: 1 };
const {
isMobile = false,
deviceScaleFactor = 1,
isLandscape = false
} = viewport || {};
const screenOrientation: Protocol.Emulation.ScreenOrientation = isLandscape ? { angle: 90, type: 'landscapePrimary' } : { angle: 0, type: 'portraitPrimary' };
await page._client.send('Emulation.setDeviceMetricsOverride', { mobile: isMobile, width, height, deviceScaleFactor, screenOrientation });
}
const shouldSetDefaultBackground = options.omitBackground && format === 'png';
if (shouldSetDefaultBackground)
await page._client.send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } });
const result = await page._client.send('Page.captureScreenshot', { format, quality: options.quality, clip });
if (shouldSetDefaultBackground)
await page._client.send('Emulation.setDefaultBackgroundColorOverride');
if (options.fullPage && viewport)
await page.setViewport(viewport);
const buffer = options.encoding === 'base64' ? result.data : Buffer.from(result.data, 'base64');
if (options.path)
await writeFileAsync(options.path, buffer);
return buffer;
function processClip(clip) {
const x = Math.round(clip.x);
const y = Math.round(clip.y);
const width = Math.round(clip.width + clip.x - x);
const height = Math.round(clip.height + clip.y - y);
return {x, y, width, height, scale: 1};
}
canCaptureOutsideViewport(): boolean {
return false;
}
private _format(options: ScreenshotOptions): 'png' | 'jpeg' {
let format: 'png' | 'jpeg' | null = null;
// options.type takes precedence over inferring the type from options.path
// because it may be a 0-length file with no extension created beforehand (i.e. as a temp file).
if (options.type) {
assert(options.type === 'png' || options.type === 'jpeg', 'Unknown options.type value: ' + options.type);
format = options.type;
} else if (options.path) {
const mimeType = mime.getType(options.path);
if (mimeType === 'image/png')
format = 'png';
else if (mimeType === 'image/jpeg')
format = 'jpeg';
assert(format, 'Unsupported screenshot mime type: ' + mimeType);
}
async setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise<void> {
await this._session.send('Emulation.setDefaultBackgroundColorOverride', { color });
}
if (!format)
format = 'png';
if (options.quality) {
assert(format === 'jpeg', 'options.quality is unsupported for the ' + format + ' screenshots');
assert(typeof options.quality === 'number', 'Expected options.quality to be a number but found ' + (typeof options.quality));
assert(Number.isInteger(options.quality), 'Expected options.quality to be an integer');
assert(options.quality >= 0 && options.quality <= 100, 'Expected options.quality to be between 0 and 100 (inclusive), got ' + options.quality);
}
assert(!options.clip || !options.fullPage, 'options.clip and options.fullPage are exclusive');
if (options.clip) {
assert(typeof options.clip.x === 'number', 'Expected options.clip.x to be a number but found ' + (typeof options.clip.x));
assert(typeof options.clip.y === 'number', 'Expected options.clip.y to be a number but found ' + (typeof options.clip.y));
assert(typeof options.clip.width === 'number', 'Expected options.clip.width to be a number but found ' + (typeof options.clip.width));
assert(typeof options.clip.height === 'number', 'Expected options.clip.height to be a number but found ' + (typeof options.clip.height));
assert(options.clip.width !== 0, 'Expected options.clip.width not to be 0.');
assert(options.clip.height !== 0, 'Expected options.clip.height not to be 0.');
}
return format;
}
}
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;
async screenshot(format: 'png' | 'jpeg', options: types.ScreenshotOptions): Promise<Buffer> {
const clip = options.clip ? { ...options.clip, scale: 1 } : undefined;
const result = await this._session.send('Page.captureScreenshot', { format, quality: options.quality, clip });
return Buffer.from(result.data, 'base64');
}
}

View file

@ -15,14 +15,18 @@
* limitations under the License.
*/
import * as types from '../types';
import { Browser } from './Browser';
import { BrowserContext } from './BrowserContext';
import { CDPSession } from './Connection';
import { Events } from './events';
import { CDPSession, CDPSessionEvents } from './Connection';
import { Events as CommonEvents } from '../events';
import { Worker } from './features/workers';
import { Page, Viewport } from './Page';
import { Page } from '../page';
import { Protocol } from './protocol';
import { Screenshotter } from './Screenshotter';
import { debugError } from '../helper';
import { FrameManager } from './FrameManager';
const targetSymbol = Symbol('target');
export class Target {
private _targetInfo: Protocol.Target.TargetInfo;
@ -30,30 +34,30 @@ export class Target {
_targetId: string;
private _sessionFactory: () => Promise<CDPSession>;
private _ignoreHTTPSErrors: boolean;
private _defaultViewport: Viewport;
private _screenshotter: Screenshotter;
private _pagePromise: Promise<Page> | null = null;
private _defaultViewport: types.Viewport;
private _pagePromise: Promise<Page<Browser, BrowserContext>> | null = null;
private _page: Page<Browser, BrowserContext> | null = null;
private _workerPromise: Promise<Worker> | null = null;
_initializedPromise: Promise<boolean>;
_initializedCallback: (value?: unknown) => void;
_isClosedPromise: Promise<void>;
_closedCallback: (value?: unknown) => void;
_isInitialized: boolean;
static fromPage(page: Page<Browser, BrowserContext>): Target {
return (page as any)[targetSymbol];
}
constructor(
targetInfo: Protocol.Target.TargetInfo,
browserContext: BrowserContext,
sessionFactory: () => Promise<CDPSession>,
ignoreHTTPSErrors: boolean,
defaultViewport: Viewport | null,
screenshotter: Screenshotter) {
defaultViewport: types.Viewport | null) {
this._targetInfo = targetInfo;
this._browserContext = browserContext;
this._targetId = targetInfo.targetId;
this._sessionFactory = sessionFactory;
this._ignoreHTTPSErrors = ignoreHTTPSErrors;
this._defaultViewport = defaultViewport;
this._screenshotter = screenshotter;
this._initializedPromise = new Promise(fulfill => this._initializedCallback = fulfill).then(async success => {
if (!success)
return false;
@ -61,22 +65,42 @@ export class Target {
if (!opener || !opener._pagePromise || this.type() !== 'page')
return true;
const openerPage = await opener._pagePromise;
if (!openerPage.listenerCount(Events.Page.Popup))
if (!openerPage.listenerCount(CommonEvents.Page.Popup))
return true;
const popupPage = await this.page();
openerPage.emit(Events.Page.Popup, popupPage);
openerPage.emit(CommonEvents.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);
}
async page(): Promise<Page | null> {
_didClose() {
if (this._page)
this._page._didClose();
}
async page(): Promise<Page<Browser, BrowserContext> | 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._screenshotter));
this._pagePromise = this._sessionFactory().then(async client => {
const frameManager = new FrameManager(client, this._browserContext, this._ignoreHTTPSErrors);
const page = frameManager.page();
this._page = page;
page[targetSymbol] = this;
client.once(CDPSessionEvents.Disconnected, () => page._didDisconnect());
client.on('Target.attachedToTarget', event => {
if (event.targetInfo.type !== 'worker') {
// If we don't detach from service workers, they will never die.
client.send('Target.detachFromTarget', { sessionId: event.sessionId }).catch(debugError);
}
});
await frameManager.initialize();
await client.send('Target.setAutoAttach', {autoAttach: true, waitForDebuggerOnStart: false, flatten: true});
if (this._defaultViewport)
await page.setViewport(this._defaultViewport);
return page;
});
}
return this._pagePromise;
}

View file

@ -11,7 +11,7 @@ export { ExecutionContext, JSHandle } from '../javascript';
export { Request, Response } from '../network';
export { Browser } from './Browser';
export { BrowserContext } from './BrowserContext';
export { BrowserFetcher } from './BrowserFetcher';
export { BrowserFetcher } from '../browserFetcher';
export { CDPSession } from './Connection';
export { Accessibility } from './features/accessibility';
export { Chromium } from './features/chromium';
@ -21,7 +21,7 @@ export { Overrides } from './features/overrides';
export { PDF } from './features/pdf';
export { Permissions } from './features/permissions';
export { Worker, Workers } from './features/workers';
export { Page } from './Page';
export { Page } from '../page';
export { Playwright } from './Playwright';
export { Target } from './Target';

View file

@ -16,26 +16,6 @@
*/
export const Events = {
Page: {
Close: 'close',
Console: 'console',
Dialog: 'dialog',
FileChooser: 'filechooser',
DOMContentLoaded: 'domcontentloaded',
// 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',
Popup: 'popup',
},
Browser: {
Disconnected: 'disconnected'
},

View file

@ -19,10 +19,11 @@ import { assert } from '../../helper';
import { Browser } from '../Browser';
import { BrowserContext } from '../BrowserContext';
import { CDPSession, Connection } from '../Connection';
import { Page } from '../Page';
import { Page } from '../../page';
import { readProtocolStream } from '../protocolHelper';
import { Target } from '../Target';
import { Worker } from './workers';
import { FrameManager } from '../FrameManager';
export class Chromium extends EventEmitter {
private _connection: Connection;
@ -47,9 +48,9 @@ export class Chromium extends EventEmitter {
return target._worker();
}
async startTracing(page: Page | undefined, options: { path?: string; screenshots?: boolean; categories?: string[]; } = {}) {
async startTracing(page: Page<Browser, BrowserContext> | undefined, options: { path?: string; screenshots?: boolean; categories?: string[]; } = {}) {
assert(!this._recording, 'Cannot start recording trace while already recording trace.');
this._tracingClient = page ? page._client : this._client;
this._tracingClient = page ? (page._delegate as FrameManager)._client : this._client;
const defaultCategories = [
'-*', 'devtools.timeline', 'v8.execute', 'disabled-by-default-devtools.timeline',
@ -91,8 +92,8 @@ export class Chromium extends EventEmitter {
return context ? targets.filter(t => t.browserContext() === context) : targets;
}
pageTarget(page: Page): Target {
return page._target;
pageTarget(page: Page<Browser, BrowserContext>): Target {
return Target.fromPage(page);
}
waitForTarget(predicate: (arg0: Target) => boolean, options: { timeout?: number; } | undefined = {}): Promise<Target> {

View file

@ -14,6 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EventEmitter } from 'events';
import { CDPSession, Connection } from '../Connection';
import { debugError } from '../../helper';
@ -21,10 +22,12 @@ import { Protocol } from '../protocol';
import { Events } from '../events';
import * as types from '../../types';
import * as js from '../../javascript';
import * as console from '../../console';
import { ExecutionContextDelegate } from '../ExecutionContext';
import { toConsoleMessageLocation, exceptionToError } from '../protocolHelper';
type AddToConsoleCallback = (type: string, args: js.JSHandle[], stackTrace: Protocol.Runtime.StackTrace | undefined) => void;
type HandleExceptionCallback = (exceptionDetails: Protocol.Runtime.ExceptionDetails) => void;
type AddToConsoleCallback = (type: string, args: js.JSHandle[], location: console.ConsoleMessageLocation) => void;
type HandleExceptionCallback = (error: Error) => void;
export class Workers extends EventEmitter {
private _workers = new Map<string, Worker>();
@ -74,8 +77,8 @@ export class Worker extends EventEmitter {
// 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 => addToConsole(event.type, event.args.map(jsHandleFactory), event.stackTrace));
this._client.on('Runtime.exceptionThrown', exception => handleException(exception.exceptionDetails));
this._client.on('Runtime.consoleAPICalled', event => addToConsole(event.type, event.args.map(jsHandleFactory), toConsoleMessageLocation(event.stackTrace)));
this._client.on('Runtime.exceptionThrown', exception => handleException(exceptionToError(exception.exceptionDetails)));
}
url(): string {

View file

@ -94,4 +94,17 @@ export async function readProtocolStream(client: CDPSession, handle: string, pat
}
}
export function toConsoleMessageLocation(stackTrace: Protocol.Runtime.StackTrace | undefined) {
return stackTrace && stackTrace.callFrames.length ? {
url: stackTrace.callFrames[0].url,
lineNumber: stackTrace.callFrames[0].lineNumber,
columnNumber: stackTrace.callFrames[0].columnNumber,
} : {};
}
export function exceptionToError(exceptionDetails: Protocol.Runtime.ExceptionDetails): Error {
const message = getExceptionMessage(exceptionDetails);
const err = new Error(message);
err.stack = ''; // Don't report clientside error with a node stack attached
return err;
}

View file

@ -3,7 +3,7 @@
import * as js from './javascript';
type ConsoleMessageLocation = {
export type ConsoleMessageLocation = {
url?: string,
lineNumber?: number,
columnNumber?: number,

View file

@ -10,7 +10,6 @@ import * as cssSelectorEngineSource from './generated/cssSelectorEngineSource';
import * as xpathSelectorEngineSource from './generated/xpathSelectorEngineSource';
import { assert, helper, debugError } from './helper';
import Injected from './injected/injected';
import { SelectorRoot } from './injected/selectorEngine';
export interface DOMWorldDelegate {
keyboard: input.Keyboard;
@ -22,12 +21,12 @@ export interface DOMWorldDelegate {
contentQuads(handle: ElementHandle): Promise<types.Quad[] | null>;
layoutViewport(): Promise<{ width: number, height: number }>;
boundingBox(handle: ElementHandle): Promise<types.Rect | null>;
screenshot(handle: ElementHandle, options?: any): Promise<string | Buffer>;
screenshot(handle: ElementHandle, options?: types.ScreenshotOptions): Promise<string | Buffer>;
setInputFiles(handle: ElementHandle, files: input.FilePayload[]): Promise<void>;
adoptElementHandle(handle: ElementHandle, to: DOMWorld): Promise<ElementHandle>;
adoptElementHandle<T extends Node>(handle: ElementHandle<T>, to: DOMWorld): Promise<ElementHandle<T>>;
}
export type ScopedSelector = types.Selector & { scope?: ElementHandle };
type ScopedSelector = types.Selector & { scope?: ElementHandle };
type ResolvedSelector = { scope?: ElementHandle, selector: string, visible?: boolean, disposeScope?: boolean };
export class DOMWorld {
@ -60,7 +59,7 @@ export class DOMWorld {
return this._injectedPromise;
}
async adoptElementHandle(handle: ElementHandle): Promise<ElementHandle> {
async adoptElementHandle<T extends Node>(handle: ElementHandle<T>): Promise<ElementHandle<T>> {
assert(handle.executionContext() !== this.context, 'Should not adopt to the same context');
return this.delegate.adoptElementHandle(handle, this);
}
@ -75,10 +74,10 @@ export class DOMWorld {
return { scope: selector.scope, selector: normalizeSelector(selector.selector), visible: selector.visible };
}
async $(selector: string | ScopedSelector): Promise<ElementHandle | null> {
async $(selector: string | ScopedSelector): Promise<ElementHandle<Element> | null> {
const resolved = await this.resolveSelector(selector);
const handle = await this.context.evaluateHandle(
(injected: Injected, selector: string, scope: SelectorRoot | undefined, visible: boolean | undefined) => {
(injected: Injected, selector: string, scope?: Node, visible?: boolean) => {
const element = injected.querySelector(selector, scope || document);
if (visible === undefined || !element)
return element;
@ -93,10 +92,10 @@ export class DOMWorld {
return handle.asElement();
}
async $$(selector: string | ScopedSelector): Promise<ElementHandle[]> {
async $$(selector: string | ScopedSelector): Promise<ElementHandle<Element>[]> {
const resolved = await this.resolveSelector(selector);
const arrayHandle = await this.context.evaluateHandle(
(injected: Injected, selector: string, scope: SelectorRoot | undefined, visible: boolean | undefined) => {
(injected: Injected, selector: string, scope?: Node, visible?: boolean) => {
const elements = injected.querySelectorAll(selector, scope || document);
if (visible !== undefined)
return elements.filter(element => injected.isVisible(element) === visible);
@ -131,7 +130,7 @@ export class DOMWorld {
$$eval: types.$$Eval<string | ScopedSelector> = async (selector, pageFunction, ...args) => {
const resolved = await this.resolveSelector(selector);
const arrayHandle = await this.context.evaluateHandle(
(injected: Injected, selector: string, scope: SelectorRoot | undefined, visible: boolean | undefined) => {
(injected: Injected, selector: string, scope?: Node, visible?: boolean) => {
const elements = injected.querySelectorAll(selector, scope || document);
if (visible !== undefined)
return elements.filter(element => injected.isVisible(element) === visible);
@ -145,8 +144,8 @@ export class DOMWorld {
}
}
export class ElementHandle extends js.JSHandle {
private readonly _world: DOMWorld;
export class ElementHandle<T extends Node = Node> extends js.JSHandle<T> {
readonly _world: DOMWorld;
constructor(context: js.ExecutionContext, remoteObject: any) {
super(context, remoteObject);
@ -154,7 +153,7 @@ export class ElementHandle extends js.JSHandle {
this._world = context._domWorld;
}
asElement(): ElementHandle | null {
asElement(): ElementHandle<T> | null {
return this;
}
@ -163,13 +162,15 @@ export class ElementHandle extends js.JSHandle {
}
async _scrollIntoViewIfNeeded() {
const error = await this.evaluate(async (element, pageJavascriptEnabled) => {
if (!element.isConnected)
const error = await this.evaluate(async (node: Node, pageJavascriptEnabled: boolean) => {
if (!node.isConnected)
return 'Node is detached from document';
if (element.nodeType !== Node.ELEMENT_NODE)
if (node.nodeType !== Node.ELEMENT_NODE)
return 'Node is not of type HTMLElement';
const element = node as Element;
// force-scroll if page's javascript is disabled.
if (!pageJavascriptEnabled) {
// @ts-ignore because only Chromium still supports 'instant'
element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
return false;
}
@ -183,8 +184,10 @@ export class ElementHandle extends js.JSHandle {
// there are rafs.
requestAnimationFrame(() => {});
});
if (visibleRatio !== 1.0)
if (visibleRatio !== 1.0) {
// @ts-ignore because only Chromium still supports 'instant'
element.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'});
}
return false;
}, this._world.delegate.isJavascriptEnabled());
if (error)
@ -254,8 +257,10 @@ export class ElementHandle extends js.JSHandle {
private async _viewportPointAndScroll(relativePoint: types.Point): Promise<{point: types.Point, scrollX: number, scrollY: number}> {
const [box, border] = await Promise.all([
this.boundingBox(),
this.evaluate((e: Element) => {
const style = e.ownerDocument.defaultView.getComputedStyle(e);
this.evaluate((node: Node) => {
if (node.nodeType !== Node.ELEMENT_NODE)
return { x: 0, y: 0 };
const style = node.ownerDocument.defaultView.getComputedStyle(node as Element);
return { x: parseInt(style.borderLeftWidth, 10), y: parseInt(style.borderTopWidth, 10) };
}).catch(debugError),
]);
@ -270,17 +275,17 @@ export class ElementHandle extends js.JSHandle {
point.y += border.y;
}
const metrics = await this._world.delegate.layoutViewport();
// Give one extra pixel to avoid any issues on viewport edge.
// Give 20 extra pixels to avoid any issues on viewport edge.
let scrollX = 0;
if (point.x < 1)
scrollX = point.x - 1;
if (point.x > metrics.width - 1)
scrollX = point.x - metrics.width + 1;
if (point.x < 20)
scrollX = point.x - 20;
if (point.x > metrics.width - 20)
scrollX = point.x - metrics.width + 20;
let scrollY = 0;
if (point.y < 1)
scrollY = point.y - 1;
if (point.y > metrics.height - 1)
scrollY = point.y - metrics.height + 1;
if (point.y < 20)
scrollY = point.y - 20;
if (point.y > metrics.height - 20)
scrollY = point.y - metrics.height + 20;
return { point, scrollX, scrollY };
}
@ -334,13 +339,25 @@ export class ElementHandle extends js.JSHandle {
}
async setInputFiles(...files: (string|input.FilePayload)[]) {
const multiple = await this.evaluate((element: HTMLInputElement) => !!element.multiple);
const multiple = await this.evaluate((node: Node) => {
if (node.nodeType !== Node.ELEMENT_NODE || (node as Element).tagName !== 'INPUT')
throw new Error('Node is not an HTMLInputElement');
const input = node as HTMLInputElement;
return input.multiple;
});
assert(multiple || files.length <= 1, 'Non-multiple file input can only accept single file!');
await this._world.delegate.setInputFiles(this, await input.loadFiles(files));
}
async focus() {
await this.evaluate(element => element.focus());
const errorMessage = await this.evaluate((element: Node) => {
if (!element['focus'])
return 'Node is not an HTML or SVG element.';
(element as HTMLElement|SVGElement).focus();
return false;
});
if (errorMessage)
throw new Error(errorMessage);
}
async type(text: string, options: { delay: (number | undefined); } | undefined) {
@ -357,7 +374,7 @@ export class ElementHandle extends js.JSHandle {
return this._world.delegate.boundingBox(this);
}
async screenshot(options: any = {}): Promise<string | Buffer> {
async screenshot(options?: types.ElementScreenshotOptions): Promise<string | Buffer> {
return this._world.delegate.screenshot(this, options);
}
@ -372,7 +389,7 @@ export class ElementHandle extends js.JSHandle {
return this._world.$(this._scopedSelector(selector));
}
$$(selector: string | types.Selector): Promise<ElementHandle[]> {
$$(selector: string | types.Selector): Promise<ElementHandle<Element>[]> {
return this._world.$$(this._scopedSelector(selector));
}
@ -384,12 +401,15 @@ export class ElementHandle extends js.JSHandle {
return this._world.$$eval(this._scopedSelector(selector), pageFunction, ...args as any);
}
$x(expression: string): Promise<ElementHandle[]> {
$x(expression: string): Promise<ElementHandle<Element>[]> {
return this._world.$$({ scope: this, selector: 'xpath=' + expression });
}
isIntersectingViewport(): Promise<boolean> {
return this.evaluate(async element => {
return this.evaluate(async (node: Node) => {
if (node.nodeType !== Node.ELEMENT_NODE)
throw new Error('Node is not of type HTMLElement');
const element = node as Element;
const visibleRatio = await new Promise(resolve => {
const observer = new IntersectionObserver(entries => {
resolve(entries[0].intersectionRatio);
@ -439,7 +459,7 @@ export function waitForFunctionTask(pageFunction: Function | string, options: ty
export function waitForSelectorTask(selector: string | types.Selector, timeout: number): Task {
return async (domWorld: DOMWorld) => {
const resolved = await domWorld.resolveSelector(selector);
return domWorld.context.evaluateHandle((injected: Injected, selector: string, scope: SelectorRoot | undefined, visible: boolean | undefined, timeout: number) => {
return domWorld.context.evaluateHandle((injected: Injected, selector: string, scope: Node | undefined, visible: boolean | undefined, timeout: number) => {
if (visible !== undefined)
return injected.pollRaf(predicate, timeout);
return injected.pollMutation(predicate, timeout);

View file

@ -1,22 +0,0 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
export async function download(
browserFetcher:
import('./chromium/BrowserFetcher').BrowserFetcher |
import('./firefox/BrowserFetcher').BrowserFetcher |
import('./webkit/BrowserFetcher').BrowserFetcher,
revision: string,
browserName: string,
{onProgress}: {onProgress?: (downloadedBytes: number, totalBytes: number) => void} = {}) : Promise<RevisionInfo> {
const revisionInfo = browserFetcher.revisionInfo(revision);
await browserFetcher.download(revision, onProgress);
return revisionInfo;
}
export type RevisionInfo = {
folderPath: string,
executablePath: string,
url: string,
local: boolean,
revision: string,
};

View file

@ -15,16 +15,24 @@
* 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;
}
}
export const Events = {
Page: {
Close: 'close',
Console: 'console',
Dialog: 'dialog',
FileChooser: 'filechooser',
DOMContentLoaded: 'domcontentloaded',
// 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',
Popup: 'popup',
},
};

View file

@ -18,14 +18,15 @@
import { EventEmitter } from 'events';
import { assert, helper, RegisteredListener } from '../helper';
import { filterCookies, NetworkCookie, SetNetworkCookieParam, rewriteCookies } from '../network';
import { Connection, ConnectionEvents } from './Connection';
import { Connection, ConnectionEvents, JugglerSessionEvents } from './Connection';
import { Events } from './events';
import { Permissions } from './features/permissions';
import { Page, Viewport } from './Page';
import { Page } from './Page';
import * as types from '../types';
export class Browser extends EventEmitter {
private _connection: Connection;
_defaultViewport: Viewport;
_defaultViewport: types.Viewport;
private _process: import('child_process').ChildProcess;
private _closeCallback: () => void;
_targets: Map<string, Target>;
@ -33,14 +34,14 @@ export class Browser extends EventEmitter {
private _contexts: Map<string, BrowserContext>;
private _eventListeners: RegisteredListener[];
static async create(connection: Connection, defaultViewport: Viewport | null, process: import('child_process').ChildProcess | null, closeCallback: () => void) {
static async create(connection: Connection, defaultViewport: types.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) {
constructor(connection: Connection, browserContextIds: Array<string>, defaultViewport: types.Viewport | null, process: import('child_process').ChildProcess | null, closeCallback: () => void) {
super();
this._connection = connection;
this._defaultViewport = defaultViewport;
@ -150,6 +151,12 @@ export class Browser extends EventEmitter {
return Array.from(this._targets.values());
}
async _pages(context: BrowserContext): Promise<Page[]> {
const targets = this._allTargets().filter(target => target.browserContext() === context && target.type() === 'page');
const pages = await Promise.all(targets.map(target => target.page()));
return pages.filter(page => !!page);
}
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);
@ -166,7 +173,7 @@ export class Browser extends EventEmitter {
_onTargetDestroyed({targetId}) {
const target = this._targets.get(targetId);
this._targets.delete(targetId);
target._closedCallback();
target._didClose();
}
_onTargetInfoChanged({targetId, url}) {
@ -182,15 +189,14 @@ export class Browser extends EventEmitter {
export class Target {
_pagePromise?: Promise<Page>;
private _page: Page | null = null;
private _browser: Browser;
_context: BrowserContext;
private _connection: any;
private _connection: Connection;
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;
@ -200,9 +206,12 @@ export class Target {
this._type = type;
this._url = url;
this._openerId = openerId;
this._isClosedPromise = new Promise(fulfill => this._closedCallback = fulfill);
}
_didClose() {
if (this._page)
this._page._didClose();
}
opener(): Target | null {
return this._openerId ? this._browser._targets.get(this._openerId) : null;
@ -222,10 +231,18 @@ export class Target {
return this._context;
}
async page() {
page(): Promise<Page> {
if (this._type === 'page' && !this._pagePromise) {
const session = await this._connection.createSession(this._targetId);
this._pagePromise = Page.create(session, this, this._browser._defaultViewport);
this._pagePromise = new Promise(async f => {
const session = await this._connection.createSession(this._targetId);
const page = new Page(session, this._context);
this._page = page;
session.once(JugglerSessionEvents.Disconnected, () => page._didDisconnect());
await page._frameManager._initialize();
if (this._browser._defaultViewport)
await page.setViewport(this._browser._defaultViewport);
f(page);
});
}
return this._pagePromise;
}
@ -248,17 +265,8 @@ export class BrowserContext {
this.permissions = new Permissions(connection, browserContextId);
}
_targets(): Array<Target> {
return this._browser._allTargets().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);
pages(): Promise<Page[]> {
return this._browser._pages(this);
}
isIncognito(): boolean {

View file

@ -1,283 +0,0 @@
/**
* 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;
}

View file

@ -134,7 +134,7 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
});
}
async handleJSONValue(handle: js.JSHandle): Promise<any> {
async handleJSONValue<T>(handle: js.JSHandle<T>): Promise<T> {
const payload = handle._remoteObject;
if (!payload.objectId)
return deserializeValue(payload);

View file

@ -18,7 +18,7 @@
import { EventEmitter } from 'events';
import { TimeoutError } from '../Errors';
import * as frames from '../frames';
import { assert, helper, RegisteredListener } from '../helper';
import { assert, helper, RegisteredListener, debugError } from '../helper';
import * as js from '../javascript';
import * as dom from '../dom';
import { TimeoutSettings } from '../TimeoutSettings';
@ -28,6 +28,9 @@ import { NavigationWatchdog, NextNavigationWatchdog } from './NavigationWatchdog
import { Page } from './Page';
import { NetworkManager } from './NetworkManager';
import { DOMWorldDelegate } from './JSHandle';
import { Events } from './events';
import * as dialog from '../dialog';
import { Protocol } from './protocol';
export const FrameManagerEvents = {
FrameNavigated: Symbol('FrameManagerEvents.FrameNavigated'),
@ -71,9 +74,23 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
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)),
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._session, 'Page.fileChooserOpened', this._onFileChooserOpened.bind(this)),
];
}
async _initialize() {
await Promise.all([
this._session.send('Runtime.enable'),
this._session.send('Network.enable'),
this._session.send('Page.enable'),
this._session.send('Page.setInterceptFileChooserDialog', { enabled: true })
]);
}
executionContextById(executionContextId) {
return this._contextIdToContext.get(executionContextId) || null;
}
@ -173,6 +190,44 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
}
}
_onUncaughtError(params) {
const error = new Error(params.message);
error.stack = params.stack;
this._page.emit(Events.Page.PageError, error);
}
_onConsole({type, args, executionContextId, location}) {
const context = this.executionContextById(executionContextId);
this._page._addConsoleMessage(type, args.map(arg => context._createHandle(arg)), location);
}
_onDialogOpened(params) {
this._page.emit(Events.Page.Dialog, new dialog.Dialog(
params.type as dialog.DialogType,
params.message,
async (accept: boolean, promptText?: string) => {
await this._session.send('Page.handleDialog', { dialogId: params.dialogId, accept, promptText }).catch(debugError);
},
params.defaultValue));
}
_onBindingCalled(event: Protocol.Page.bindingCalledPayload) {
const context = this.executionContextById(event.executionContextId);
this._page._onBindingCalled(event.payload, context);
}
async _onFileChooserOpened({executionContextId, element}) {
const context = this.executionContextById(executionContextId);
const handle = context._createHandle(element).asElement()!;
this._page._onFileChooserOpened(handle);
}
async _exposeBinding(name: string, bindingFunction: string) {
await this._session.send('Page.addBinding', {name: name});
await this._session.send('Page.addScriptToEvaluateOnNewDocument', {script: bindingFunction});
await Promise.all(this.frames().map(frame => frame.evaluate(bindingFunction).catch(debugError)));
}
dispose() {
helper.removeEventListeners(this._eventListeners);
}

View file

@ -22,6 +22,7 @@ import * as types from '../types';
import * as frames from '../frames';
import { JugglerSession } from './Connection';
import { FrameManager } from './FrameManager';
import { Protocol } from './protocol';
export class DOMWorldDelegate implements dom.DOMWorldDelegate {
readonly keyboard: input.Keyboard;
@ -92,38 +93,22 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate {
return this._frameManager._page.evaluate(() => ({ width: innerWidth, height: innerHeight }));
}
async screenshot(handle: dom.ElementHandle, options: any = {}): Promise<string | Buffer> {
const clip = await this._session.send('Page.getBoundingBox', {
frameId: this._frameId,
objectId: toRemoteObject(handle).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 handle._scrollIntoViewIfNeeded();
return await this._frameManager._page.screenshot(Object.assign({}, options, {
clip: {
x: clip.x,
y: clip.y,
width: clip.width,
height: clip.height,
},
}));
async screenshot(handle: dom.ElementHandle, options?: types.ElementScreenshotOptions): Promise<Buffer> {
const page = this._frameManager._page;
return page._screenshotter.screenshotElement(handle, options);
}
async setInputFiles(handle: dom.ElementHandle, files: input.FilePayload[]): Promise<void> {
await handle.evaluate(input.setFileInputFunction, files);
}
async adoptElementHandle(handle: dom.ElementHandle, to: dom.DOMWorld): Promise<dom.ElementHandle> {
async adoptElementHandle<T extends Node>(handle: dom.ElementHandle<T>, to: dom.DOMWorld): Promise<dom.ElementHandle<T>> {
assert(false, 'Multiple isolated worlds are not implemented');
return handle;
}
}
function toRemoteObject(handle: dom.ElementHandle): any {
function toRemoteObject(handle: dom.ElementHandle): Protocol.RemoteObject {
return handle._remoteObject;
}

View file

@ -20,11 +20,11 @@ import * as removeFolder from 'rimraf';
import * as childProcess from 'child_process';
import {Connection} from './Connection';
import {Browser} from './Browser';
import {BrowserFetcher} from './BrowserFetcher';
import {BrowserFetcher, BrowserFetcherOptions} from '../browserFetcher';
import * as readline from 'readline';
import * as fs from 'fs';
import * as util from 'util';
import {helper, debugError} from '../helper';
import {helper, debugError, assert} from '../helper';
import {TimeoutError} from '../Errors';
import {WebSocketTransport} from './WebSocketTransport';
@ -227,7 +227,7 @@ export class Launcher {
}
_resolveExecutablePath() {
const browserFetcher = new BrowserFetcher(this._projectRoot, { browser: 'firefox' });
const browserFetcher = createBrowserFetcher(this._projectRoot);
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};
@ -275,3 +275,46 @@ function waitForWSEndpoint(firefoxProcess: import('child_process').ChildProcess,
}
});
}
export function createBrowserFetcher(projectRoot: string, options: BrowserFetcherOptions = {}): BrowserFetcher {
const downloadURLs = {
linux: '%s/builds/firefox/%s/firefox-linux.zip',
mac: '%s/builds/firefox/%s/firefox-mac.zip',
win32: '%s/builds/firefox/%s/firefox-win32.zip',
win64: '%s/builds/firefox/%s/firefox-win64.zip',
};
const defaultOptions = {
path: path.join(projectRoot, '.local-firefox'),
host: 'https://playwrightaccount.blob.core.windows.net',
platform: (() => {
const platform = os.platform();
if (platform === 'darwin')
return 'mac';
if (platform === 'linux')
return 'linux';
if (platform === 'win32')
return os.arch() === 'x64' ? 'win64' : 'win32';
return platform;
})()
};
options = {
...defaultOptions,
...options,
};
assert(!!downloadURLs[options.platform], 'Unsupported platform: ' + options.platform);
return new BrowserFetcher(options.path, options.platform, (platform: string, revision: string) => {
let executablePath = '';
if (platform === 'linux')
executablePath = path.join('firefox', 'firefox');
else if (platform === 'mac')
executablePath = path.join('firefox', 'Nightly.app', 'Contents', 'MacOS', 'firefox');
else if (platform === 'win32' || platform === 'win64')
executablePath = path.join('firefox', 'firefox.exe');
return {
downloadUrl: util.format(downloadURLs[platform], options.host, revision),
executablePath
};
});
}

View file

@ -16,83 +16,68 @@
*/
import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as mime from 'mime';
import * as console from '../console';
import * as dom from '../dom';
import { TimeoutError } from '../Errors';
import * as frames from '../frames';
import { assert, debugError, helper, RegisteredListener } from '../helper';
import * as input from '../input';
import * as js from '../javascript';
import * as network from '../network';
import { Screenshotter } from '../screenshotter';
import { TimeoutSettings } from '../TimeoutSettings';
import { BrowserContext, Target } from './Browser';
import { JugglerSession, JugglerSessionEvents } from './Connection';
import * as types from '../types';
import { BrowserContext } from './Browser';
import { JugglerSession } from './Connection';
import { Events } from './events';
import { Accessibility } from './features/accessibility';
import { Interception } from './features/interception';
import { FrameManager, FrameManagerEvents, normalizeWaitUntil } from './FrameManager';
import { RawMouseImpl, RawKeyboardImpl } from './Input';
import { RawKeyboardImpl, RawMouseImpl } from './Input';
import { NavigationWatchdog } from './NavigationWatchdog';
import { NetworkManager, NetworkManagerEvents } from './NetworkManager';
import * as input from '../input';
import * as types from '../types';
import * as js from '../javascript';
import * as dom from '../dom';
import * as network from '../network';
import * as frames from '../frames';
import * as dialog from '../dialog';
import * as console from '../console';
const writeFileAsync = helper.promisify(fs.writeFile);
import { FFScreenshotDelegate } from './Screenshotter';
export class Page extends EventEmitter {
private _timeoutSettings: TimeoutSettings;
private _session: JugglerSession;
private _target: Target;
private _browserContext: BrowserContext;
private _keyboard: input.Keyboard;
private _mouse: input.Mouse;
readonly accessibility: Accessibility;
readonly interception: Interception;
private _closed: boolean;
private _closedCallback: () => void;
private _closedPromise: Promise<void>;
private _disconnected = false;
private _disconnectedCallback: (e: Error) => void;
private _disconnectedPromise: Promise<Error>;
private _pageBindings: Map<string, Function>;
private _networkManager: NetworkManager;
_frameManager: FrameManager;
_javascriptEnabled = true;
private _eventListeners: RegisteredListener[];
private _viewport: Viewport;
private _disconnectPromise: Promise<Error>;
private _viewport: types.Viewport;
private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>();
_screenshotter: Screenshotter;
static async create(session: JugglerSession, 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'),
session.send('Page.setInterceptFileChooserDialog', { enabled: true })
]);
if (defaultViewport)
await page.setViewport(defaultViewport);
return page;
}
constructor(session: JugglerSession, target: Target) {
constructor(session: JugglerSession, browserContext: BrowserContext) {
super();
this._timeoutSettings = new TimeoutSettings();
this._session = session;
this._target = target;
this._browserContext = browserContext;
this._keyboard = new input.Keyboard(new RawKeyboardImpl(session));
this._mouse = new input.Mouse(new RawMouseImpl(session), this._keyboard);
this.accessibility = new Accessibility(session);
this._closed = false;
this._closedPromise = new Promise(f => this._closedCallback = f);
this._disconnectedPromise = new Promise(f => this._disconnectedCallback = f);
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.interception = new Interception(this._networkManager);
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._session, 'Page.fileChooserOpened', this._onFileChooserOpened.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)),
@ -104,13 +89,23 @@ export class Page extends EventEmitter {
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);
});
this._screenshotter = new Screenshotter(this, new FFScreenshotDelegate(session, this._frameManager), browserContext.browser());
}
_didClose() {
assert(!this._closed, 'Page closed twice');
this._closed = true;
this._frameManager.dispose();
this._networkManager.dispose();
helper.removeEventListeners(this._eventListeners);
this.emit(Events.Page.Close);
this._closedCallback();
}
_didDisconnect() {
assert(!this._disconnected, 'Page disconnected twice');
this._disconnected = true;
this._disconnectedCallback(new Error('Target closed'));
}
async setExtraHTTPHeaders(headers) {
@ -118,8 +113,8 @@ export class Page extends EventEmitter {
}
async emulateMedia(options: {
type?: ''|'screen'|'print',
colorScheme?: 'dark' | 'light' | 'no-preference' }) {
type?: input.MediaType,
colorScheme?: input.MediaColorScheme }) {
assert(!options.type || input.mediaTypes.has(options.type), 'Unsupported media type: ' + options.type);
assert(!options.colorScheme || input.mediaColorSchemes.has(options.colorScheme), 'Unsupported color scheme: ' + options.colorScheme);
await this._session.send('Page.setEmulatedMedia', options);
@ -129,11 +124,7 @@ export class Page extends EventEmitter {
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)));
await this._frameManager._exposeBinding(name, helper.evaluationString(addPageBinding, name));
function addPageBinding(bindingName: string) {
const binding: (string) => void = window[bindingName];
@ -153,8 +144,8 @@ export class Page extends EventEmitter {
}
}
async _onBindingCalled(event: any) {
const {name, seq, args} = JSON.parse(event.payload);
async _onBindingCalled(payload: string, context: js.ExecutionContext) {
const {name, seq, args} = JSON.parse(payload);
let expression = null;
try {
const result = await this._pageBindings.get(name)(...args);
@ -165,7 +156,7 @@ export class Page extends EventEmitter {
else
expression = helper.evaluationString(deliverErrorValue, name, seq, error);
}
this._session.send('Runtime.evaluate', { expression, executionContextId: event.executionContextId }).catch(debugError);
context.evaluate(expression).catch(debugError);
function deliverResult(name: string, seq: number, result: any) {
window[name]['callbacks'].get(seq).resolve(result);
@ -185,12 +176,6 @@ export class Page extends EventEmitter {
}
}
_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<network.Request> {
const {
timeout = this._timeoutSettings.timeout(),
@ -201,7 +186,7 @@ export class Page extends EventEmitter {
if (typeof urlOrPredicate === 'function')
return !!(urlOrPredicate(request));
return false;
}, timeout, this._sessionClosePromise());
}, timeout, this._disconnectedPromise);
}
async waitForResponse(urlOrPredicate: (string | Function), options: { timeout?: number; } | undefined = {}): Promise<network.Response> {
@ -214,7 +199,7 @@ export class Page extends EventEmitter {
if (typeof urlOrPredicate === 'function')
return !!(urlOrPredicate(response));
return false;
}, timeout, this._sessionClosePromise());
}, timeout, this._disconnectedPromise);
}
setDefaultNavigationTimeout(timeout: number) {
@ -242,7 +227,7 @@ export class Page extends EventEmitter {
await this._session.send('Page.setCacheDisabled', {cacheDisabled: !enabled});
}
async emulate(options: { viewport: Viewport; userAgent: string; }) {
async emulate(options: { viewport: types.Viewport; userAgent: string; }) {
await Promise.all([
this.setViewport(options.viewport),
this.setUserAgent(options.userAgent),
@ -250,20 +235,14 @@ export class Page extends EventEmitter {
}
browserContext(): BrowserContext {
return this._target.browserContext();
}
_onUncaughtError(params) {
const error = new Error(params.message);
error.stack = params.stack;
this.emit(Events.Page.PageError, error);
return this._browserContext;
}
viewport() {
return this._viewport;
}
async setViewport(viewport: Viewport) {
async setViewport(viewport: types.Viewport) {
const {
width,
height,
@ -275,8 +254,8 @@ export class Page extends EventEmitter {
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;
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();
@ -288,7 +267,7 @@ export class Page extends EventEmitter {
}
browser() {
return this._target.browser();
return this._browserContext.browser();
}
url() {
@ -299,16 +278,6 @@ export class Page extends EventEmitter {
return this._frameManager.frames();
}
_onDialogOpened(params) {
this.emit(Events.Page.Dialog, new dialog.Dialog(
params.type as dialog.DialogType,
params.message,
async (accept: boolean, promptText?: string) => {
await this._session.send('Page.handleDialog', { dialogId: params.dialogId, accept, promptText }).catch(debugError);
},
params.defaultValue));
}
mainFrame(): frames.Frame {
return this._frameManager.mainFrame();
}
@ -419,26 +388,8 @@ export class Page extends EventEmitter {
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};
}
screenshot(options: types.ScreenshotOptions = {}): Promise<Buffer> {
return this._screenshotter.screenshotPage(options);
}
evaluate: types.Evaluate = (pageFunction, ...args) => {
@ -530,12 +481,13 @@ export class Page extends EventEmitter {
}
async close(options: any = {}) {
assert(!this._disconnected, 'Protocol error: Connection closed. Most likely the page has been closed.');
const {
runBeforeUnload = false,
} = options;
await this._session.send('Page.close', { runBeforeUnload });
if (!runBeforeUnload)
await this._target._isClosedPromise;
await this._closedPromise;
}
async content() {
@ -546,9 +498,12 @@ export class Page extends EventEmitter {
return await this._frameManager.mainFrame().setContent(html);
}
_onConsole({type, args, executionContextId, location}) {
const context = this._frameManager.executionContextById(executionContextId);
this.emit(Events.Page.Console, new console.ConsoleMessage(type, undefined, args.map(arg => context._createHandle(arg)), location));
_addConsoleMessage(type: string, args: js.JSHandle[], location: console.ConsoleMessageLocation) {
if (!this.listenerCount(Events.Page.Console)) {
args.forEach(arg => arg.dispose());
return;
}
this.emit(Events.Page.Console, new console.ConsoleMessage(type, undefined, args, location));
}
isClosed(): boolean {
@ -568,11 +523,11 @@ export class Page extends EventEmitter {
});
}
async _onFileChooserOpened({executionContextId, element}) {
if (!this._fileChooserInterceptors.size)
async _onFileChooserOpened(handle: dom.ElementHandle) {
if (!this._fileChooserInterceptors.size) {
await handle.dispose();
return;
const context = this._frameManager.executionContextById(executionContextId);
const handle = context._createHandle(element).asElement()!;
}
const interceptors = Array.from(this._fileChooserInterceptors);
this._fileChooserInterceptors.clear();
const multiple = await handle.evaluate((element: HTMLInputElement) => !!element.multiple);
@ -583,34 +538,6 @@ export class Page extends EventEmitter {
}
}
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;
}
type FileChooser = {
element: dom.ElementHandle,
multiple: boolean

View file

@ -15,24 +15,28 @@
* limitations under the License.
*/
import { Browser } from './Browser';
import { BrowserFetcher } from './BrowserFetcher';
import { BrowserFetcher, BrowserFetcherOptions, OnProgressCallback, BrowserFetcherRevisionInfo } from '../browserFetcher';
import { ConnectionTransport } from '../ConnectionTransport';
import { DeviceDescriptors } from '../DeviceDescriptors';
import * as Errors from '../Errors';
import { Launcher } from './Launcher';
import {download, RevisionInfo} from '../download';
import { Launcher, createBrowserFetcher } from './Launcher';
export class Playwright {
private _projectRoot: string;
private _launcher: Launcher;
readonly _revision: string;
downloadBrowser: (options?: { onProgress?: (downloadedBytes: number, totalBytes: number) => void; }) => Promise<RevisionInfo>;
constructor(projectRoot: string, preferredRevision: string) {
this._projectRoot = projectRoot;
this._launcher = new Launcher(projectRoot, preferredRevision);
this._revision = preferredRevision;
this.downloadBrowser = download.bind(null, this.createBrowserFetcher(), preferredRevision, 'Chromium');
}
async downloadBrowser(options?: BrowserFetcherOptions & { onProgress?: OnProgressCallback }): Promise<BrowserFetcherRevisionInfo> {
const fetcher = this.createBrowserFetcher(options);
const revisionInfo = fetcher.revisionInfo(this._revision);
await fetcher.download(this._revision, options ? options.onProgress : undefined);
return revisionInfo;
}
launch(options: any): Promise<Browser> {
@ -65,7 +69,7 @@ export class Playwright {
return this._launcher.defaultArgs(options);
}
createBrowserFetcher(options?: any | undefined): BrowserFetcher {
return new BrowserFetcher(this._projectRoot, { browser: 'firefox', ...options });
createBrowserFetcher(options?: BrowserFetcherOptions): BrowserFetcher {
return createBrowserFetcher(this._projectRoot, options);
}
}

View file

@ -0,0 +1,42 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import { ScreenshotterDelegate } from '../screenshotter';
import * as types from '../types';
import * as dom from '../dom';
import { JugglerSession } from './Connection';
import { FrameManager } from './FrameManager';
export class FFScreenshotDelegate implements ScreenshotterDelegate {
private _session: JugglerSession;
private _frameManager: FrameManager;
constructor(session: JugglerSession, frameManager: FrameManager) {
this._session = session;
this._frameManager = frameManager;
}
getBoundingBox(handle: dom.ElementHandle<Node>): Promise<types.Rect | null> {
const frameId = this._frameManager._frameData(handle.executionContext().frame()).frameId;
return this._session.send('Page.getBoundingBox', {
frameId,
objectId: handle._remoteObject.objectId,
});
}
canCaptureOutsideViewport(): boolean {
return true;
}
async setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise<void> {
}
async screenshot(format: 'png' | 'jpeg', options: types.ScreenshotOptions): Promise<Buffer> {
const { data } = await this._session.send('Page.screenshot', {
mimeType: ('image/' + format) as ('image/png' | 'image/jpeg'),
fullPage: options.fullPage,
clip: options.clip,
});
return Buffer.from(data, 'base64');
}
}

View file

@ -4,7 +4,7 @@
export { TimeoutError } from '../Errors';
export { Keyboard, Mouse } from '../input';
export { Browser, BrowserContext } from './Browser';
export { BrowserFetcher } from './BrowserFetcher';
export { BrowserFetcher } from '../browserFetcher';
export { Dialog } from '../dialog';
export { ExecutionContext, JSHandle } from '../javascript';
export { ElementHandle } from '../dom';

View file

@ -122,12 +122,12 @@ export class Frame {
return context.evaluate(pageFunction, ...args as any);
}
async $(selector: string | types.Selector): Promise<dom.ElementHandle | null> {
async $(selector: string | types.Selector): Promise<dom.ElementHandle<Element> | null> {
const domWorld = await this._mainDOMWorld();
return domWorld.$(types.clearSelector(selector));
}
async $x(expression: string): Promise<dom.ElementHandle[]> {
async $x(expression: string): Promise<dom.ElementHandle<Element>[]> {
const domWorld = await this._mainDOMWorld();
return domWorld.$$('xpath=' + expression);
}
@ -142,7 +142,7 @@ export class Frame {
return domWorld.$$eval(selector, pageFunction, ...args as any);
}
async $$(selector: string | types.Selector): Promise<dom.ElementHandle[]> {
async $$(selector: string | types.Selector): Promise<dom.ElementHandle<Element>[]> {
const domWorld = await this._mainDOMWorld();
return domWorld.$$(types.clearSelector(selector));
}

View file

@ -14,6 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as debug from 'debug';
import { TimeoutError } from './Errors';
@ -22,7 +23,7 @@ export const debugError = debug(`playwright:error`);
export type RegisteredListener = {
emitter: NodeJS.EventEmitter;
eventName: (string | symbol);
handler: (_: any) => void;
handler: (...args: any[]) => void;
};
class Helper {
@ -62,7 +63,7 @@ class Helper {
static addEventListener(
emitter: NodeJS.EventEmitter,
eventName: (string | symbol),
handler: (_: any) => void): RegisteredListener {
handler: (...args: any[]) => void): RegisteredListener {
emitter.on(eventName, handler);
return { emitter, eventName, handler };
}
@ -70,7 +71,7 @@ class Helper {
static removeEventListeners(listeners: Array<{
emitter: NodeJS.EventEmitter;
eventName: (string | symbol);
handler: (_: any) => void;
handler: (...args: any[]) => void;
}>) {
for (const listener of listeners)
listener.emitter.removeListener(listener.eventName, listener.handler);

View file

@ -17,9 +17,11 @@ class Injected {
this.engines.set(engine.name, engine);
}
querySelector(selector: string, root: SelectorRoot): Element | undefined {
querySelector(selector: string, root: Node): Element | undefined {
const parsed = this._parseSelector(selector);
let element = root;
if (!root['querySelector'])
throw new Error('Node is not queryable.');
let element = root as SelectorRoot;
for (const { engine, selector } of parsed) {
const next = engine.query((element as Element).shadowRoot || element, selector);
if (!next)
@ -29,9 +31,11 @@ class Injected {
return element as Element;
}
querySelectorAll(selector: string, root: SelectorRoot): Element[] {
querySelectorAll(selector: string, root: Node): Element[] {
const parsed = this._parseSelector(selector);
let set = new Set<SelectorRoot>([ root ]);
if (!root['querySelectorAll'])
throw new Error('Node is not queryable.');
let set = new Set<SelectorRoot>([ root as SelectorRoot ]);
for (const { engine, selector } of parsed) {
const newSet = new Set<Element>();
for (const prev of set) {

View file

@ -287,9 +287,10 @@ export class Mouse {
}
}
export const selectFunction = (element: HTMLSelectElement, ...optionsToSelect: (Node | SelectOption)[]) => {
if (element.nodeName.toLowerCase() !== 'select')
export const selectFunction = (node: Node, ...optionsToSelect: (Node | SelectOption)[]) => {
if (node.nodeName.toLowerCase() !== 'select')
throw new Error('Element is not a <select> element.');
const element = node as HTMLSelectElement;
const options = Array.from(element.options);
element.value = undefined;
@ -315,9 +316,10 @@ export const selectFunction = (element: HTMLSelectElement, ...optionsToSelect: (
return options.filter(option => option.selected).map(option => option.value);
};
export const fillFunction = (element: HTMLElement) => {
if (element.nodeType !== Node.ELEMENT_NODE)
export const fillFunction = (node: Node) => {
if (node.nodeType !== Node.ELEMENT_NODE)
return 'Node is not of type HTMLElement';
const element = node as HTMLElement;
if (!element.isConnected)
return 'Element is not attached to the DOM';
if (!element.ownerDocument || !element.ownerDocument.defaultView)
@ -328,7 +330,6 @@ export const fillFunction = (element: HTMLElement) => {
return 'Element is hidden';
if (!element.offsetParent && element.tagName !== 'BODY')
return 'Element is not visible';
if (element.nodeName.toLowerCase() === 'input') {
const input = element as HTMLInputElement;
const type = input.getAttribute('type') || '';
@ -397,5 +398,7 @@ export type FilePayload = {
data: string
};
export const mediaTypes = new Set(['screen', 'print']);
export const mediaColorSchemes = new Set(['dark', 'light', 'no-preference']);
export type MediaType = 'screen' | 'print';
export const mediaTypes: Set<MediaType> = new Set(['screen', 'print']);
export type MediaColorScheme = 'dark' | 'light' | 'no-preference';
export const mediaColorSchemes: Set<MediaColorScheme> = new Set(['dark', 'light', 'no-preference']);

View file

@ -10,7 +10,7 @@ export interface ExecutionContextDelegate {
getProperties(handle: JSHandle): Promise<Map<string, JSHandle>>;
releaseHandle(handle: JSHandle): Promise<void>;
handleToString(handle: JSHandle, includeType: boolean): string;
handleJSONValue(handle: JSHandle): Promise<any>;
handleJSONValue<T>(handle: JSHandle<T>): Promise<T>;
}
export class ExecutionContext {
@ -38,7 +38,7 @@ export class ExecutionContext {
}
}
export class JSHandle {
export class JSHandle<T = any> {
readonly _context: ExecutionContext;
readonly _remoteObject: any;
_disposed = false;
@ -52,11 +52,11 @@ export class JSHandle {
return this._context;
}
evaluate: types.EvaluateOn = (pageFunction, ...args) => {
evaluate: types.EvaluateOn<T> = (pageFunction, ...args) => {
return this._context.evaluate(pageFunction, this, ...args);
}
evaluateHandle: types.EvaluateHandleOn = (pageFunction, ...args) => {
evaluateHandle: types.EvaluateHandleOn<T> = (pageFunction, ...args) => {
return this._context.evaluateHandle(pageFunction, this, ...args);
}
@ -76,7 +76,7 @@ export class JSHandle {
return this._context._delegate.getProperties(this);
}
jsonValue(): Promise<any> {
jsonValue(): Promise<T> {
return this._context._delegate.handleJSONValue(this);
}

469
src/page.ts Normal file
View file

@ -0,0 +1,469 @@
/**
* 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 * as console from './console';
import * as dom from './dom';
import * as frames from './frames';
import { assert, debugError, helper } from './helper';
import * as input from './input';
import * as js from './javascript';
import * as network from './network';
import { Screenshotter, ScreenshotterDelegate } from './screenshotter';
import { TimeoutSettings } from './TimeoutSettings';
import * as types from './types';
import { Events } from './events';
export interface PageDelegate {
readonly rawMouse: input.RawMouse;
readonly rawKeyboard: input.RawKeyboard;
readonly screenshotterDelegate: ScreenshotterDelegate;
mainFrame(): frames.Frame;
frames(): frames.Frame[];
reload(options?: frames.NavigateOptions): Promise<network.Response | null>;
goBack(options?: frames.NavigateOptions): Promise<network.Response | null>;
goForward(options?: frames.NavigateOptions): Promise<network.Response | null>;
exposeBinding(name: string, bindingFunction: string): Promise<void>;
evaluateOnNewDocument(source: string): Promise<void>;
closePage(runBeforeUnload: boolean): Promise<void>;
setExtraHTTPHeaders(extraHTTPHeaders: network.Headers): Promise<void>;
setUserAgent(userAgent: string): Promise<void>;
setJavaScriptEnabled(enabled: boolean): Promise<void>;
setBypassCSP(enabled: boolean): Promise<void>;
setViewport(viewport: types.Viewport): Promise<void>;
setEmulateMedia(mediaType: input.MediaType | null, mediaColorScheme: input.MediaColorScheme | null): Promise<void>;
setCacheEnabled(enabled: boolean): Promise<void>;
}
interface BrowserContextInterface<Browser> {
browser(): Browser;
}
type PageState = {
viewport: types.Viewport | null;
userAgent: string | null;
mediaType: input.MediaType | null;
mediaColorScheme: input.MediaColorScheme | null;
javascriptEnabled: boolean | null;
extraHTTPHeaders: network.Headers | null;
bypassCSP: boolean | null;
cacheEnabled: boolean | null;
};
export type FileChooser = {
element: dom.ElementHandle,
multiple: boolean
};
export class Page<Browser, BrowserContext extends BrowserContextInterface<Browser>> extends EventEmitter {
private _closed = false;
private _closedCallback: () => void;
private _closedPromise: Promise<void>;
private _disconnected = false;
private _disconnectedCallback: (e: Error) => void;
private _disconnectedPromise: Promise<Error>;
private _browserContext: BrowserContext;
readonly keyboard: input.Keyboard;
readonly mouse: input.Mouse;
readonly _timeoutSettings: TimeoutSettings;
readonly _delegate: PageDelegate;
readonly _state: PageState;
private _pageBindings = new Map<string, Function>();
readonly _screenshotter: Screenshotter;
private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>();
constructor(delegate: PageDelegate, browserContext: BrowserContext, ignoreHTTPSErrors: boolean) {
super();
this._delegate = delegate;
this._closedPromise = new Promise(f => this._closedCallback = f);
this._disconnectedPromise = new Promise(f => this._disconnectedCallback = f);
this._browserContext = browserContext;
this._state = {
viewport: null,
userAgent: null,
mediaType: null,
mediaColorScheme: null,
javascriptEnabled: null,
extraHTTPHeaders: null,
bypassCSP: null,
cacheEnabled: null,
};
this.keyboard = new input.Keyboard(delegate.rawKeyboard);
this.mouse = new input.Mouse(delegate.rawMouse, this.keyboard);
this._timeoutSettings = new TimeoutSettings();
this._screenshotter = new Screenshotter(this, delegate.screenshotterDelegate, browserContext.browser());
}
_didClose() {
assert(!this._closed, 'Page closed twice');
this._closed = true;
this.emit(Events.Page.Close);
this._closedCallback();
}
_didDisconnect() {
assert(!this._disconnected, 'Page disconnected twice');
this._disconnected = true;
this._disconnectedCallback(new Error('Target closed'));
}
async _onFileChooserOpened(handle: dom.ElementHandle) {
if (!this._fileChooserInterceptors.size) {
await handle.dispose();
return;
}
const interceptors = Array.from(this._fileChooserInterceptors);
this._fileChooserInterceptors.clear();
const multiple = await handle.evaluate((element: HTMLInputElement) => !!element.multiple);
const fileChooser = { element: handle, multiple };
for (const interceptor of interceptors)
interceptor.call(null, fileChooser);
this.emit(Events.Page.FileChooser, fileChooser);
}
async waitForFileChooser(options: { timeout?: number; } = {}): Promise<FileChooser> {
const {
timeout = this._timeoutSettings.timeout(),
} = options;
let callback;
const promise = new Promise<FileChooser>(x => callback = x);
this._fileChooserInterceptors.add(callback);
return helper.waitWithTimeout<FileChooser>(promise, 'waiting for file chooser', timeout).catch(e => {
this._fileChooserInterceptors.delete(callback);
throw e;
});
}
browser(): Browser {
return this._browserContext.browser();
}
browserContext(): BrowserContext {
return this._browserContext;
}
mainFrame(): frames.Frame {
return this._delegate.mainFrame();
}
frames(): frames.Frame[] {
return this._delegate.frames();
}
setDefaultNavigationTimeout(timeout: number) {
this._timeoutSettings.setDefaultNavigationTimeout(timeout);
}
setDefaultTimeout(timeout: number) {
this._timeoutSettings.setDefaultTimeout(timeout);
}
async $(selector: string | types.Selector): Promise<dom.ElementHandle<Element> | null> {
return this.mainFrame().$(selector);
}
evaluateHandle: types.EvaluateHandle = async (pageFunction, ...args) => {
const context = await this.mainFrame().executionContext();
return context.evaluateHandle(pageFunction, ...args as any);
}
$eval: types.$Eval = (selector, pageFunction, ...args) => {
return this.mainFrame().$eval(selector, pageFunction, ...args as any);
}
$$eval: types.$$Eval = (selector, pageFunction, ...args) => {
return this.mainFrame().$$eval(selector, pageFunction, ...args as any);
}
async $$(selector: string | types.Selector): Promise<dom.ElementHandle<Element>[]> {
return this.mainFrame().$$(selector);
}
async $x(expression: string): Promise<dom.ElementHandle<Element>[]> {
return this.mainFrame().$x(expression);
}
async addScriptTag(options: { url?: string; path?: string; content?: string; type?: string; }): Promise<dom.ElementHandle> {
return this.mainFrame().addScriptTag(options);
}
async addStyleTag(options: { url?: string; path?: string; content?: string; }): Promise<dom.ElementHandle> {
return this.mainFrame().addStyleTag(options);
}
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);
await this._delegate.exposeBinding(name, helper.evaluationString(addPageBinding, name));
function addPageBinding(bindingName: string) {
const binding = 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;
};
}
}
setExtraHTTPHeaders(headers: network.Headers) {
this._state.extraHTTPHeaders = {...headers};
return this._delegate.setExtraHTTPHeaders(headers);
}
setUserAgent(userAgent: string) {
this._state.userAgent = userAgent;
return this._delegate.setUserAgent(userAgent);
}
async _onBindingCalled(payload: string, context: js.ExecutionContext) {
const {name, seq, args} = JSON.parse(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);
}
context.evaluate(expression).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);
}
}
_addConsoleMessage(type: string, args: js.JSHandle[], location: console.ConsoleMessageLocation) {
if (!this.listenerCount(Events.Page.Console)) {
args.forEach(arg => arg.dispose());
return;
}
this.emit(Events.Page.Console, new console.ConsoleMessage(type, undefined, args, location));
}
url(): string {
return this.mainFrame().url();
}
content(): Promise<string> {
return this.mainFrame().content();
}
setContent(html: string, options?: frames.NavigateOptions): Promise<void> {
return this.mainFrame().setContent(html, options);
}
goto(url: string, options?: frames.GotoOptions): Promise<network.Response | null> {
return this.mainFrame().goto(url, options);
}
reload(options?: frames.NavigateOptions): Promise<network.Response | null> {
return this._delegate.reload(options);
}
waitForNavigation(options?: frames.NavigateOptions): Promise<network.Response | null> {
return this.mainFrame().waitForNavigation(options);
}
async waitForRequest(urlOrPredicate: (string | Function), options: { timeout?: number; } = {}): Promise<Request> {
const {
timeout = this._timeoutSettings.timeout(),
} = options;
return helper.waitForEvent(this, Events.Page.Request, (request: network.Request) => {
if (helper.isString(urlOrPredicate))
return (urlOrPredicate === request.url());
if (typeof urlOrPredicate === 'function')
return !!(urlOrPredicate(request));
return false;
}, timeout, this._disconnectedPromise);
}
async waitForResponse(urlOrPredicate: (string | Function), options: { timeout?: number; } = {}): Promise<network.Response> {
const {
timeout = this._timeoutSettings.timeout(),
} = options;
return helper.waitForEvent(this, Events.Page.Response, (response: network.Response) => {
if (helper.isString(urlOrPredicate))
return (urlOrPredicate === response.url());
if (typeof urlOrPredicate === 'function')
return !!(urlOrPredicate(response));
return false;
}, timeout, this._disconnectedPromise);
}
goBack(options?: frames.NavigateOptions): Promise<network.Response | null> {
return this._delegate.goBack(options);
}
goForward(options?: frames.NavigateOptions): Promise<network.Response | null> {
return this._delegate.goForward(options);
}
async emulate(options: { viewport: types.Viewport; userAgent: string; }) {
await Promise.all([
this.setViewport(options.viewport),
this.setUserAgent(options.userAgent)
]);
}
async setJavaScriptEnabled(enabled: boolean) {
if (this._state.javascriptEnabled === enabled)
return;
this._state.javascriptEnabled = enabled;
await this._delegate.setJavaScriptEnabled(enabled);
}
async setBypassCSP(enabled: boolean) {
if (this._state.bypassCSP === enabled)
return;
await this._delegate.setBypassCSP(enabled);
}
async emulateMedia(options: { type?: input.MediaType, colorScheme?: input.MediaColorScheme }) {
assert(!options.type || input.mediaTypes.has(options.type), 'Unsupported media type: ' + options.type);
assert(!options.colorScheme || input.mediaColorSchemes.has(options.colorScheme), 'Unsupported color scheme: ' + options.colorScheme);
if (options.type !== undefined)
this._state.mediaType = options.type;
if (options.colorScheme !== undefined)
this._state.mediaColorScheme = options.colorScheme;
await this._delegate.setEmulateMedia(this._state.mediaType, this._state.mediaColorScheme);
}
async setViewport(viewport: types.Viewport) {
const oldIsMobile = this._state.viewport ? !!this._state.viewport.isMobile : false;
const oldHasTouch = this._state.viewport ? !!this._state.viewport.hasTouch : false;
const newIsMobile = !!viewport.isMobile;
const newHasTouch = !!viewport.hasTouch;
this._state.viewport = { ...viewport };
await this._delegate.setViewport(viewport);
if (oldIsMobile !== newIsMobile || oldHasTouch !== newHasTouch)
await this.reload();
}
viewport(): types.Viewport | null {
return this._state.viewport;
}
evaluate: types.Evaluate = (pageFunction, ...args) => {
return this.mainFrame().evaluate(pageFunction, ...args as any);
}
async evaluateOnNewDocument(pageFunction: Function | string, ...args: any[]) {
const source = helper.evaluationString(pageFunction, ...args);
await this._delegate.evaluateOnNewDocument(source);
}
async setCacheEnabled(enabled: boolean = true) {
if (this._state.cacheEnabled === enabled)
return;
this._state.cacheEnabled = enabled;
await this._delegate.setCacheEnabled(enabled);
}
screenshot(options?: types.ScreenshotOptions): Promise<Buffer> {
return this._screenshotter.screenshotPage(options);
}
title(): Promise<string> {
return this.mainFrame().title();
}
async close(options: { runBeforeUnload: (boolean | undefined); } = {runBeforeUnload: undefined}) {
assert(!this._disconnected, 'Protocol error: Connection closed. Most likely the page has been closed.');
const runBeforeUnload = !!options.runBeforeUnload;
await this._delegate.closePage(runBeforeUnload);
if (!runBeforeUnload)
await this._closedPromise;
}
isClosed(): boolean {
return this._closed;
}
click(selector: string | types.Selector, options?: input.ClickOptions) {
return this.mainFrame().click(selector, options);
}
dblclick(selector: string | types.Selector, options?: input.MultiClickOptions) {
return this.mainFrame().dblclick(selector, options);
}
tripleclick(selector: string | types.Selector, options?: input.MultiClickOptions) {
return this.mainFrame().tripleclick(selector, options);
}
fill(selector: string | types.Selector, value: string) {
return this.mainFrame().fill(selector, value);
}
focus(selector: string | types.Selector) {
return this.mainFrame().focus(selector);
}
hover(selector: string | types.Selector, options?: input.PointerActionOptions) {
return this.mainFrame().hover(selector, options);
}
select(selector: string | types.Selector, ...values: (string | dom.ElementHandle | input.SelectOption)[]): Promise<string[]> {
return this.mainFrame().select(selector, ...values);
}
type(selector: string | types.Selector, text: string, options: { delay: (number | undefined); } | undefined) {
return this.mainFrame().type(selector, text, options);
}
waitFor(selectorOrFunctionOrTimeout: (string | number | Function), options: { visible?: boolean; hidden?: boolean; timeout?: number; polling?: string | number; } = {}, ...args: any[]): Promise<js.JSHandle> {
return this.mainFrame().waitFor(selectorOrFunctionOrTimeout, options, ...args);
}
waitForSelector(selector: string | types.Selector, options: types.TimeoutOptions = {}): Promise<dom.ElementHandle | null> {
return this.mainFrame().waitForSelector(selector, options);
}
waitForXPath(xpath: string, options: types.TimeoutOptions = {}): Promise<dom.ElementHandle | null> {
return this.mainFrame().waitForXPath(xpath, options);
}
waitForFunction(pageFunction: Function | string, options: types.WaitForFunctionOptions, ...args: any[]): Promise<js.JSHandle> {
return this.mainFrame().waitForFunction(pageFunction, options, ...args);
}
}

208
src/screenshotter.ts Normal file
View file

@ -0,0 +1,208 @@
/**
* 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 * as mime from 'mime';
import * as dom from './dom';
import { assert, helper } from './helper';
import * as types from './types';
const writeFileAsync = helper.promisify(fs.writeFile);
export interface Page {
viewport(): types.Viewport | null;
setViewport(v: types.Viewport): Promise<void>;
evaluate(f: () => any): Promise<types.Rect>;
}
export interface ScreenshotterDelegate {
getBoundingBox(handle: dom.ElementHandle<Node>): Promise<types.Rect | null>;
canCaptureOutsideViewport(): boolean;
setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise<void>;
screenshot(format: string, options: types.ScreenshotOptions, viewport: types.Viewport): Promise<Buffer>;
}
export class Screenshotter {
private _queue = new TaskQueue();
private _delegate: ScreenshotterDelegate;
private _page: Page;
constructor(page: Page, delegate: ScreenshotterDelegate, browserObject: any) {
this._delegate = delegate;
this._page = page;
this._queue = browserObject[taskQueueSymbol];
if (!this._queue) {
this._queue = new TaskQueue();
browserObject[taskQueueSymbol] = this._queue;
}
}
async screenshotPage(options: types.ScreenshotOptions = {}): Promise<Buffer> {
const format = validateScreeshotOptions(options);
return this._queue.postTask(async () => {
let overridenViewport: types.Viewport | undefined;
const viewport = this._page.viewport();
if (viewport && options.fullPage && !this._delegate.canCaptureOutsideViewport()) {
const fullPage = await this._page.evaluate(() => ({
width: Math.max(
document.body.scrollWidth, document.documentElement.scrollWidth,
document.body.offsetWidth, document.documentElement.offsetWidth,
document.body.clientWidth, document.documentElement.clientWidth
),
height: Math.max(
document.body.scrollHeight, document.documentElement.scrollHeight,
document.body.offsetHeight, document.documentElement.offsetHeight,
document.body.clientHeight, document.documentElement.clientHeight
)
}));
overridenViewport = { ...viewport, ...fullPage };
await this._page.setViewport(overridenViewport);
} else if (options.clip) {
options.clip = trimClipToViewport(viewport, options.clip);
}
const result = await this._screenshot(format, options, overridenViewport || viewport);
if (overridenViewport)
await this._page.setViewport(viewport);
return result;
});
}
async screenshotElement(handle: dom.ElementHandle, options: types.ElementScreenshotOptions = {}): Promise<Buffer> {
const format = validateScreeshotOptions(options);
const rewrittenOptions: types.ScreenshotOptions = { ...options };
return this._queue.postTask(async () => {
let overridenViewport: types.Viewport | undefined;
let boundingBox = await this._delegate.getBoundingBox(handle);
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.');
boundingBox = enclosingIntRect(boundingBox);
const viewport = this._page.viewport();
if (!this._delegate.canCaptureOutsideViewport()) {
if (boundingBox.width > viewport.width || boundingBox.height > viewport.height) {
overridenViewport = {
...viewport,
width: Math.max(viewport.width, boundingBox.width),
height: Math.max(viewport.height, boundingBox.height),
};
await this._page.setViewport(overridenViewport);
}
await handle._scrollIntoViewIfNeeded();
boundingBox = enclosingIntRect(await this._delegate.getBoundingBox(handle));
}
if (!overridenViewport)
rewrittenOptions.clip = boundingBox;
const result = await this._screenshot(format, rewrittenOptions, overridenViewport || viewport);
if (overridenViewport)
await this._page.setViewport(viewport);
return result;
});
}
private async _screenshot(format: 'png' | 'jpeg', options: types.ScreenshotOptions, viewport: types.Viewport): Promise<Buffer> {
const shouldSetDefaultBackground = options.omitBackground && format === 'png';
if (shouldSetDefaultBackground)
await this._delegate.setBackgroundColor({ r: 0, g: 0, b: 0, a: 0});
const buffer = await this._delegate.screenshot(format, options, viewport);
if (shouldSetDefaultBackground)
await this._delegate.setBackgroundColor();
if (options.path)
await writeFileAsync(options.path, buffer);
return buffer;
}
}
const taskQueueSymbol = Symbol('TaskQueue');
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;
}
}
function trimClipToViewport(viewport: types.Viewport | null, clip: types.Rect | null): types.Rect | null {
if (!clip || !viewport)
return clip;
const p1 = { x: Math.min(clip.x, viewport.width), y: Math.min(clip.y, viewport.height) };
const p2 = { x: Math.min(clip.x + clip.width, viewport.width), y: Math.min(clip.y + clip.height, viewport.height) };
const result = { x: p1.x, y: p1.y, width: p2.x - p1.x, height: p2.y - p1.y };
assert(result.width && result.height, 'Clipped area is either empty or outside the viewport');
return result;
}
function validateScreeshotOptions(options: types.ScreenshotOptions): 'png' | 'jpeg' {
let format: 'png' | 'jpeg' | null = null;
// options.type takes precedence over inferring the type from options.path
// because it may be a 0-length file with no extension created beforehand (i.e. as a temp file).
if (options.type) {
assert(options.type === 'png' || options.type === 'jpeg', 'Unknown options.type value: ' + options.type);
format = options.type;
} else if (options.path) {
const mimeType = mime.getType(options.path);
if (mimeType === 'image/png')
format = 'png';
else if (mimeType === 'image/jpeg')
format = 'jpeg';
assert(format, 'Unsupported screenshot mime type: ' + mimeType);
}
if (!format)
format = 'png';
if (options.quality) {
assert(format === 'jpeg', 'options.quality is unsupported for the ' + format + ' screenshots');
assert(typeof options.quality === 'number', 'Expected options.quality to be a number but found ' + (typeof options.quality));
assert(Number.isInteger(options.quality), 'Expected options.quality to be an integer');
assert(options.quality >= 0 && options.quality <= 100, 'Expected options.quality to be between 0 and 100 (inclusive), got ' + options.quality);
}
assert(!options.clip || !options.fullPage, 'options.clip and options.fullPage are exclusive');
if (options.clip) {
assert(typeof options.clip.x === 'number', 'Expected options.clip.x to be a number but found ' + (typeof options.clip.x));
assert(typeof options.clip.y === 'number', 'Expected options.clip.y to be a number but found ' + (typeof options.clip.y));
assert(typeof options.clip.width === 'number', 'Expected options.clip.width to be a number but found ' + (typeof options.clip.width));
assert(typeof options.clip.height === 'number', 'Expected options.clip.height to be a number but found ' + (typeof options.clip.height));
assert(options.clip.width !== 0, 'Expected options.clip.width not to be 0.');
assert(options.clip.height !== 0, 'Expected options.clip.height not to be 0.');
}
return format;
}
function enclosingIntRect(rect: types.Rect): types.Rect {
const x = Math.floor(rect.x + 1e-3);
const y = Math.floor(rect.y + 1e-3);
const x2 = Math.ceil(rect.x + rect.width - 1e-3);
const y2 = Math.ceil(rect.y + rect.height - 1e-3);
return { x, y, width: x2 - x, height: y2 - y };
}

View file

@ -3,17 +3,21 @@
import * as js from './javascript';
import { helper } from './helper';
import * as dom from './dom';
type Boxed<Args extends any[]> = { [Index in keyof Args]: Args[Index] | js.JSHandle };
type Boxed<Args extends any[]> = { [Index in keyof Args]: Args[Index] | js.JSHandle<Args[Index]> };
type PageFunction<Args extends any[], R = any> = string | ((...args: Args) => R | Promise<R>);
type PageFunctionOn<On, Args extends any[], R = any> = string | ((on: On, ...args: Args) => R | Promise<R>);
type Handle<T> = T extends Node ? dom.ElementHandle<T> : js.JSHandle<T>;
type ElementForSelector<T> = T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : Element;
export type Evaluate = <Args extends any[], R>(pageFunction: PageFunction<Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type EvaluateHandle = <Args extends any[]>(pageFunction: PageFunction<Args>, ...args: Boxed<Args>) => Promise<js.JSHandle>;
export type $Eval<S = string | Selector> = <Args extends any[], R>(selector: S, pageFunction: PageFunctionOn<Element, Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type $$Eval<S = string | Selector> = <Args extends any[], R>(selector: S, pageFunction: PageFunctionOn<Element[], Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type EvaluateOn = <Args extends any[], R>(pageFunction: PageFunctionOn<any, Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type EvaluateHandleOn = <Args extends any[]>(pageFunction: PageFunctionOn<any, Args>, ...args: Boxed<Args>) => Promise<js.JSHandle>;
export type EvaluateHandle = <Args extends any[], R>(pageFunction: PageFunction<Args, R>, ...args: Boxed<Args>) => Promise<Handle<R>>;
export type $Eval<O = string | Selector> = <Args extends any[], R, S extends O>(selector: S, pageFunction: PageFunctionOn<ElementForSelector<S>, Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type $$Eval<O = string | Selector> = <Args extends any[], R, S extends O>(selector: S, pageFunction: PageFunctionOn<ElementForSelector<S>[], Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type EvaluateOn<T> = <Args extends any[], R>(pageFunction: PageFunctionOn<T, Args, R>, ...args: Boxed<Args>) => Promise<R>;
export type EvaluateHandleOn<T> = <Args extends any[], R>(pageFunction: PageFunctionOn<T, Args, R>, ...args: Boxed<Args>) => Promise<Handle<R>>;
export type Rect = { x: number, y: number, width: number, height: number };
export type Point = { x: number, y: number };
@ -38,3 +42,24 @@ export function clearSelector(selector: string | Selector): string | Selector {
return selector;
return { selector: selector.selector, visible: selector.visible };
}
export type ElementScreenshotOptions = {
type?: 'png' | 'jpeg',
path?: string,
quality?: number,
omitBackground?: boolean,
};
export type ScreenshotOptions = ElementScreenshotOptions & {
fullPage?: boolean,
clip?: Rect,
};
export type Viewport = {
width: number;
height: number;
deviceScaleFactor?: number;
isMobile?: boolean;
isLandscape?: boolean;
hasTouch?: boolean;
}

View file

@ -17,21 +17,20 @@
import * as childProcess from 'child_process';
import { EventEmitter } from 'events';
import { assert, helper, RegisteredListener } from '../helper';
import { assert, helper, RegisteredListener, debugError } from '../helper';
import { filterCookies, NetworkCookie, rewriteCookies, SetNetworkCookieParam } from '../network';
import { Connection } from './Connection';
import { Page, Viewport } from './Page';
import { Connection, ConnectionEvents, TargetSession } from './Connection';
import { Page } from './Page';
import { Target } from './Target';
import { TaskQueue } from './TaskQueue';
import { Protocol } from './protocol';
import * as types from '../types';
export class Browser extends EventEmitter {
_defaultViewport: Viewport;
private _process: childProcess.ChildProcess;
_screenshotTaskQueue = new TaskQueue();
_connection: Connection;
readonly _defaultViewport: types.Viewport;
private readonly _process: childProcess.ChildProcess;
readonly _connection: Connection;
private _closeCallback: () => Promise<void>;
private _defaultContext: BrowserContext;
private readonly _defaultContext: BrowserContext;
private _contexts = new Map<string, BrowserContext>();
_targets = new Map<string, Target>();
private _eventListeners: RegisteredListener[];
@ -39,7 +38,7 @@ export class Browser extends EventEmitter {
constructor(
connection: Connection,
defaultViewport: Viewport | null,
defaultViewport: types.Viewport | null,
process: childProcess.ChildProcess | null,
closeCallback?: (() => Promise<void>)) {
super();
@ -56,13 +55,10 @@ export class Browser extends EventEmitter {
this._contexts = new Map();
this._eventListeners = [
helper.addEventListener(this._connection, 'Target.targetCreated', this._onTargetCreated.bind(this)),
helper.addEventListener(this._connection, ConnectionEvents.TargetCreated, this._onTargetCreated.bind(this)),
helper.addEventListener(this._connection, 'Target.targetDestroyed', this._onTargetDestroyed.bind(this)),
helper.addEventListener(this._connection, 'Target.didCommitProvisionalTarget', this._onProvisionalTargetCommitted.bind(this)),
];
// Taking multiple screenshots in parallel doesn't work well, so we serialize them.
this._screenshotTaskQueue = new TaskQueue();
}
async userAgent(): Promise<string> {
@ -146,7 +142,7 @@ export class Browser extends EventEmitter {
return contextPages.reduce((acc, x) => acc.concat(x), []);
}
async _onTargetCreated({targetInfo}) {
async _onTargetCreated(session: TargetSession, targetInfo: Protocol.Target.TargetInfo) {
let context = null;
if (targetInfo.browserContextId) {
// FIXME: we don't know about the default context id, so assume that all targets from
@ -159,7 +155,7 @@ export class Browser extends EventEmitter {
}
if (!context)
context = this._defaultContext;
const target = new Target(targetInfo, context);
const target = new Target(session, targetInfo, context);
this._targets.set(targetInfo.targetId, target);
this._privateEvents.emit(BrowserEvents.TargetCreated, target);
}
@ -167,18 +163,29 @@ export class Browser extends EventEmitter {
_onTargetDestroyed({targetId}) {
const target = this._targets.get(targetId);
this._targets.delete(targetId);
target._closedCallback();
target._didClose();
}
_closePage(page: Page) {
this._connection.send('Target.close', {
targetId: Target.fromPage(page)._targetId
}).catch(debugError);
}
async _pages(context: BrowserContext): Promise<Page[]> {
const targets = this.targets().filter(target => target._browserContext === context && target._type === 'page');
const pages = await Promise.all(targets.map(target => target.page()));
return pages.filter(page => !!page);
}
async _activatePage(page: Page): Promise<void> {
await this._connection.send('Target.activate', { targetId: Target.fromPage(page)._targetId });
}
async _onProvisionalTargetCommitted({oldTargetId, newTargetId}) {
const oldTarget = this._targets.get(oldTargetId);
if (!oldTarget._pagePromise)
return;
const page = await oldTarget._pagePromise;
const newTarget = this._targets.get(newTargetId);
const newSession = this._connection.session(newTargetId);
page._swapTargetOnNavigation(newSession, newTarget);
newTarget._pagePromise = oldTarget._pagePromise;
newTarget._swappedIn(oldTarget);
}
disconnect() {
@ -204,17 +211,8 @@ export class BrowserContext {
this._id = contextId;
}
_targets(): Target[] {
return this._browser.targets().filter(target => target.browserContext() === this);
}
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);
pages(): Promise<Page[]> {
return this._browser._pages(this);
}
isIncognito(): boolean {

View file

@ -25,7 +25,8 @@ const debugProtocol = debug('playwright:protocol');
const debugWrappedMessage = require('debug')('wrapped');
export const ConnectionEvents = {
Disconnected: Symbol('ConnectionEvents.Disconnected')
Disconnected: Symbol('ConnectionEvents.Disconnected'),
TargetCreated: Symbol('ConnectionEvents.TargetCreated')
};
export class Connection extends EventEmitter {
@ -99,15 +100,12 @@ export class Connection extends EventEmitter {
_dispatchTargetMessageToSession(object: {method: string, params: any}) {
if (object.method === 'Target.targetCreated') {
const {targetId, type} = object.params.targetInfo;
// FIXME: this is a workaround for cross-origin navigation in WebKit.
// console.log(`[${targetId}] ${object.method}`);
const session = new TargetSession(this, type, targetId);
this._sessions.set(targetId, session);
this.emit(ConnectionEvents.TargetCreated, session, object.params.targetInfo);
} else if (object.method === 'Target.targetDestroyed') {
// console.log(`[${object.params.targetId}] ${object.method}`);
const session = this._sessions.get(object.params.targetId);
if (session) {
// FIXME: this is a workaround for cross-origin navigation in WebKit.
session._onClosed();
this._sessions.delete(object.params.targetId);
}
@ -124,6 +122,7 @@ export class Connection extends EventEmitter {
const oldSession = this._sessions.get(oldTargetId);
if (!oldSession)
throw new Error('Unknown old target: ' + oldTargetId);
oldSession._swappedOut = true;
}
}
@ -146,10 +145,6 @@ export class Connection extends EventEmitter {
this._onClose();
this._transport.close();
}
session(targetId: string) : TargetSession {
return this._sessions.get(targetId);
}
}
export const TargetSessionEvents = {
@ -161,8 +156,7 @@ export class TargetSession extends EventEmitter {
private _callbacks = new Map<number, {resolve:(o: any) => void, reject: (e: Error) => void, error: Error, method: string}>();
private _targetType: string;
private _sessionId: string;
private _out = [];
private _in = [];
_swappedOut = 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;
@ -189,7 +183,6 @@ export class TargetSession extends EventEmitter {
params
};
debugWrappedMessage('SEND ► ' + JSON.stringify(messageObj, null, 2));
this._out.push(messageObj);
// Serialize message before adding callback in case JSON throws.
const message = JSON.stringify(messageObj);
const result = new Promise<Protocol.CommandReturnValues[T]>((resolve, reject) => {
@ -200,7 +193,7 @@ export class TargetSession extends EventEmitter {
}).catch(e => {
// There is a possible race of the connection closure. We may have received
// targetDestroyed notification before response for the command, in that
// case it's safe to swallow the exception.g
// case it's safe to swallow the exception.
const callback = this._callbacks.get(innerId);
assert(!callback, 'Callback was not rejected when target was destroyed.');
});
@ -210,7 +203,6 @@ export class TargetSession extends EventEmitter {
_dispatchMessageFromTarget(message: string) {
const object = JSON.parse(message);
debugWrappedMessage('◀ RECV ' + JSON.stringify(object, null, 2));
this._in.push(object);
if (object.id && this._callbacks.has(object.id)) {
const callback = this._callbacks.get(object.id);
this._callbacks.delete(object.id);
@ -220,14 +212,18 @@ export class TargetSession extends EventEmitter {
callback.resolve(object.result);
} else {
assert(!object.id);
// console.log(`[${this._sessionId}] ${object.method}`);
this.emit(object.method, object.params);
}
}
_onClosed() {
for (const callback of this._callbacks.values())
callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`));
for (const callback of this._callbacks.values()) {
// TODO: make some calls like screenshot catch swapped out error and retry.
if (this._swappedOut)
callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target was swapped out.`));
else
callback.reject(rewriteError(callback.error, `Protocol error (${callback.method}): Target closed.`));
}
this._callbacks.clear();
this._connection = null;
this.emit(TargetSessionEvents.Disconnected);
@ -245,3 +241,7 @@ function rewriteError(error: Error, message: string): Error {
error.message = message;
return error;
}
export function isSwappedOutError(e: Error) {
return e.message.includes('Target was swapped out.');
}

View file

@ -15,7 +15,7 @@
* limitations under the License.
*/
import { TargetSession } from './Connection';
import { TargetSession, isSwappedOutError } from './Connection';
import { helper } from '../helper';
import { valueFromRemoteObject, releaseObject } from './protocolHelper';
import { Protocol } from './protocol';
@ -25,7 +25,7 @@ export const EVALUATION_SCRIPT_URL = '__playwright_evaluation_script__';
const SOURCE_URL_REGEX = /^[\040\t]*\/\/[@#] sourceURL=\s*(\S*?)\s*$/m;
export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
private _globalObjectId?: string;
private _globalObjectId?: Promise<string>;
_session: TargetSession;
_contextId: number;
private _contextDestroyedCallback: () => void;
@ -45,8 +45,6 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
}
async evaluate(context: js.ExecutionContext, 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;
@ -58,18 +56,9 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
emulateUserGesture: true
}).then(response => {
if (response.result.type === 'object' && response.result.className === 'Promise') {
const contextDiscarded = this._executionContextDestroyedPromise.then(() => ({
wasThrown: true,
result: {
description: 'Protocol error: Execution context was destroyed, most likely because of a navigation.'
} as Protocol.Runtime.RemoteObject
}));
return Promise.race([
contextDiscarded,
this._session.send('Runtime.awaitPromise', {
promiseObjectId: response.result.objectId,
returnByValue: false
})
this._executionContextDestroyedPromise.then(() => contextDestroyedResult),
this._awaitPromise(response.result.objectId),
]);
}
return response;
@ -78,32 +67,8 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
throw new Error('Evaluation failed: ' + response.result.description);
if (!returnByValue)
return context._createHandle(response.result);
if (response.result.objectId) {
const serializeFunction = function() {
try {
return JSON.stringify(this);
} catch (e) {
if (e instanceof TypeError)
return void 0;
throw e;
}
};
return this._session.send('Runtime.callFunctionOn', {
// Serialize object using standard JSON implementation to correctly pass 'undefined'.
functionDeclaration: serializeFunction + '\n' + suffix + '\n',
objectId: response.result.objectId,
returnByValue
}).then(serializeResponse => {
if (serializeResponse.wasThrown)
throw new Error('Serialization failed: ' + serializeResponse.result.description);
// This is the case of too long property chain, not serializable to json string.
if (serializeResponse.result.type === 'undefined')
return undefined;
if (serializeResponse.result.type !== 'string')
throw new Error('Unexpected result of JSON.stringify: ' + JSON.stringify(serializeResponse, null, 2));
return JSON.parse(serializeResponse.result.value);
});
}
if (response.result.objectId)
return this._returnObjectByValue(response.result.objectId);
return valueFromRemoteObject(response.result);
}).catch(rewriteError);
}
@ -164,18 +129,9 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
}
return callFunctionOnPromise.then(response => {
if (response.result.type === 'object' && response.result.className === 'Promise') {
const contextDiscarded = this._executionContextDestroyedPromise.then(() => ({
wasThrown: true,
result: {
description: 'Protocol error: Execution context was destroyed, most likely because of a navigation.'
} as Protocol.Runtime.RemoteObject
}));
return Promise.race([
contextDiscarded,
this._session.send('Runtime.awaitPromise', {
promiseObjectId: response.result.objectId,
returnByValue: false
})
this._executionContextDestroyedPromise.then(() => contextDestroyedResult),
this._awaitPromise(response.result.objectId),
]);
}
return response;
@ -184,32 +140,8 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
throw new Error('Evaluation failed: ' + response.result.description);
if (!returnByValue)
return context._createHandle(response.result);
if (response.result.objectId) {
const serializeFunction = function() {
try {
return JSON.stringify(this);
} catch (e) {
if (e instanceof TypeError)
return void 0;
throw e;
}
};
return this._session.send('Runtime.callFunctionOn', {
// Serialize object using standard JSON implementation to correctly pass 'undefined'.
functionDeclaration: serializeFunction + '\n' + suffix + '\n',
objectId: response.result.objectId,
returnByValue
}).then(serializeResponse => {
if (serializeResponse.wasThrown)
throw new Error('Serialization failed: ' + serializeResponse.result.description);
// This is the case of too long property chain, not serializable to json string.
if (serializeResponse.result.type === 'undefined')
return undefined;
if (serializeResponse.result.type !== 'string')
throw new Error('Unexpected result of JSON.stringify: ' + JSON.stringify(serializeResponse, null, 2));
return JSON.parse(serializeResponse.result.value);
});
}
if (response.result.objectId)
return this._returnObjectByValue(response.result.objectId);
return valueFromRemoteObject(response.result);
}).catch(rewriteError);
@ -263,14 +195,64 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
}
}
async _contextGlobalObjectId() {
private _contextGlobalObjectId() {
if (!this._globalObjectId) {
const globalObject = await this._session.send('Runtime.evaluate', { expression: 'this', contextId: this._contextId });
this._globalObjectId = globalObject.result.objectId;
this._globalObjectId = this._session.send('Runtime.evaluate', {
expression: 'this',
contextId: this._contextId
}).catch(e => {
if (isSwappedOutError(e))
throw new Error('Execution context was destroyed, most likely because of a navigation.');
throw e;
}).then(response => {
return response.result.objectId;
});
}
return this._globalObjectId;
}
private _awaitPromise(objectId: Protocol.Runtime.RemoteObjectId) {
return this._session.send('Runtime.awaitPromise', {
promiseObjectId: objectId,
returnByValue: false
}).catch(e => {
if (isSwappedOutError(e))
return contextDestroyedResult;
throw e;
});
}
private _returnObjectByValue(objectId: Protocol.Runtime.RemoteObjectId) {
const serializeFunction = function() {
try {
return JSON.stringify(this);
} catch (e) {
if (e instanceof TypeError)
return void 0;
throw e;
}
};
return this._session.send('Runtime.callFunctionOn', {
// Serialize object using standard JSON implementation to correctly pass 'undefined'.
functionDeclaration: serializeFunction + '\n' + suffix + '\n',
objectId: objectId,
returnByValue: true
}).catch(e => {
if (isSwappedOutError(e))
return contextDestroyedResult;
throw e;
}).then(serializeResponse => {
if (serializeResponse.wasThrown)
throw new Error('Serialization failed: ' + serializeResponse.result.description);
// This is the case of too long property chain, not serializable to json string.
if (serializeResponse.result.type === 'undefined')
return undefined;
if (serializeResponse.result.type !== 'string')
throw new Error('Unexpected result of JSON.stringify: ' + JSON.stringify(serializeResponse, null, 2));
return JSON.parse(serializeResponse.result.value);
});
}
async getProperties(handle: js.JSHandle): Promise<Map<string, js.JSHandle>> {
const response = await this._session.send('Runtime.getProperties', {
objectId: toRemoteObject(handle).objectId,
@ -289,7 +271,7 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
await releaseObject(this._session, toRemoteObject(handle));
}
async handleJSONValue(handle: js.JSHandle): Promise<any> {
async handleJSONValue<T>(handle: js.JSHandle<T>): Promise<T> {
const remoteObject = toRemoteObject(handle);
if (remoteObject.objectId) {
const response = await this._session.send('Runtime.callFunctionOn', {
@ -328,9 +310,16 @@ export class ExecutionContextDelegate implements js.ExecutionContextDelegate {
}
return { value: arg };
}
}
const suffix = `//# sourceURL=${EVALUATION_SCRIPT_URL}`;
const contextDestroyedResult = {
wasThrown: true,
result: {
description: 'Protocol error: Execution context was destroyed, most likely because of a navigation.'
} as Protocol.Runtime.RemoteObject
};
function toRemoteObject(handle: js.JSHandle): Protocol.Runtime.RemoteObject {
return handle._remoteObject as Protocol.Runtime.RemoteObject;
}

View file

@ -30,6 +30,7 @@ import { NetworkManager, NetworkManagerEvents } from './NetworkManager';
import { Page } from './Page';
import { Protocol } from './protocol';
import { DOMWorldDelegate } from './JSHandle';
import * as dialog from '../dialog';
export const FrameManagerEvents = {
FrameNavigatedWithinDocument: Symbol('FrameNavigatedWithinDocument'),
@ -52,23 +53,25 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
_frames: Map<string, frames.Frame>;
_contextIdToContext: Map<number, js.ExecutionContext>;
_isolatedWorlds: Set<string>;
_sessionListeners: RegisteredListener[];
_sessionListeners: RegisteredListener[] = [];
_mainFrame: frames.Frame;
constructor(session: TargetSession, page: Page, timeoutSettings: TimeoutSettings) {
constructor(page: Page, timeoutSettings: TimeoutSettings) {
super();
this._session = session;
this._page = page;
this._networkManager = new NetworkManager(session, this);
this._networkManager = new NetworkManager(this);
this._timeoutSettings = timeoutSettings;
this._frames = new Map();
this._contextIdToContext = new Map();
this._isolatedWorlds = new Set();
this._addSessionListeners();
}
async initialize() {
async initialize(session: TargetSession) {
helper.removeEventListeners(this._sessionListeners);
this.disconnectFromTarget();
this._session = session;
this._addSessionListeners();
this.emit(FrameManagerEvents.TargetSwappedOnNavigation);
const [,{frameTree}] = await Promise.all([
// Page agent must be enabled before Runtime.
this._session.send('Page.enable'),
@ -77,8 +80,17 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
this._handleFrameTree(frameTree);
await Promise.all([
this._session.send('Runtime.enable'),
this._networkManager.initialize(),
this._session.send('Console.enable'),
this._session.send('Dialog.enable'),
this._session.send('Page.setInterceptFileChooserDialog', { enabled: true }),
this._networkManager.initialize(session),
]);
if (this._page._userAgent !== null)
await this._session.send('Page.overrideUserAgent', { value: this._page._userAgent });
if (this._page._emulatedMediaType !== undefined)
await this._session.send('Page.setEmulatedMedia', { media: this._page._emulatedMediaType || '' });
if (!this._page._javascriptEnabled)
await this._session.send('Emulation.setJavaScriptEnabled', { enabled: this._page._javascriptEnabled });
}
_addSessionListeners() {
@ -88,19 +100,14 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
helper.addEventListener(this._session, 'Page.frameDetached', event => this._onFrameDetached(event.frameId)),
helper.addEventListener(this._session, 'Page.frameStoppedLoading', event => this._onFrameStoppedLoading(event.frameId)),
helper.addEventListener(this._session, 'Runtime.executionContextCreated', event => this._onExecutionContextCreated(event.context)),
helper.addEventListener(this._session, 'Page.loadEventFired', event => this._page.emit(Events.Page.Load)),
helper.addEventListener(this._session, 'Console.messageAdded', event => this._onConsoleMessage(event)),
helper.addEventListener(this._session, 'Page.domContentEventFired', event => this._page.emit(Events.Page.DOMContentLoaded)),
helper.addEventListener(this._session, 'Dialog.javascriptDialogOpening', event => this._onDialog(event)),
helper.addEventListener(this._session, 'Page.fileChooserOpened', event => this._onFileChooserOpened(event))
];
}
async _swapTargetOnNavigation(newSession) {
helper.removeEventListeners(this._sessionListeners);
this.disconnectFromTarget();
this._session = newSession;
this._addSessionListeners();
this._networkManager.setSession(newSession);
this.emit(FrameManagerEvents.TargetSwappedOnNavigation);
// this.initialize() will be called by page.
}
disconnectFromTarget() {
for (const context of this._contextIdToContext.values()) {
(context._delegate as ExecutionContextDelegate)._dispose();
@ -284,6 +291,55 @@ export class FrameManager extends EventEmitter implements frames.FrameDelegate {
}, html);
await watchDog.waitForNavigation();
}
async _onConsoleMessage(event: Protocol.Console.messageAddedPayload) {
const { type, level, text, parameters, url, line: lineNumber, column: columnNumber } = event.message;
let derivedType: string = type;
if (type === 'log')
derivedType = level;
else if (type === 'timing')
derivedType = 'timeEnd';
const mainFrameContext = await this.mainFrame().executionContext();
const handles = (parameters || []).map(p => {
let context: js.ExecutionContext | null = null;
if (p.objectId) {
const objectId = JSON.parse(p.objectId);
context = this._contextIdToContext.get(objectId.injectedScriptId);
} else {
context = mainFrameContext;
}
return context._createHandle(p);
});
this._page._addConsoleMessage(derivedType, handles, { url, lineNumber, columnNumber }, handles.length ? undefined : text);
}
_onDialog(event: Protocol.Dialog.javascriptDialogOpeningPayload) {
this._page.emit(Events.Page.Dialog, new dialog.Dialog(
event.type as dialog.DialogType,
event.message,
async (accept: boolean, promptText?: string) => {
await this._session.send('Dialog.handleJavaScriptDialog', { accept, promptText });
},
event.defaultPrompt));
}
async _onFileChooserOpened(event: {frameId: Protocol.Network.FrameId, element: Protocol.Runtime.RemoteObject}) {
const context = await this.frame(event.frameId)._utilityContext();
const handle = context._createHandle(event.element).asElement()!;
this._page._onFileChooserOpened(handle);
}
async setUserAgent(userAgent: string) {
await this._session.send('Page.overrideUserAgent', { value: userAgent });
}
async setEmulatedMedia(type?: string | null) {
await this._session.send('Page.setEmulatedMedia', { media: type || '' });
}
async setJavaScriptEnabled(enabled: boolean) {
await this._session.send('Emulation.setJavaScriptEnabled', { enabled });
}
}
/**

View file

@ -15,8 +15,7 @@
* limitations under the License.
*/
import * as fs from 'fs';
import { debugError, helper, assert } from '../helper';
import { debugError, assert } from '../helper';
import * as input from '../input';
import * as dom from '../dom';
import * as frames from '../frames';
@ -25,8 +24,6 @@ import { TargetSession } from './Connection';
import { FrameManager } from './FrameManager';
import { Protocol } from './protocol';
const writeFileAsync = helper.promisify(fs.writeFile);
export class DOMWorldDelegate implements dom.DOMWorldDelegate {
readonly keyboard: input.Keyboard;
readonly mouse: input.Mouse;
@ -91,16 +88,9 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate {
return this._frameManager._page.evaluate(() => ({ width: innerWidth, height: innerHeight }));
}
async screenshot(handle: dom.ElementHandle, options: any = {}): Promise<string | Buffer> {
const objectId = toRemoteObject(handle).objectId;
this._client.send('DOM.getDocument');
const {nodeId} = await this._client.send('DOM.requestNode', {objectId});
const result = await this._client.send('Page.snapshotNode', {nodeId});
const prefix = 'data:image/png;base64,';
const buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64');
if (options.path)
await writeFileAsync(options.path, buffer);
return buffer;
screenshot(handle: dom.ElementHandle, options?: types.ElementScreenshotOptions): Promise<string | Buffer> {
const page = this._frameManager._page;
return page._screenshotter.screenshotElement(handle, options);
}
async setInputFiles(handle: dom.ElementHandle, files: input.FilePayload[]): Promise<void> {
@ -108,7 +98,7 @@ export class DOMWorldDelegate implements dom.DOMWorldDelegate {
await this._client.send('DOM.setInputFiles', { objectId, files });
}
async adoptElementHandle(handle: dom.ElementHandle, to: dom.DOMWorld): Promise<dom.ElementHandle> {
async adoptElementHandle<T extends Node>(handle: dom.ElementHandle<T>, to: dom.DOMWorld): Promise<dom.ElementHandle<T>> {
assert(false, 'Multiple isolated worlds are not implemented');
return handle;
}

View file

@ -15,12 +15,16 @@
* limitations under the License.
*/
import * as childProcess from 'child_process';
import { debugError, helper } from '../helper';
import { debugError, helper, assert } from '../helper';
import { Browser } from './Browser';
import { BrowserFetcher } from './BrowserFetcher';
import { BrowserFetcher, BrowserFetcherOptions } from '../browserFetcher';
import { Connection } from './Connection';
import { Viewport } from './Page';
import * as types from '../types';
import { PipeTransport } from './PipeTransport';
import { execSync } from 'child_process';
import * as path from 'path';
import * as util from 'util';
import * as os from 'os';
const DEFAULT_ARGS = [
];
@ -127,7 +131,7 @@ export class Launcher {
const transport = new PipeTransport(webkitProcess.stdio[3] as NodeJS.WritableStream, webkitProcess.stdio[4] as NodeJS.ReadableStream);
connection = new Connection('', transport, slowMo);
const browser = new Browser(connection, defaultViewport, webkitProcess, gracefullyCloseWebkit);
await browser._waitForTarget(t => t.type() === 'page');
await browser._waitForTarget(t => t._type === 'page');
return browser;
} catch (e) {
killWebKit();
@ -168,7 +172,7 @@ export class Launcher {
}
_resolveExecutablePath(): { executablePath: string; missingText: string | null; } {
const browserFetcher = new BrowserFetcher(this._projectRoot);
const browserFetcher = createBrowserFetcher(this._projectRoot);
const revisionInfo = browserFetcher.revisionInfo(this._preferredRevision);
const missingText = !revisionInfo.local ? `WebKit revision is not downloaded. Run "npm install" or "yarn install"` : null;
return {executablePath: revisionInfo.executablePath, missingText};
@ -186,6 +190,51 @@ export type LauncherLaunchOptions = {
headless?: boolean,
dumpio?: boolean,
env?: {[key: string]: string} | undefined,
defaultViewport?: Viewport | null,
defaultViewport?: types.Viewport | null,
slowMo?: number,
};
let cachedMacVersion = undefined;
function getMacVersion() {
if (!cachedMacVersion) {
const [major, minor] = execSync('sw_vers -productVersion').toString('utf8').trim().split('.');
cachedMacVersion = major + '.' + minor;
}
return cachedMacVersion;
}
export function createBrowserFetcher(projectRoot: string, options: BrowserFetcherOptions = {}): BrowserFetcher {
const downloadURLs = {
linux: '%s/builds/webkit/%s/minibrowser-linux.zip',
mac: '%s/builds/webkit/%s/minibrowser-mac-%s.zip',
};
const defaultOptions = {
path: path.join(projectRoot, '.local-webkit'),
host: 'https://playwrightaccount.blob.core.windows.net',
platform: (() => {
const platform = os.platform();
if (platform === 'darwin')
return 'mac';
if (platform === 'linux')
return 'linux';
if (platform === 'win32')
return 'linux'; // Windows gets linux binaries and uses WSL
return platform;
})()
};
options = {
...defaultOptions,
...options,
};
assert(!!downloadURLs[options.platform], 'Unsupported platform: ' + options.platform);
return new BrowserFetcher(options.path, options.platform, (platform: string, revision: string) => {
return {
downloadUrl: (platform === 'mac') ?
util.format(downloadURLs[platform], options.host, revision, getMacVersion()) :
util.format(downloadURLs[platform], options.host, revision),
executablePath: 'pw_run.sh',
};
});
}

View file

@ -31,7 +31,7 @@ export const NetworkManagerEvents = {
};
export class NetworkManager extends EventEmitter {
private _sesssion: TargetSession;
private _session: TargetSession;
private _frameManager: FrameManager;
private _requestIdToRequest = new Map<string, InterceptableRequest>();
private _extraHTTPHeaders: network.Headers = {};
@ -39,30 +39,22 @@ export class NetworkManager extends EventEmitter {
private _userCacheDisabled = false;
private _sessionListeners: RegisteredListener[] = [];
constructor(client: TargetSession, frameManager: FrameManager) {
constructor(frameManager: FrameManager) {
super();
this._sesssion = client;
this._frameManager = frameManager;
this._sesssion.on('Network.requestWillBeSent', this._onRequestWillBeSent.bind(this));
this._sesssion.on('Network.responseReceived', this._onResponseReceived.bind(this));
this._sesssion.on('Network.loadingFinished', this._onLoadingFinished.bind(this));
this._sesssion.on('Network.loadingFailed', this._onLoadingFailed.bind(this));
}
setSession(newSession: TargetSession) {
async initialize(session: TargetSession) {
helper.removeEventListeners(this._sessionListeners);
this._sesssion = newSession;
this._session = session;
this._sessionListeners = [
helper.addEventListener(this._sesssion, 'Network.requestWillBeSent', this._onRequestWillBeSent.bind(this)),
helper.addEventListener(this._sesssion, 'Network.responseReceived', this._onResponseReceived.bind(this)),
helper.addEventListener(this._sesssion, 'Network.loadingFinished', this._onLoadingFinished.bind(this)),
helper.addEventListener(this._sesssion, 'Network.loadingFailed', this._onLoadingFailed.bind(this)),
helper.addEventListener(this._session, 'Network.requestWillBeSent', this._onRequestWillBeSent.bind(this)),
helper.addEventListener(this._session, 'Network.responseReceived', this._onResponseReceived.bind(this)),
helper.addEventListener(this._session, 'Network.loadingFinished', this._onLoadingFinished.bind(this)),
helper.addEventListener(this._session, 'Network.loadingFailed', this._onLoadingFailed.bind(this)),
];
}
async initialize() {
await this._sesssion.send('Network.enable');
await this._session.send('Network.enable');
await this._session.send('Network.setExtraHTTPHeaders', { headers: this._extraHTTPHeaders });
}
async setExtraHTTPHeaders(extraHTTPHeaders: { [s: string]: string; }) {
@ -72,7 +64,7 @@ export class NetworkManager extends EventEmitter {
assert(helper.isString(value), `Expected value of header "${key}" to be String, but "${typeof value}" is found.`);
this._extraHTTPHeaders[key.toLowerCase()] = value;
}
await this._sesssion.send('Network.setExtraHTTPHeaders', { headers: this._extraHTTPHeaders });
await this._session.send('Network.setExtraHTTPHeaders', { headers: this._extraHTTPHeaders });
}
extraHTTPHeaders(): { [s: string]: string; } {
@ -85,7 +77,7 @@ export class NetworkManager extends EventEmitter {
}
async _updateProtocolCacheDisabled() {
await this._sesssion.send('Network.setResourceCachingDisabled', {
await this._session.send('Network.setResourceCachingDisabled', {
disabled: this._userCacheDisabled
});
}
@ -109,7 +101,7 @@ export class NetworkManager extends EventEmitter {
_createResponse(request: InterceptableRequest, responsePayload: Protocol.Network.Response): network.Response {
const remoteAddress: network.RemoteAddress = { ip: '', port: 0 };
const getResponseBody = async () => {
const response = await this._sesssion.send('Network.getResponseBody', { requestId: request._requestId });
const response = await this._session.send('Network.getResponseBody', { requestId: request._requestId });
return Buffer.from(response.body, response.base64Encoded ? 'base64' : 'utf8');
};
return new network.Response(request.request, responsePayload.status, responsePayload.statusText, headersObject(responsePayload.headers), remoteAddress, getResponseBody);

View file

@ -16,73 +16,55 @@
*/
import { EventEmitter } from 'events';
import * as fs from 'fs';
import * as mime from 'mime';
import { assert, debugError, helper, RegisteredListener } from '../helper';
import * as console from '../console';
import * as dom from '../dom';
import * as frames from '../frames';
import { assert, helper } from '../helper';
import * as input from '../input';
import { ClickOptions, mediaColorSchemes, mediaTypes, MultiClickOptions } from '../input';
import * as js from '../javascript';
import * as network from '../network';
import { Screenshotter } from '../screenshotter';
import { TimeoutSettings } from '../TimeoutSettings';
import * as types from '../types';
import { Browser, BrowserContext } from './Browser';
import { TargetSession, TargetSessionEvents } from './Connection';
import { TargetSession } from './Connection';
import { Events } from './events';
import { FrameManager, FrameManagerEvents } from './FrameManager';
import { RawKeyboardImpl, RawMouseImpl } from './Input';
import { NetworkManagerEvents } from './NetworkManager';
import { Protocol } from './protocol';
import { Target } from './Target';
import { TaskQueue } from './TaskQueue';
import * as input from '../input';
import * as types from '../types';
import * as frames from '../frames';
import * as js from '../javascript';
import * as dom from '../dom';
import * as network from '../network';
import * as dialog from '../dialog';
import * as console from '../console';
const writeFileAsync = helper.promisify(fs.writeFile);
export type Viewport = {
width: number;
height: number;
}
import { WKScreenshotDelegate } from './Screenshotter';
export class Page extends EventEmitter {
private _closed = false;
private _closedCallback: () => void;
private _closedPromise: Promise<void>;
private _disconnected = false;
private _disconnectedCallback: (e: Error) => void;
private _disconnectedPromise: Promise<Error>;
_session: TargetSession;
private _target: Target;
private _browserContext: BrowserContext;
private _keyboard: input.Keyboard;
private _mouse: input.Mouse;
private _timeoutSettings: TimeoutSettings;
private _frameManager: FrameManager;
private _bootstrapScripts: string[] = [];
_javascriptEnabled = true;
private _viewport: Viewport | null = null;
private _screenshotTaskQueue: TaskQueue;
private _workers = new Map<string, Worker>();
private _disconnectPromise: Promise<Error> | undefined;
private _sessionListeners: RegisteredListener[] = [];
private _emulatedMediaType: string | undefined;
_userAgent: string | null = null;
_emulatedMediaType: string | undefined;
private _viewport: types.Viewport | null = null;
_screenshotter: Screenshotter;
private _fileChooserInterceptors = new Set<(chooser: FileChooser) => void>();
static async create(session: TargetSession, target: Target, defaultViewport: Viewport | null, screenshotTaskQueue: TaskQueue): Promise<Page> {
const page = new Page(session, target, screenshotTaskQueue);
await page._initialize();
if (defaultViewport)
await page.setViewport(defaultViewport);
return page;
}
constructor(session: TargetSession, target: Target, screenshotTaskQueue: TaskQueue) {
constructor(browserContext: BrowserContext) {
super();
this._keyboard = new input.Keyboard(new RawKeyboardImpl(session));
this._mouse = new input.Mouse(new RawMouseImpl(session), this._keyboard);
this._closedPromise = new Promise(f => this._closedCallback = f);
this._disconnectedPromise = new Promise(f => this._disconnectedCallback = f);
this._timeoutSettings = new TimeoutSettings();
this._frameManager = new FrameManager(session, this, this._timeoutSettings);
this._frameManager = new FrameManager(this, this._timeoutSettings);
this._screenshotTaskQueue = screenshotTaskQueue;
this._setSession(session);
this._setTarget(target);
this._browserContext = browserContext;
this._frameManager.on(FrameManagerEvents.FrameAttached, event => this.emit(Events.Page.FrameAttached, event));
this._frameManager.on(FrameManagerEvents.FrameDetached, event => this.emit(Events.Page.FrameDetached, event));
@ -95,86 +77,46 @@ export class Page extends EventEmitter {
networkManager.on(NetworkManagerEvents.RequestFinished, event => this.emit(Events.Page.RequestFinished, event));
}
async _initialize() {
return Promise.all([
this._frameManager.initialize(),
this._session.send('Console.enable'),
this._session.send('Dialog.enable'),
this._session.send('Page.setInterceptFileChooserDialog', { enabled: true }),
]);
_didClose() {
assert(!this._closed, 'Page closed twice');
this._closed = true;
this.emit(Events.Page.Close);
this._closedCallback();
}
_setSession(newSession: TargetSession) {
helper.removeEventListeners(this._sessionListeners);
this._session = newSession;
this._sessionListeners = [
helper.addEventListener(this._session, TargetSessionEvents.Disconnected, () => this._frameManager.disconnectFromTarget()),
helper.addEventListener(this._session, 'Page.loadEventFired', event => this.emit(Events.Page.Load)),
helper.addEventListener(this._session, 'Console.messageAdded', event => this._onConsoleMessage(event)),
helper.addEventListener(this._session, 'Page.domContentEventFired', event => this.emit(Events.Page.DOMContentLoaded)),
helper.addEventListener(this._session, 'Dialog.javascriptDialogOpening', event => this._onDialog(event)),
helper.addEventListener(this._session, 'Page.fileChooserOpened', event => this._onFileChooserOpened(event))
];
_didDisconnect() {
assert(!this._disconnected, 'Page disconnected twice');
this._disconnected = true;
this._frameManager.disconnectFromTarget();
this._disconnectedCallback(new Error('Target closed'));
}
_onDialog(event: Protocol.Dialog.javascriptDialogOpeningPayload) {
this.emit(Events.Page.Dialog, new dialog.Dialog(
event.type as dialog.DialogType,
event.message,
async (accept: boolean, promptText?: string) => {
await this._session.send('Dialog.handleJavaScriptDialog', { accept, promptText });
},
event.defaultPrompt));
}
_setTarget(newTarget: Target) {
this._target = newTarget;
this._target._isClosedPromise.then(() => {
if (this._target !== newTarget)
return;
this.emit(Events.Page.Close);
this._closed = true;
});
}
async _swapTargetOnNavigation(newSession : TargetSession, newTarget : Target) {
this._setSession(newSession);
this._setTarget(newTarget);
await this._frameManager._swapTargetOnNavigation(newSession);
await this._initialize().catch(e => debugError('failed to enable agents after swap: ' + e));
_initialize(session: TargetSession) {
this._session = session;
this._keyboard = new input.Keyboard(new RawKeyboardImpl(session));
this._mouse = new input.Mouse(new RawMouseImpl(session), this._keyboard);
this._screenshotter = new Screenshotter(this, new WKScreenshotDelegate(session), this._browserContext.browser());
return this._frameManager.initialize(session);
}
browser(): Browser {
return this._target.browser();
return this._browserContext.browser();
}
browserContext(): BrowserContext {
return this._target.browserContext();
return this._browserContext;
}
_onTargetCrashed() {
this.emit('error', new Error('Page crashed!'));
}
async _onConsoleMessage(event: Protocol.Console.messageAddedPayload) {
const { type, level, text, parameters, url, line: lineNumber, column: columnNumber } = event.message;
let derivedType: string = type;
if (type === 'log')
derivedType = level;
else if (type === 'timing')
derivedType = 'timeEnd';
const mainFrameContext = await this.mainFrame().executionContext();
const handles = (parameters || []).map(p => {
let context: js.ExecutionContext | null = null;
if (p.objectId) {
const objectId = JSON.parse(p.objectId);
context = this._frameManager._contextIdToContext.get(objectId.injectedScriptId);
} else {
context = mainFrameContext;
}
return context._createHandle(p);
});
this.emit(Events.Page.Console, new console.ConsoleMessage(derivedType, handles.length ? undefined : text, handles, { url, lineNumber, columnNumber }));
_addConsoleMessage(type: string, args: js.JSHandle[], location: console.ConsoleMessageLocation, text?: string) {
if (!this.listenerCount(Events.Page.Console)) {
args.forEach(arg => arg.dispose());
return;
}
this.emit(Events.Page.Console, new console.ConsoleMessage(type, text, args, location));
}
mainFrame(): frames.Frame {
@ -189,11 +131,6 @@ export class Page extends EventEmitter {
return this._frameManager.frames();
}
workers(): Worker[] {
return Array.from(this._workers.values());
}
setDefaultNavigationTimeout(timeout: number) {
this._timeoutSettings.setDefaultNavigationTimeout(timeout);
}
@ -240,7 +177,8 @@ export class Page extends EventEmitter {
}
async setUserAgent(userAgent: string) {
await this._session.send('Page.overrideUserAgent', { value: userAgent });
this._userAgent = userAgent;
this._frameManager.setUserAgent(userAgent);
}
url(): string {
@ -276,12 +214,14 @@ export class Page extends EventEmitter {
}
async _go<T extends keyof Protocol.CommandParameters>(command: T): Promise<network.Response | null> {
const [response, error] = await Promise.all([
const [response] = await Promise.all([
this.waitForNavigation(),
this._session.send(command).then(() => null).catch(e => e),
]);
if (error)
return null;
this._session.send(command).then(() => null),
]).catch(error => {
if (error instanceof Error && error.message.includes(`Protocol error (${command}): Failed to go`))
return [null];
throw error;
});
return response;
}
@ -289,12 +229,6 @@ export class Page extends EventEmitter {
return await this._frameManager.mainFrame().waitForNavigation();
}
_sessionClosePromise() {
if (!this._disconnectPromise)
this._disconnectPromise = new Promise(fulfill => this._session.once(TargetSessionEvents.Disconnected, () => fulfill(new Error('Target closed'))));
return this._disconnectPromise;
}
async waitForRequest(urlOrPredicate: (string | Function), options: { timeout?: number; } = {}): Promise<Request> {
const {
timeout = this._timeoutSettings.timeout(),
@ -305,7 +239,7 @@ export class Page extends EventEmitter {
if (typeof urlOrPredicate === 'function')
return !!(urlOrPredicate(request));
return false;
}, timeout, this._sessionClosePromise());
}, timeout, this._disconnectedPromise);
}
async waitForResponse(urlOrPredicate: (string | Function), options: { timeout?: number; } = {}): Promise<Response> {
@ -318,10 +252,10 @@ export class Page extends EventEmitter {
if (typeof urlOrPredicate === 'function')
return !!(urlOrPredicate(response));
return false;
}, timeout, this._sessionClosePromise());
}, timeout, this._disconnectedPromise);
}
async emulate(options: { viewport: Viewport; userAgent: string; }) {
async emulate(options: { viewport: types.Viewport; userAgent: string; }) {
await Promise.all([
this.setViewport(options.viewport),
this.setUserAgent(options.userAgent)
@ -329,24 +263,23 @@ export class Page extends EventEmitter {
}
async emulateMedia(options: {
type?: string | null,
colorScheme?: 'dark' | 'light' | 'no-preference' | null }) {
type?: input.MediaType | null,
colorScheme?: input.MediaColorScheme | null }) {
assert(!options.type || mediaTypes.has(options.type), 'Unsupported media type: ' + options.type);
assert(!options.colorScheme || mediaColorSchemes.has(options.colorScheme), 'Unsupported color scheme: ' + options.colorScheme);
assert(!options.colorScheme, 'Media feature emulation is not supported');
const media = typeof options.type === 'undefined' ? this._emulatedMediaType : options.type;
await this._session.send('Page.setEmulatedMedia', { media: media || '' });
this._emulatedMediaType = options.type;
this._emulatedMediaType = typeof options.type === 'undefined' ? this._emulatedMediaType : options.type;
this._frameManager.setEmulatedMedia(this._emulatedMediaType);
}
async setViewport(viewport: Viewport) {
async setViewport(viewport: types.Viewport) {
this._viewport = viewport;
const width = viewport.width;
const height = viewport.height;
await this._session.send('Emulation.setDeviceMetricsOverride', { width, height });
await this._session.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: viewport.deviceScaleFactor || 1 });
}
viewport(): Viewport | null {
viewport(): types.Viewport | null {
return this._viewport;
}
@ -362,74 +295,19 @@ export class Page extends EventEmitter {
await this._session.send('Page.setBootstrapScript', { source });
}
async setJavaScriptEnabled(enabled: boolean) {
setJavaScriptEnabled(enabled: boolean) {
if (this._javascriptEnabled === enabled)
return;
this._javascriptEnabled = enabled;
await this._session.send('Emulation.setJavaScriptEnabled', { enabled });
return this._frameManager.setJavaScriptEnabled(enabled);
}
async setCacheEnabled(enabled: boolean = true) {
await this._frameManager.networkManager().setCacheEnabled(enabled);
}
async screenshot(options: ScreenshotOptions = {}): Promise<Buffer | string> {
let screenshotType = null;
// options.type takes precedence over inferring the type from options.path
// because it may be a 0-length file with no extension created beforehand (i.e. as a temp file).
if (options.type) {
assert(options.type === 'png', 'Unknown options.type value: ' + options.type);
screenshotType = options.type;
} else if (options.path) {
const mimeType = mime.getType(options.path);
if (mimeType === 'image/png')
screenshotType = 'png';
assert(screenshotType, 'Unsupported screenshot mime type: ' + mimeType);
}
if (!screenshotType)
screenshotType = 'png';
if (options.quality)
assert(screenshotType === 'jpeg', 'options.quality is unsupported for the ' + screenshotType + ' screenshots');
assert(!options.clip || !options.fullPage, 'options.clip and options.fullPage are exclusive');
if (options.clip) {
assert(typeof options.clip.x === 'number', 'Expected options.clip.x to be a number but found ' + (typeof options.clip.x));
assert(typeof options.clip.y === 'number', 'Expected options.clip.y to be a number but found ' + (typeof options.clip.y));
assert(typeof options.clip.width === 'number', 'Expected options.clip.width to be a number but found ' + (typeof options.clip.width));
assert(typeof options.clip.height === 'number', 'Expected options.clip.height to be a number but found ' + (typeof options.clip.height));
assert(options.clip.width !== 0, 'Expected options.clip.width not to be 0.');
assert(options.clip.height !== 0, 'Expected options.clip.height not to be 0.');
}
return this._screenshotTaskQueue.postTask(this._screenshotTask.bind(this, options));
}
async _screenshotTask(options?: ScreenshotOptions): Promise<Buffer | string> {
const params: Protocol.Page.snapshotRectParameters = { x: 0, y: 0, width: 800, height: 600, coordinateSystem: 'Page' };
if (options.fullPage) {
const pageSize = await this.evaluate(() =>
({
width: document.body.scrollWidth,
height: document.body.scrollHeight
}));
Object.assign(params, pageSize);
} else if (options.clip) {
Object.assign(params, options.clip);
} else if (this._viewport) {
Object.assign(params, this._viewport);
}
const [, result] = await Promise.all([
this._session._connection.send('Target.activate', { targetId: this._target._targetId }),
this._session.send('Page.snapshotRect', params),
]).catch(e => {
debugError('Failed to take screenshot: ' + e);
throw e;
});
const prefix = 'data:image/png;base64,';
const buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64');
if (options.path)
await writeFileAsync(options.path, buffer);
return buffer;
screenshot(options?: types.ScreenshotOptions): Promise<Buffer> {
return this._screenshotter.screenshotPage(options);
}
async title(): Promise<string> {
@ -437,12 +315,9 @@ export class Page extends EventEmitter {
}
async close() {
this.browser()._connection.send('Target.close', {
targetId: this._target._targetId
}).catch(e => {
debugError(e);
});
await this._target._isClosedPromise;
assert(!this._disconnected, 'Protocol error: Connection closed. Most likely the page has been closed.');
this.browser()._closePage(this);
await this._closedPromise;
}
isClosed(): boolean {
@ -462,11 +337,11 @@ export class Page extends EventEmitter {
});
}
async _onFileChooserOpened(event: {frameId: Protocol.Network.FrameId, element: Protocol.Runtime.RemoteObject}) {
if (!this._fileChooserInterceptors.size)
async _onFileChooserOpened(handle: dom.ElementHandle) {
if (!this._fileChooserInterceptors.size) {
await handle.dispose();
return;
const context = await this._frameManager.frame(event.frameId)._utilityContext();
const handle = context._createHandle(event.element).asElement()!;
}
const interceptors = Array.from(this._fileChooserInterceptors);
this._fileChooserInterceptors.clear();
const multiple = await handle.evaluate((element: HTMLInputElement) => !!element.multiple);
@ -529,33 +404,6 @@ export class Page extends EventEmitter {
}
}
type Metrics = {
Timestamp?: number,
Documents?: number,
Frames?: number,
JSEventListeners?: number,
Nodes?: number,
LayoutCount?: number,
RecalcStyleCount?: number,
LayoutDuration?: number,
RecalcStyleDuration?: number,
ScriptDuration?: number,
TaskDuration?: number,
JSHeapUsedSize?: number,
JSHeapTotalSize?: number,
}
type ScreenshotOptions = {
type?: string,
path?: string,
fullPage?: boolean,
clip?: {x: number, y: number, width: number, height: number},
quality?: number,
omitBackground?: boolean,
encoding?: string,
}
type FileChooser = {
element: dom.ElementHandle,
multiple: boolean

View file

@ -15,23 +15,27 @@
* limitations under the License.
*/
import { Browser } from './Browser';
import { BrowserFetcher, BrowserFetcherOptions } from './BrowserFetcher';
import { BrowserFetcher, BrowserFetcherOptions, OnProgressCallback, BrowserFetcherRevisionInfo } from '../browserFetcher';
import { DeviceDescriptors } from '../DeviceDescriptors';
import * as Errors from '../Errors';
import { Launcher, LauncherLaunchOptions } from './Launcher';
import { download, RevisionInfo } from '../download';
import { Launcher, LauncherLaunchOptions, createBrowserFetcher } from './Launcher';
export class Playwright {
private _projectRoot: string;
private _launcher: Launcher;
readonly _revision: string;
downloadBrowser: (options?: { onProgress?: (downloadedBytes: number, totalBytes: number) => void; }) => Promise<RevisionInfo>;
constructor(projectRoot: string, preferredRevision: string) {
this._projectRoot = projectRoot;
this._launcher = new Launcher(projectRoot, preferredRevision);
this._revision = preferredRevision;
this.downloadBrowser = download.bind(null, this.createBrowserFetcher(), preferredRevision, 'WebKit');
}
async downloadBrowser(options?: BrowserFetcherOptions & { onProgress?: OnProgressCallback }): Promise<BrowserFetcherRevisionInfo> {
const fetcher = this.createBrowserFetcher(options);
const revisionInfo = fetcher.revisionInfo(this._revision);
await fetcher.download(this._revision, options ? options.onProgress : undefined);
return revisionInfo;
}
launch(options: (LauncherLaunchOptions) | undefined): Promise<Browser> {
@ -57,7 +61,7 @@ export class Playwright {
return this._launcher.defaultArgs(options);
}
createBrowserFetcher(options?: BrowserFetcherOptions | undefined): BrowserFetcher {
return new BrowserFetcher(this._projectRoot, options);
createBrowserFetcher(options?: BrowserFetcherOptions): BrowserFetcher {
return createBrowserFetcher(this._projectRoot, options);
}
}

View file

@ -0,0 +1,40 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import * as jpeg from 'jpeg-js';
import { PNG } from 'pngjs';
import * as dom from '../dom';
import { ScreenshotterDelegate } from '../screenshotter';
import * as types from '../types';
import { TargetSession } from './Connection';
export class WKScreenshotDelegate implements ScreenshotterDelegate {
private _session: TargetSession;
constructor(session: TargetSession) {
this._session = session;
}
getBoundingBox(handle: dom.ElementHandle<Node>): Promise<types.Rect | null> {
return handle.boundingBox();
}
canCaptureOutsideViewport(): boolean {
return false;
}
async setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise<void> {
// TODO: line below crashes, sort it out.
this._session.send('Page.setDefaultBackgroundColorOverride', { color });
}
async screenshot(format: string, options: types.ScreenshotOptions, viewport: types.Viewport): Promise<Buffer> {
const rect = options.clip || { x: 0, y: 0, width: viewport.width, height: viewport.height };
const result = await this._session.send('Page.snapshotRect', { ...rect, coordinateSystem: options.fullPage ? 'Page' : 'Viewport' });
const prefix = 'data:image/png;base64,';
let buffer = Buffer.from(result.dataURL.substr(prefix.length), 'base64');
if (format === 'jpeg')
buffer = jpeg.encode(PNG.sync.read(buffer)).data;
return buffer;
}
}

View file

@ -15,56 +15,81 @@
* limitations under the License.
*/
import { RegisteredListener } from '../helper';
import { Browser, BrowserContext } from './Browser';
import { BrowserContext } from './Browser';
import { Page } from './Page';
import { Protocol } from './protocol';
import { isSwappedOutError, TargetSession, TargetSessionEvents } from './Connection';
const targetSymbol = Symbol('target');
export class Target {
private _browserContext: BrowserContext;
_targetId: string;
private _type: 'page' | 'service-worker' | 'worker';
_pagePromise: Promise<Page> | null = null;
private _url: string;
_initializedPromise: Promise<boolean>;
_initializedCallback: (value?: unknown) => void;
_isClosedPromise: Promise<void>;
_closedCallback: (value?: unknown) => void;
_isInitialized: boolean;
_eventListeners: RegisteredListener[];
readonly _browserContext: BrowserContext;
readonly _targetId: string;
readonly _type: 'page' | 'service-worker' | 'worker';
private readonly _session: TargetSession;
private _pagePromise: Promise<Page> | null = null;
private _page: Page | null = null;
constructor(targetInfo: Protocol.Target.TargetInfo, browserContext: BrowserContext) {
const {targetId, url, type} = targetInfo;
static fromPage(page: Page): Target {
return (page as any)[targetSymbol];
}
constructor(session: TargetSession, targetInfo: Protocol.Target.TargetInfo, browserContext: BrowserContext) {
const {targetId, type} = targetInfo;
this._session = session;
this._browserContext = browserContext;
this._targetId = targetId;
this._type = type;
/** @type {?Promise<!Page>} */
this._pagePromise = null;
this._url = url;
this._isClosedPromise = new Promise(fulfill => this._closedCallback = fulfill);
}
async page(): Promise<Page | null> {
_didClose() {
if (this._page)
this._page._didClose();
}
async _swappedIn(oldTarget: Target) {
if (!oldTarget._pagePromise)
return;
this._pagePromise = oldTarget._pagePromise;
this._page = oldTarget._page;
// Swapped out target should not be accessed by anyone. Reset page promise so that
// old target does not close the page on connection reset.
oldTarget._pagePromise = null;
oldTarget._page = null;
await this._adoptPage();
}
private async _adoptPage() {
(this._page as any)[targetSymbol] = this;
this._session.once(TargetSessionEvents.Disconnected, () => {
// Once swapped out, we reset _page and won't call _didDisconnect for old session.
if (this._page)
this._page._didDisconnect();
});
await this._page._initialize(this._session).catch(e => {
// Swallow initialization errors due to newer target swap in,
// since we will reinitialize again.
if (!isSwappedOutError(e))
throw e;
});
}
async page(): Promise<Page> {
if (this._type === 'page' && !this._pagePromise) {
const session = this.browser()._connection.session(this._targetId);
this._pagePromise = Page.create(session, this, this.browser()._defaultViewport, this.browser()._screenshotTaskQueue);
const browser = this._browserContext.browser();
// Reference local page variable as _page may be
// cleared on swap.
const page = new Page(this._browserContext);
this._page = page;
this._pagePromise = new Promise(async f => {
await this._adoptPage();
if (browser._defaultViewport)
await page.setViewport(browser._defaultViewport);
f(page);
});
}
return this._pagePromise;
}
url(): string {
return this._url;
}
type(): 'page' | 'service-worker' | 'worker' {
return this._type;
}
browser(): Browser {
return this._browserContext.browser();
}
browserContext(): BrowserContext {
return this._browserContext;
}
}

View file

@ -3,7 +3,7 @@
export { TimeoutError } from '../Errors';
export { Browser, BrowserContext } from './Browser';
export { BrowserFetcher } from './BrowserFetcher';
export { BrowserFetcher } from '../browserFetcher';
export { ExecutionContext, JSHandle } from '../javascript';
export { ElementHandle } from '../dom';
export { Frame } from '../frames';

View file

@ -2,8 +2,11 @@
<script src='./script.js' type='text/javascript'></script>
<style>
body {
height: 100;
height: 100px;
margin: 8px;
border: 0;
background-color: #555;
}
div {
line-height: 18px;

View file

@ -1,14 +1,17 @@
<style>
body {
display: flex;
height: 100%;
height: 500px;
margin: 8px;
}
body iframe {
flex-grow: 1;
flex-shrink: 1;
border: 0;
background-color: green;
}
::-webkit-scrollbar{
display: none;
}

View file

@ -2,13 +2,14 @@
body {
display: flex;
flex-direction: column;
height: 100%;
height: 400px;
margin: 8px;
}
body iframe {
flex-grow: 1;
flex-shrink: 1;
border: 0;
}
</style>
<iframe src='./frame.html' name='uno'></iframe>

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
const { waitEvent } = require('../../../test/utils');
const { waitEvent } = require('../utils');
module.exports.addTests = function({testRunner, expect, playwright, FFOX, CHROME, WEBKIT}) {
const {describe, xdescribe, fdescribe} = testRunner;
@ -227,4 +227,28 @@ module.exports.addTests = function({testRunner, expect, playwright, FFOX, CHROME
await context.close();
});
});
describe('Chromium-Specific Page Tests', function() {
it('Page.setRequestInterception should work with intervention headers', async({server, page}) => {
server.setRoute('/intervention', (req, res) => res.end(`
<script>
document.write('<script src="${server.CROSS_PROCESS_PREFIX}/intervention.js">' + '</scr' + 'ipt>');
</script>
`));
server.setRedirect('/intervention.js', '/redirect.js');
let serverRequest = null;
server.setRoute('/redirect.js', (req, res) => {
serverRequest = req;
res.end('console.log(1);');
});
await page.interception.enable();
page.on('request', request => page.interception.continue(request));
await page.goto(server.PREFIX + '/intervention');
// Check for feature URL substring rather than https://www.chromestatus.com to
// make it work with Edgium.
expect(serverRequest.headers.intervention).toContain('feature/5718547946799104');
});
});
};

View file

@ -18,8 +18,6 @@ const path = require('path');
const os = require('os');
const fs = require('fs');
const util = require('util');
const utils = require('./utils');
const {waitEvent} = utils;
const rmAsync = util.promisify(require('rimraf'));
const mkdtempAsync = util.promisify(fs.mkdtemp);
@ -37,7 +35,7 @@ module.exports.addTests = function({testRunner, expect, playwright, defaultBrows
const headlessOptions = Object.assign({}, defaultBrowserOptions, {
headless: true
});
const extensionPath = path.join(__dirname, 'assets', 'simple-extension');
const extensionPath = path.join(__dirname, '..', 'assets', 'simple-extension');
const extensionOptions = Object.assign({}, defaultBrowserOptions, {
headless: false,
args: [

View file

@ -14,9 +14,17 @@
* limitations under the License.
*/
const utils = require('./utils');
const { waitEvent } = require('../utils');
const util = require('util');
const fs = require('fs');
const path = require('path');
const os = require('os');
const rmAsync = util.promisify(require('rimraf'));
const mkdtempAsync = util.promisify(fs.mkdtemp);
module.exports.addLauncherTests = function({testRunner, expect, defaultBrowserOptions, playwright}) {
const TMP_FOLDER = path.join(os.tmpdir(), 'pptr_tmp_folder-');
module.exports.addTests = function({testRunner, expect, defaultBrowserOptions, playwright}) {
const {describe, xdescribe, fdescribe} = testRunner;
const {it, fit, xit} = testRunner;
const {beforeAll, beforeEach, afterAll, afterEach} = testRunner;
@ -162,7 +170,7 @@ module.exports.addLauncherTests = function({testRunner, expect, defaultBrowserOp
remoteBrowser2.on('disconnected', () => ++disconnectedRemote2);
await Promise.all([
utils.waitEvent(remoteBrowser2, 'disconnected'),
waitEvent(remoteBrowser2, 'disconnected'),
remoteBrowser2.disconnect(),
]);
@ -171,8 +179,8 @@ module.exports.addLauncherTests = function({testRunner, expect, defaultBrowserOp
expect(disconnectedRemote2).toBe(1);
await Promise.all([
utils.waitEvent(remoteBrowser1, 'disconnected'),
utils.waitEvent(originalBrowser, 'disconnected'),
waitEvent(remoteBrowser1, 'disconnected'),
waitEvent(originalBrowser, 'disconnected'),
originalBrowser.close(),
]);
@ -183,33 +191,3 @@ module.exports.addLauncherTests = function({testRunner, expect, defaultBrowserOp
});
};
module.exports.addPageTests = function({testRunner, expect}) {
const {describe, xdescribe, fdescribe} = testRunner;
const {it, fit, xit} = testRunner;
const {beforeAll, beforeEach, afterAll, afterEach} = testRunner;
describe('Chromium-Specific Page Tests', function() {
it('Page.setRequestInterception should work with intervention headers', async({server, page}) => {
server.setRoute('/intervention', (req, res) => res.end(`
<script>
document.write('<script src="${server.CROSS_PROCESS_PREFIX}/intervention.js">' + '</scr' + 'ipt>');
</script>
`));
server.setRedirect('/intervention.js', '/redirect.js');
let serverRequest = null;
server.setRoute('/redirect.js', (req, res) => {
serverRequest = req;
res.end('console.log(1);');
});
await page.interception.enable();
page.on('request', request => page.interception.continue(request));
await page.goto(server.PREFIX + '/intervention');
// Check for feature URL substring rather than https://www.chromestatus.com to
// make it work with Edgium.
expect(serverRequest.headers.intervention).toContain('feature/5718547946799104');
});
});
};

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
const {waitEvent} = require('./utils');
const { waitEvent } = require('../utils');
module.exports.addTests = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
const {describe, xdescribe, fdescribe} = testRunner;

View file

@ -15,7 +15,7 @@
* limitations under the License.
*/
const utils = require('../../../test/utils');
const utils = require('../utils');
const { waitEvent } = utils;
module.exports.addTests = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
@ -23,7 +23,7 @@ module.exports.addTests = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
const {it, fit, xit} = testRunner;
const {beforeAll, beforeEach, afterAll, afterEach} = testRunner;
describe.skip(FFOX || WEBKIT)('Workers', function() {
describe('Workers', function() {
it('Page.workers', async function({page, server}) {
await Promise.all([
new Promise(x => page.workers.once('workercreated', x)),

View file

@ -78,7 +78,7 @@ module.exports.addTests = function({testRunner, expect, playwright, FFOX, CHROME
]);
expect(page.url()).toBe(server.PREFIX + '/wrappedlink.html#clicked');
});
it.skip(FFOX)('should click when one of inline box children is outside of viewport', async({page, server}) => {
it('should click when one of inline box children is outside of viewport', async({page, server}) => {
await page.setContent(`
<style>
i {
@ -93,9 +93,8 @@ module.exports.addTests = function({testRunner, expect, playwright, FFOX, CHROME
});
it('should select the text by triple clicking', async({page, server}) => {
await page.goto(server.PREFIX + '/input/textarea.html');
await page.focus('textarea');
const text = 'This is the text that we are going to try to select. Let\'s see how it goes.';
await page.keyboard.type(text);
await page.fill('textarea', text);
await page.tripleclick('textarea');
expect(await page.evaluate(() => {
const textarea = document.querySelector('textarea');
@ -272,7 +271,7 @@ module.exports.addTests = function({testRunner, expect, playwright, FFOX, CHROME
await frame.click('button');
expect(await frame.evaluate(() => window.result)).toBe('Clicked');
});
it.skip(WEBKIT)('should click the button with deviceScaleFactor set', async({page, server}) => {
it('should click the button with deviceScaleFactor set', async({page, server}) => {
await page.setViewport({width: 400, height: 400, deviceScaleFactor: 5});
expect(await page.evaluate(() => window.devicePixelRatio)).toBe(5);
await page.setContent('<div style="width:100px;height:100px">spacer</div>');
@ -282,37 +281,34 @@ module.exports.addTests = function({testRunner, expect, playwright, FFOX, CHROME
await button.click();
expect(await frame.evaluate(() => window.result)).toBe('Clicked');
});
it('should click the button with relative point', async({page, server}) => {
await page.goto(server.PREFIX + '/input/button.html');
await page.click('button', { relativePoint: { x: 20, y: 10 } });
expect(await page.evaluate(() => result)).toBe('Clicked');
expect(await page.evaluate(() => offsetX)).toBe(20);
expect(await page.evaluate(() => offsetY)).toBe(10);
});
it('should click the button with px border with relative point', async({page, server}) => {
await page.goto(server.PREFIX + '/input/button.html');
await page.$eval('button', button => button.style.borderWidth = '2px');
await page.$eval('button', button => button.style.borderWidth = '8px');
await page.click('button', { relativePoint: { x: 20, y: 10 } });
expect(await page.evaluate(() => result)).toBe('Clicked');
expect(await page.evaluate(() => offsetX)).toBe(20);
expect(await page.evaluate(() => offsetY)).toBe(10);
// Safari reports border-relative offsetX/offsetY.
expect(await page.evaluate(() => offsetX)).toBe(WEBKIT ? 20 + 8 : 20);
expect(await page.evaluate(() => offsetY)).toBe(WEBKIT ? 10 + 8 : 10);
});
it('should click the button with em border with relative point', async({page, server}) => {
await page.goto(server.PREFIX + '/input/button.html');
await page.$eval('button', button => button.style.borderWidth = '2em');
await page.$eval('button', button => button.style.fontSize = '12px');
await page.click('button', { relativePoint: { x: 20, y: 10 } });
expect(await page.evaluate(() => result)).toBe('Clicked');
expect(await page.evaluate(() => offsetX)).toBe(20);
expect(await page.evaluate(() => offsetY)).toBe(10);
// Safari reports border-relative offsetX/offsetY.
expect(await page.evaluate(() => offsetX)).toBe(WEBKIT ? 12 * 2 + 20 : 20);
expect(await page.evaluate(() => offsetY)).toBe(WEBKIT ? 12 * 2 + 10 : 10);
});
it('should click a very large button with relative point', async({page, server}) => {
await page.goto(server.PREFIX + '/input/button.html');
await page.$eval('button', button => button.style.borderWidth = '8px');
await page.$eval('button', button => button.style.height = button.style.width = '2000px');
await page.click('button', { relativePoint: { x: 1900, y: 1910 } });
expect(await page.evaluate(() => window.result)).toBe('Clicked');
expect(await page.evaluate(() => offsetX)).toBe(1900);
expect(await page.evaluate(() => offsetY)).toBe(1910);
// Safari reports border-relative offsetX/offsetY.
expect(await page.evaluate(() => offsetX)).toBe(WEBKIT ? 1900 + 8 : 1900);
expect(await page.evaluate(() => offsetY)).toBe(WEBKIT ? 1910 + 8 : 1910);
});
xit('should click a button in scrolling container with relative point', async({page, server}) => {
await page.goto(server.PREFIX + '/input/button.html');
@ -332,7 +328,7 @@ module.exports.addTests = function({testRunner, expect, playwright, FFOX, CHROME
expect(await page.evaluate(() => offsetY)).toBe(1910);
});
it.skip(FFOX || WEBKIT)('should update modifiers correctly', async({page, server}) => {
it('should update modifiers correctly', async({page, server}) => {
await page.goto(server.PREFIX + '/input/button.html');
await page.click('button', { modifiers: ['Shift'] });
expect(await page.evaluate(() => shiftKey)).toBe(true);
@ -348,5 +344,14 @@ module.exports.addTests = function({testRunner, expect, playwright, FFOX, CHROME
await page.click('button');
expect(await page.evaluate(() => shiftKey)).toBe(false);
});
it.skip(CHROME)('should click an offscreen element when scroll-behavior is smooth', async({page}) => {
await page.setContent(`
<div style="border: 1px solid black; height: 500px; overflow: auto; width: 500px; scroll-behavior: smooth">
<button style="margin-top: 2000px" onClick="window.clicked = true">hi</button>
</div>
`);
await page.click('button');
expect(await page.evaluate('window.clicked')).toBe(true);
});
});
};

View file

@ -35,7 +35,7 @@ module.exports.addTests = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
const nestedFrame = page.frames().find(frame => frame.name() === 'dos');
const elementHandle = await nestedFrame.$('div');
const box = await elementHandle.boundingBox();
expect(box).toEqual({ x: 28, y: 276, width: 264, height: 18 });
expect(box).toEqual({ x: 24, y: 224, width: 268, height: 18 });
});
it('should return null for invisible elements', async({page, server}) => {
await page.setContent('<div style="display:none">hi</div>');

View file

@ -16,14 +16,14 @@
const fs = require('fs');
const path = require('path');
const utils = require('./utils');
const utils = require('../utils');
module.exports.addTests = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
const {describe, xdescribe, fdescribe} = testRunner;
const {it, fit, xit} = testRunner;
const {beforeAll, beforeEach, afterAll, afterEach} = testRunner;
describe.skip(WEBKIT)('Interception.enable', function() {
describe('Interception.enable', function() {
it('should intercept', async({page, server}) => {
await page.interception.enable();
page.on('request', request => {
@ -275,7 +275,7 @@ module.exports.addTests = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
return e.message;
}
});
if (CHROME || WEBKIT)
if (CHROME)
expect(result).toContain('Failed to fetch');
else
expect(result).toContain('NetworkError');
@ -405,7 +405,7 @@ module.exports.addTests = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
urls.add(request.url().split('/').pop());
page.interception.continue(request);
});
await page.goto(pathToFileURL(path.join(__dirname, 'assets', 'one-style.html')));
await page.goto(pathToFileURL(path.join(__dirname, '..', 'assets', 'one-style.html')));
expect(urls.size).toBe(2);
expect(urls.has('one-style.html')).toBe(true);
expect(urls.has('one-style.css')).toBe(true);
@ -536,7 +536,7 @@ module.exports.addTests = function({testRunner, expect, FFOX, CHROME, WEBKIT}) {
it('should allow mocking binary responses', async({page, server}) => {
await page.interception.enable();
page.on('request', request => {
const imageBuffer = fs.readFileSync(path.join(__dirname, 'assets', 'pptr.png'));
const imageBuffer = fs.readFileSync(path.join(__dirname, '..', 'assets', 'pptr.png'));
page.interception.fulfill(request, {
contentType: 'image/png',
body: imageBuffer

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 B

After

Width:  |  Height:  |  Size: 130 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 B

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 B

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 B

After

Width:  |  Height:  |  Size: 81 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 B

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 B

After

Width:  |  Height:  |  Size: 138 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 B

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 B

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 B

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Some files were not shown because too many files have changed in this diff Show more