Merge branch 'master' into focus_boolean
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
14
.travis.yml
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
1012
|
||||
1021
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
REMOTE_URL="https://github.com/webkit/webkit"
|
||||
BASE_BRANCH="master"
|
||||
BASE_REVISION="031545c904ac108f0063861f58a3e4e2a299b0c0"
|
||||
BASE_REVISION="131efe8ad014ffa190946fea083b8f96b16f6e89"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
11
docs/api.md
|
|
@ -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
|
|
@ -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;
|
||||
11
package.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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), []);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
constructor(session: CDPSession) {
|
||||
this._session = session;
|
||||
}
|
||||
|
||||
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));
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
canCaptureOutsideViewport(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
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 setBackgroundColor(color?: { r: number; g: number; b: number; a: number; }): Promise<void> {
|
||||
await this._session.send('Emulation.setDefaultBackgroundColorOverride', { color });
|
||||
}
|
||||
|
||||
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};
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import * as js from './javascript';
|
||||
|
||||
type ConsoleMessageLocation = {
|
||||
export type ConsoleMessageLocation = {
|
||||
url?: string,
|
||||
lineNumber?: number,
|
||||
columnNumber?: number,
|
||||
|
|
|
|||
90
src/dom.ts
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
@ -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) {
|
||||
this._pagePromise = new Promise(async f => {
|
||||
const session = await this._connection.createSession(this._targetId);
|
||||
this._pagePromise = Page.create(session, this, this._browser._defaultViewport);
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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._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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
src/firefox/Screenshotter.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
17
src/input.ts
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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 };
|
||||
}
|
||||
37
src/types.ts
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
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.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
]);
|
||||
}
|
||||
|
||||
_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))
|
||||
];
|
||||
}
|
||||
|
||||
_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);
|
||||
_didClose() {
|
||||
assert(!this._closed, 'Page closed twice');
|
||||
this._closed = true;
|
||||
});
|
||||
this.emit(Events.Page.Close);
|
||||
this._closedCallback();
|
||||
}
|
||||
|
||||
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));
|
||||
_didDisconnect() {
|
||||
assert(!this._disconnected, 'Page disconnected twice');
|
||||
this._disconnected = true;
|
||||
this._frameManager.disconnectFromTarget();
|
||||
this._disconnectedCallback(new Error('Target closed'));
|
||||
}
|
||||
|
||||
_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;
|
||||
_addConsoleMessage(type: string, args: js.JSHandle[], location: console.ConsoleMessageLocation, text?: string) {
|
||||
if (!this.listenerCount(Events.Page.Console)) {
|
||||
args.forEach(arg => arg.dispose());
|
||||
return;
|
||||
}
|
||||
return context._createHandle(p);
|
||||
});
|
||||
this.emit(Events.Page.Console, new console.ConsoleMessage(derivedType, handles.length ? undefined : text, handles, { url, lineNumber, columnNumber }));
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
40
src/webkit/Screenshotter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
};
|
||||
|
|
@ -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: [
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)),
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
Before Width: | Height: | Size: 138 B After Width: | Height: | Size: 130 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 326 B After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 119 B After Width: | Height: | Size: 228 B |
|
Before Width: | Height: | Size: 75 B After Width: | Height: | Size: 81 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 311 B After Width: | Height: | Size: 461 B |
|
Before Width: | Height: | Size: 109 B After Width: | Height: | Size: 138 B |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 153 B After Width: | Height: | Size: 168 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 153 B After Width: | Height: | Size: 168 B |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 326 B After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 35 KiB |