feat(downloads): support downloads on cr and wk (#1632)

This commit is contained in:
Pavel Feldman 2020-04-02 17:56:14 -07:00 committed by GitHub
parent 3d6d9db44a
commit 75571e8eb8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 468 additions and 106 deletions

View file

@ -14,6 +14,7 @@
- [class: JSHandle](#class-jshandle) - [class: JSHandle](#class-jshandle)
- [class: ConsoleMessage](#class-consolemessage) - [class: ConsoleMessage](#class-consolemessage)
- [class: Dialog](#class-dialog) - [class: Dialog](#class-dialog)
- [class: Download](#class-download)
- [class: Keyboard](#class-keyboard) - [class: Keyboard](#class-keyboard)
- [class: Mouse](#class-mouse) - [class: Mouse](#class-mouse)
- [class: Request](#class-request) - [class: Request](#class-request)
@ -191,6 +192,7 @@ Indicates that the browser is connected.
#### browser.newContext([options]) #### browser.newContext([options])
- `options` <[Object]> - `options` <[Object]>
- `acceptDownloads` <[boolean]> Whether to automatically download all the attachments. Defaults to `false` where all the downloads are canceled.
- `ignoreHTTPSErrors` <[boolean]> Whether to ignore HTTPS errors during navigation. Defaults to `false`. - `ignoreHTTPSErrors` <[boolean]> Whether to ignore HTTPS errors during navigation. Defaults to `false`.
- `bypassCSP` <[boolean]> Toggles bypassing page's Content-Security-Policy. - `bypassCSP` <[boolean]> Toggles bypassing page's Content-Security-Policy.
- `viewport` <?[Object]> Sets a consistent viewport for each page. Defaults to an 1280x720 viewport. `null` disables the default viewport. - `viewport` <?[Object]> Sets a consistent viewport for each page. Defaults to an 1280x720 viewport. `null` disables the default viewport.
@ -230,6 +232,7 @@ Creates a new browser context. It won't share cookies/cache with other browser c
#### browser.newPage([options]) #### browser.newPage([options])
- `options` <[Object]> - `options` <[Object]>
- `acceptDownloads` <[boolean]> Whether to automatically download all the attachments. Defaults to `false` where all the downloads are canceled.
- `ignoreHTTPSErrors` <[boolean]> Whether to ignore HTTPS errors during navigation. Defaults to `false`. - `ignoreHTTPSErrors` <[boolean]> Whether to ignore HTTPS errors during navigation. Defaults to `false`.
- `bypassCSP` <[boolean]> Toggles bypassing page's Content-Security-Policy. - `bypassCSP` <[boolean]> Toggles bypassing page's Content-Security-Policy.
- `viewport` <?[Object]> Sets a consistent viewport for each page. Defaults to an 1280x720 viewport. `null` disables the default viewport. - `viewport` <?[Object]> Sets a consistent viewport for each page. Defaults to an 1280x720 viewport. `null` disables the default viewport.
@ -629,6 +632,7 @@ page.removeListener('request', logRequest);
- [event: 'console'](#event-console) - [event: 'console'](#event-console)
- [event: 'dialog'](#event-dialog) - [event: 'dialog'](#event-dialog)
- [event: 'domcontentloaded'](#event-domcontentloaded) - [event: 'domcontentloaded'](#event-domcontentloaded)
- [event: 'download'](#event-download)
- [event: 'filechooser'](#event-filechooser) - [event: 'filechooser'](#event-filechooser)
- [event: 'frameattached'](#event-frameattached) - [event: 'frameattached'](#event-frameattached)
- [event: 'framedetached'](#event-framedetached) - [event: 'framedetached'](#event-framedetached)
@ -729,6 +733,11 @@ Emitted when a JavaScript dialog appears, such as `alert`, `prompt`, `confirm` o
Emitted when the JavaScript [`DOMContentLoaded`](https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded) event is dispatched. Emitted when the JavaScript [`DOMContentLoaded`](https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded) event is dispatched.
#### event: 'download'
- <[Download]>
Emitted when attachment is downloaded. User can access basic file operations on downloaded content via the passed [Download] instance. Browser context must be created with the `acceptDownloads` set to `true` when user needs access to the downloaded content. If `acceptDownloads` is not set or set to `false`, download events are emitted, but the actual download is not performed and user has no access to the downloaded files.
#### event: 'filechooser' #### event: 'filechooser'
- <[Object]> - <[Object]>
- `element` <[ElementHandle]> handle to the input element that was clicked - `element` <[ElementHandle]> handle to the input element that was clicked
@ -2971,6 +2980,58 @@ const { chromium } = require('playwright'); // Or 'firefox' or 'webkit'.
- returns: <[string]> Dialog's type, can be one of `alert`, `beforeunload`, `confirm` or `prompt`. - returns: <[string]> Dialog's type, can be one of `alert`, `beforeunload`, `confirm` or `prompt`.
### class: Download
[Download] objects are dispatched by page via the ['download'](#event-download) event.
Note that browser context must be created with the `acceptDownloads` set to `true` when user needs access to the downloaded content. If `acceptDownloads` is not set or set to `false`, download events are emitted, but the actual download is not performed and user has no access to the downloaded files.
All the downloaded files belonging to the browser context are deleted when the browser context is closed. All downloaded files are deleted when the browser closes.
An example of using `Download` class:
```js
const [ download ] = await Promise.all([
page.waitForEvent('download'),
page.click('a')
]);
const path = await download.path();
...
```
<!-- GEN:toc -->
- [download.createReadStream()](#downloadcreatereadstream)
- [download.delete()](#downloaddelete)
- [download.failure()](#downloadfailure)
- [download.path()](#downloadpath)
- [download.url()](#downloadurl)
<!-- GEN:stop -->
#### download.createReadStream()
- returns: <[Promise]<null|[Readable]>>
Returns readable stream for current download or `null` if download failed.
#### download.delete()
- returns: <[Promise]>
Deletes the downloaded file.
#### download.failure()
- returns: <[Promise]<null|[string]>>
Returns download error if any.
#### download.path()
- returns: <[Promise]<null|[string]>>
Returns path to the downloaded file in case of successful download.
#### download.url()
- returns: <[string]>
Returns downloaded url.
### class: Keyboard ### class: Keyboard
Keyboard provides an api for managing a virtual keyboard. The high level api is [`keyboard.type`](#keyboardtypetext-options), which takes raw characters and generates proper keydown, keypress/input, and keyup events on your page. Keyboard provides an api for managing a virtual keyboard. The high level api is [`keyboard.type`](#keyboardtypetext-options), which takes raw characters and generates proper keydown, keypress/input, and keyup events on your page.
@ -4112,6 +4173,6 @@ const { chromium } = require('playwright');
[number]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type "Number" [number]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type "Number"
[origin]: https://developer.mozilla.org/en-US/docs/Glossary/Origin "Origin" [origin]: https://developer.mozilla.org/en-US/docs/Glossary/Origin "Origin"
[selector]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors "selector" [selector]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors "selector"
[stream.Readable]: https://nodejs.org/api/stream.html#stream_class_stream_readable "stream.Readable" [Readable]: https://nodejs.org/api/stream.html#stream_class_stream_readable "Readable"
[string]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type "String" [string]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type "String"
[xpath]: https://developer.mozilla.org/en-US/docs/Web/XPath "xpath" [xpath]: https://developer.mozilla.org/en-US/docs/Web/XPath "xpath"

View file

@ -10,7 +10,7 @@
"playwright": { "playwright": {
"chromium_revision": "754895", "chromium_revision": "754895",
"firefox_revision": "1069", "firefox_revision": "1069",
"webkit_revision": "1185" "webkit_revision": "1186"
}, },
"scripts": { "scripts": {
"ctest": "cross-env BROWSER=chromium node test/test.js", "ctest": "cross-env BROWSER=chromium node test/test.js",

View file

@ -19,6 +19,7 @@ export { Browser } from './browser';
export { BrowserContext } from './browserContext'; export { BrowserContext } from './browserContext';
export { ConsoleMessage } from './console'; export { ConsoleMessage } from './console';
export { Dialog } from './dialog'; export { Dialog } from './dialog';
export { Download } from './download';
export { ElementHandle } from './dom'; export { ElementHandle } from './dom';
export { TimeoutError } from './errors'; export { TimeoutError } from './errors';
export { Frame } from './frames'; export { Frame } from './frames';

View file

@ -17,6 +17,8 @@
import { BrowserContext, BrowserContextOptions } from './browserContext'; import { BrowserContext, BrowserContextOptions } from './browserContext';
import { Page } from './page'; import { Page } from './page';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { Download } from './download';
import { debugProtocol } from './transport';
export interface Browser extends EventEmitter { export interface Browser extends EventEmitter {
newContext(options?: BrowserContextOptions): Promise<BrowserContext>; newContext(options?: BrowserContextOptions): Promise<BrowserContext>;
@ -25,14 +27,38 @@ export interface Browser extends EventEmitter {
isConnected(): boolean; isConnected(): boolean;
close(): Promise<void>; close(): Promise<void>;
_disconnect(): Promise<void>; _disconnect(): Promise<void>;
_setDebugFunction(debugFunction: (message: string) => void): void;
} }
export async function createPageInNewContext(browser: Browser, options?: BrowserContextOptions): Promise<Page> { export abstract class BrowserBase extends EventEmitter implements Browser {
const context = await browser.newContext(options); _downloadsPath: string = '';
const page = await context.newPage(); private _downloads = new Map<string, Download>();
page._ownedContext = context; _debugProtocol = debugProtocol;
return page;
abstract newContext(options?: BrowserContextOptions): Promise<BrowserContext>;
abstract contexts(): BrowserContext[];
abstract isConnected(): boolean;
abstract close(): Promise<void>;
abstract _disconnect(): Promise<void>;
async newPage(options?: BrowserContextOptions): Promise<Page> {
const context = await this.newContext(options);
const page = await context.newPage();
page._ownedContext = context;
return page;
}
_downloadCreated(page: Page, uuid: string, url: string) {
const download = new Download(page, this._downloadsPath, uuid, url);
this._downloads.set(uuid, download);
}
_downloadFinished(uuid: string, error: string) {
const download = this._downloads.get(uuid);
if (!download)
return;
download._reportFinished(error);
this._downloads.delete(uuid);
}
} }
export type LaunchType = 'local' | 'server' | 'persistent'; export type LaunchType = 'local' | 'server' | 'persistent';

View file

@ -22,6 +22,7 @@ import { TimeoutSettings } from './timeoutSettings';
import * as types from './types'; import * as types from './types';
import { Events } from './events'; import { Events } from './events';
import { ExtendedEventEmitter } from './extendedEventEmitter'; import { ExtendedEventEmitter } from './extendedEventEmitter';
import { Download } from './download';
export type BrowserContextOptions = { export type BrowserContextOptions = {
viewport?: types.Size | null, viewport?: types.Size | null,
@ -38,7 +39,8 @@ export type BrowserContextOptions = {
httpCredentials?: types.Credentials, httpCredentials?: types.Credentials,
deviceScaleFactor?: number, deviceScaleFactor?: number,
isMobile?: boolean, isMobile?: boolean,
hasTouch?: boolean hasTouch?: boolean,
acceptDownloads?: boolean
}; };
export interface BrowserContext { export interface BrowserContext {
@ -71,6 +73,7 @@ export abstract class BrowserContextBase extends ExtendedEventEmitter implements
private readonly _closePromise: Promise<Error>; private readonly _closePromise: Promise<Error>;
private _closePromiseFulfill: ((error: Error) => void) | undefined; private _closePromiseFulfill: ((error: Error) => void) | undefined;
readonly _permissions = new Map<string, string[]>(); readonly _permissions = new Map<string, string[]>();
readonly _downloads = new Set<Download>();
constructor(options: BrowserContextOptions) { constructor(options: BrowserContextOptions) {
super(); super();
@ -89,13 +92,16 @@ export abstract class BrowserContextBase extends ExtendedEventEmitter implements
_browserClosed() { _browserClosed() {
for (const page of this.pages()) for (const page of this.pages())
page._didClose(); page._didClose();
this._didCloseInternal(); this._didCloseInternal(true);
} }
_didCloseInternal() { async _didCloseInternal(omitDeleteDownloads = false) {
this._closed = true; this._closed = true;
this.emit(Events.BrowserContext.Close); this.emit(Events.BrowserContext.Close);
this._closePromiseFulfill!(new Error('Context closed')); this._closePromiseFulfill!(new Error('Context closed'));
if (!omitDeleteDownloads)
await Promise.all([...this._downloads].map(d => d.delete()));
this._downloads.clear();
} }
// BrowserContext methods. // BrowserContext methods.

View file

@ -15,7 +15,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { Browser, createPageInNewContext } from '../browser'; import { BrowserBase } from '../browser';
import { assertBrowserContextIsNotOwned, BrowserContext, BrowserContextBase, BrowserContextOptions, validateBrowserContextOptions, verifyGeolocation } from '../browserContext'; import { assertBrowserContextIsNotOwned, BrowserContext, BrowserContextBase, BrowserContextOptions, validateBrowserContextOptions, verifyGeolocation } from '../browserContext';
import { Events as CommonEvents } from '../events'; import { Events as CommonEvents } from '../events';
import { assert, debugError, helper } from '../helper'; import { assert, debugError, helper } from '../helper';
@ -29,10 +29,9 @@ import { readProtocolStream } from './crProtocolHelper';
import { Events } from './events'; import { Events } from './events';
import { Protocol } from './protocol'; import { Protocol } from './protocol';
import { CRExecutionContext } from './crExecutionContext'; import { CRExecutionContext } from './crExecutionContext';
import { EventEmitter } from 'events';
import type { BrowserServer } from '../server/browserServer'; import type { BrowserServer } from '../server/browserServer';
export class CRBrowser extends EventEmitter implements Browser { export class CRBrowser extends BrowserBase {
readonly _connection: CRConnection; readonly _connection: CRConnection;
_session: CRSession; _session: CRSession;
private _clientRootSessionPromise: Promise<CRSession> | null = null; private _clientRootSessionPromise: Promise<CRSession> | null = null;
@ -104,10 +103,6 @@ export class CRBrowser extends EventEmitter implements Browser {
return Array.from(this._contexts.values()); return Array.from(this._contexts.values());
} }
async newPage(options?: BrowserContextOptions): Promise<Page> {
return createPageInNewContext(this, options);
}
_onAttachedToTarget({targetInfo, sessionId, waitingForDebugger}: Protocol.Target.attachedToTargetPayload) { _onAttachedToTarget({targetInfo, sessionId, waitingForDebugger}: Protocol.Target.attachedToTargetPayload) {
const session = this._connection.session(sessionId)!; const session = this._connection.session(sessionId)!;
const context = (targetInfo.browserContextId && this._contexts.has(targetInfo.browserContextId)) ? const context = (targetInfo.browserContextId && this._contexts.has(targetInfo.browserContextId)) ?
@ -250,10 +245,6 @@ export class CRBrowser extends EventEmitter implements Browser {
this._clientRootSessionPromise = this._connection.createBrowserSession(); this._clientRootSessionPromise = this._connection.createBrowserSession();
return this._clientRootSessionPromise; return this._clientRootSessionPromise;
} }
_setDebugFunction(debugFunction: debug.IDebugger) {
this._connection._debugProtocol = debugFunction;
}
} }
class CRServiceWorker extends Worker { class CRServiceWorker extends Worker {
@ -284,12 +275,20 @@ export class CRBrowserContext extends BrowserContextBase {
} }
async _initialize() { async _initialize() {
const promises: Promise<any>[] = [
this._browser._session.send('Browser.setDownloadBehavior', {
behavior: this._options.acceptDownloads ? 'allowAndName' : 'deny',
browserContextId: this._browserContextId || undefined,
downloadPath: this._browser._downloadsPath
})
];
if (this._options.permissions) if (this._options.permissions)
await this.grantPermissions(this._options.permissions); promises.push(this.grantPermissions(this._options.permissions));
if (this._options.offline) if (this._options.offline)
await this.setOffline(this._options.offline); promises.push(this.setOffline(this._options.offline));
if (this._options.httpCredentials) if (this._options.httpCredentials)
await this.setHTTPCredentials(this._options.httpCredentials); promises.push(this.setHTTPCredentials(this._options.httpCredentials));
await Promise.all(promises);
} }
pages(): Page[] { pages(): Page[] {
@ -435,7 +434,7 @@ export class CRBrowserContext extends BrowserContextBase {
} }
await this._browser._session.send('Target.disposeBrowserContext', { browserContextId: this._browserContextId }); await this._browser._session.send('Target.disposeBrowserContext', { browserContextId: this._browserContextId });
this._browser._contexts.delete(this._browserContextId); this._browser._contexts.delete(this._browserContextId);
this._didCloseInternal(); await this._didCloseInternal();
} }
backgroundPages(): Page[] { backgroundPages(): Page[] {

View file

@ -16,8 +16,7 @@
*/ */
import { assert } from '../helper'; import { assert } from '../helper';
import * as debug from 'debug'; import { ConnectionTransport, ProtocolRequest, ProtocolResponse, debugProtocol } from '../transport';
import { ConnectionTransport, ProtocolRequest, ProtocolResponse } from '../transport';
import { Protocol } from './protocol'; import { Protocol } from './protocol';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
@ -35,7 +34,6 @@ export class CRConnection extends EventEmitter {
private readonly _sessions = new Map<string, CRSession>(); private readonly _sessions = new Map<string, CRSession>();
readonly rootSession: CRSession; readonly rootSession: CRSession;
_closed = false; _closed = false;
_debugProtocol: debug.IDebugger;
constructor(transport: ConnectionTransport) { constructor(transport: ConnectionTransport) {
super(); super();
@ -44,8 +42,6 @@ export class CRConnection extends EventEmitter {
this._transport.onclose = this._onClose.bind(this); this._transport.onclose = this._onClose.bind(this);
this.rootSession = new CRSession(this, '', 'browser', ''); this.rootSession = new CRSession(this, '', 'browser', '');
this._sessions.set('', this.rootSession); this._sessions.set('', this.rootSession);
this._debugProtocol = debug('pw:protocol');
(this._debugProtocol as any).color = '34';
} }
static fromSession(session: CRSession): CRConnection { static fromSession(session: CRSession): CRConnection {
@ -61,15 +57,15 @@ export class CRConnection extends EventEmitter {
const message: ProtocolRequest = { id, method, params }; const message: ProtocolRequest = { id, method, params };
if (sessionId) if (sessionId)
message.sessionId = sessionId; message.sessionId = sessionId;
if (this._debugProtocol.enabled) if (debugProtocol.enabled)
this._debugProtocol('SEND ► ' + rewriteInjectedScriptEvaluationLog(message)); debugProtocol('SEND ► ' + rewriteInjectedScriptEvaluationLog(message));
this._transport.send(message); this._transport.send(message);
return id; return id;
} }
async _onMessage(message: ProtocolResponse) { async _onMessage(message: ProtocolResponse) {
if (this._debugProtocol.enabled) if (debugProtocol.enabled)
this._debugProtocol('◀ RECV ' + JSON.stringify(message)); debugProtocol('◀ RECV ' + JSON.stringify(message));
if (message.id === kBrowserCloseMessageId) if (message.id === kBrowserCloseMessageId)
return; return;
if (message.method === 'Target.attachedToTarget') { if (message.method === 'Target.attachedToTarget') {

View file

@ -94,6 +94,8 @@ export class CRPage implements PageDelegate {
helper.addEventListener(this._client, 'Page.frameStoppedLoading', event => this._onFrameStoppedLoading(event.frameId)), helper.addEventListener(this._client, 'Page.frameStoppedLoading', event => this._onFrameStoppedLoading(event.frameId)),
helper.addEventListener(this._client, 'Page.javascriptDialogOpening', event => this._onDialog(event)), helper.addEventListener(this._client, 'Page.javascriptDialogOpening', event => this._onDialog(event)),
helper.addEventListener(this._client, 'Page.navigatedWithinDocument', event => this._onFrameNavigatedWithinDocument(event.frameId, event.url)), helper.addEventListener(this._client, 'Page.navigatedWithinDocument', event => this._onFrameNavigatedWithinDocument(event.frameId, event.url)),
helper.addEventListener(this._client, 'Page.downloadWillBegin', event => this._onDownloadWillBegin(event)),
helper.addEventListener(this._client, 'Page.downloadProgress', event => this._onDownloadProgress(event)),
helper.addEventListener(this._client, 'Runtime.bindingCalled', event => this._onBindingCalled(event)), helper.addEventListener(this._client, 'Runtime.bindingCalled', event => this._onBindingCalled(event)),
helper.addEventListener(this._client, 'Runtime.consoleAPICalled', event => this._onConsoleAPI(event)), helper.addEventListener(this._client, 'Runtime.consoleAPICalled', event => this._onConsoleAPI(event)),
helper.addEventListener(this._client, 'Runtime.exceptionThrown', exception => this._handleException(exception.exceptionDetails)), helper.addEventListener(this._client, 'Runtime.exceptionThrown', exception => this._handleException(exception.exceptionDetails)),
@ -168,7 +170,6 @@ export class CRPage implements PageDelegate {
promises.push(this._firstNonInitialNavigationCommittedPromise); promises.push(this._firstNonInitialNavigationCommittedPromise);
await Promise.all(promises); await Promise.all(promises);
} }
didClose() { didClose() {
helper.removeEventListeners(this._eventListeners); helper.removeEventListeners(this._eventListeners);
this._networkManager.dispose(); this._networkManager.dispose();
@ -356,6 +357,17 @@ export class CRPage implements PageDelegate {
this._page._onFileChooserOpened(handle); this._page._onFileChooserOpened(handle);
} }
_onDownloadWillBegin(payload: Protocol.Page.downloadWillBeginPayload) {
this._browserContext._browser._downloadCreated(this._page, payload.guid, payload.url);
}
_onDownloadProgress(payload: Protocol.Page.downloadProgressPayload) {
if (payload.state === 'completed')
this._browserContext._browser._downloadFinished(payload.guid, '');
if (payload.state === 'canceled')
this._browserContext._browser._downloadFinished(payload.guid, 'canceled');
}
async updateExtraHTTPHeaders(): Promise<void> { async updateExtraHTTPHeaders(): Promise<void> {
const headers = network.mergeHeaders([ const headers = network.mergeHeaders([
this._browserContext._options.extraHTTPHeaders, this._browserContext._options.extraHTTPHeaders,

88
src/download.ts Normal file
View file

@ -0,0 +1,88 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as path from 'path';
import * as fs from 'fs';
import * as util from 'util';
import { Page } from './page';
import { Events } from './events';
import { Readable } from 'stream';
export class Download {
private _downloadsPath: string;
private _uuid: string;
private _finishedCallback: () => void;
private _finishedPromise: Promise<void>;
private _page: Page;
private _acceptDownloads: boolean;
private _failure: string | null = null;
private _deleted = false;
private _url: string;
constructor(page: Page, downloadsPath: string, uuid: string, url: string) {
this._page = page;
this._downloadsPath = downloadsPath;
this._uuid = uuid;
this._url = url;
this._finishedCallback = () => {};
this._finishedPromise = new Promise(f => this._finishedCallback = f);
this._page.emit(Events.Page.Download, this);
page._browserContext._downloads.add(this);
this._acceptDownloads = !!this._page._browserContext._options.acceptDownloads;
}
url(): string {
return this._url;
}
async path(): Promise<string | null> {
if (!this._acceptDownloads)
throw new Error('Pass { acceptDownloads: true } when you are creating your browser context.');
const fileName = path.join(this._downloadsPath, this._uuid);
await this._finishedPromise;
if (this._failure)
return null;
return fileName;
}
async failure(): Promise<string | null> {
if (!this._acceptDownloads)
return 'Pass { acceptDownloads: true } when you are creating your browser context.';
await this._finishedPromise;
return this._failure;
}
async createReadStream(): Promise<Readable | null> {
const fileName = await this.path();
return fileName ? fs.createReadStream(fileName) : null;
}
async delete(): Promise<void> {
if (!this._acceptDownloads)
return;
const fileName = await this.path();
if (this._deleted)
return;
this._deleted = true;
if (fileName)
await util.promisify(fs.unlink)(fileName).catch(e => {});
}
_reportFinished(error: string) {
this._failure = error || null;
this._finishedCallback();
}
}

View file

@ -33,6 +33,7 @@ export const Events = {
Close: 'close', Close: 'close',
Console: 'console', Console: 'console',
Dialog: 'dialog', Dialog: 'dialog',
Download: 'download',
FileChooser: 'filechooser', FileChooser: 'filechooser',
DOMContentLoaded: 'domcontentloaded', DOMContentLoaded: 'domcontentloaded',
// Can't use just 'error' due to node.js special treatment of error events. // Can't use just 'error' due to node.js special treatment of error events.

View file

@ -15,7 +15,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { Browser, createPageInNewContext } from '../browser'; import { BrowserBase } from '../browser';
import { assertBrowserContextIsNotOwned, BrowserContext, BrowserContextBase, BrowserContextOptions, validateBrowserContextOptions, verifyGeolocation } from '../browserContext'; import { assertBrowserContextIsNotOwned, BrowserContext, BrowserContextBase, BrowserContextOptions, validateBrowserContextOptions, verifyGeolocation } from '../browserContext';
import { Events } from '../events'; import { Events } from '../events';
import { assert, helper, RegisteredListener } from '../helper'; import { assert, helper, RegisteredListener } from '../helper';
@ -27,10 +27,9 @@ import { ConnectionEvents, FFConnection } from './ffConnection';
import { headersArray } from './ffNetworkManager'; import { headersArray } from './ffNetworkManager';
import { FFPage } from './ffPage'; import { FFPage } from './ffPage';
import { Protocol } from './protocol'; import { Protocol } from './protocol';
import { EventEmitter } from 'events';
import type { BrowserServer } from '../server/browserServer'; import type { BrowserServer } from '../server/browserServer';
export class FFBrowser extends EventEmitter implements Browser { export class FFBrowser extends BrowserBase {
_connection: FFConnection; _connection: FFConnection;
readonly _ffPages: Map<string, FFPage>; readonly _ffPages: Map<string, FFPage>;
readonly _defaultContext: FFBrowserContext; readonly _defaultContext: FFBrowserContext;
@ -111,10 +110,6 @@ export class FFBrowser extends EventEmitter implements Browser {
return Array.from(this._contexts.values()); return Array.from(this._contexts.values());
} }
async newPage(options?: BrowserContextOptions): Promise<Page> {
return createPageInNewContext(this, options);
}
_onDetachedFromTarget(payload: Protocol.Browser.detachedFromTargetPayload) { _onDetachedFromTarget(payload: Protocol.Browser.detachedFromTargetPayload) {
const ffPage = this._ffPages.get(payload.targetId)!; const ffPage = this._ffPages.get(payload.targetId)!;
this._ffPages.delete(payload.targetId); this._ffPages.delete(payload.targetId);
@ -156,10 +151,6 @@ export class FFBrowser extends EventEmitter implements Browser {
else else
await this._disconnect(); await this._disconnect();
} }
_setDebugFunction(debugFunction: debug.IDebugger) {
this._connection._debugProtocol = debugFunction;
}
} }
export class FFBrowserContext extends BrowserContextBase { export class FFBrowserContext extends BrowserContextBase {
@ -320,6 +311,6 @@ export class FFBrowserContext extends BrowserContextBase {
} }
await this._browser._connection.send('Browser.removeBrowserContext', { browserContextId: this._browserContextId }); await this._browser._connection.send('Browser.removeBrowserContext', { browserContextId: this._browserContextId });
this._browser._contexts.delete(this._browserContextId); this._browser._contexts.delete(this._browserContextId);
this._didCloseInternal(); await this._didCloseInternal();
} }
} }

View file

@ -15,10 +15,9 @@
* limitations under the License. * limitations under the License.
*/ */
import * as debug from 'debug';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { assert } from '../helper'; import { assert } from '../helper';
import { ConnectionTransport, ProtocolRequest, ProtocolResponse } from '../transport'; import { ConnectionTransport, ProtocolRequest, ProtocolResponse, debugProtocol } from '../transport';
import { Protocol } from './protocol'; import { Protocol } from './protocol';
export const ConnectionEvents = { export const ConnectionEvents = {
@ -34,7 +33,6 @@ export class FFConnection extends EventEmitter {
private _callbacks: Map<number, {resolve: Function, reject: Function, error: Error, method: string}>; private _callbacks: Map<number, {resolve: Function, reject: Function, error: Error, method: string}>;
private _transport: ConnectionTransport; private _transport: ConnectionTransport;
readonly _sessions: Map<string, FFSession>; readonly _sessions: Map<string, FFSession>;
_debugProtocol = debug('pw:protocol');
_closed: boolean; _closed: boolean;
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; 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;
@ -59,7 +57,6 @@ export class FFConnection extends EventEmitter {
this.off = super.removeListener; this.off = super.removeListener;
this.removeListener = super.removeListener; this.removeListener = super.removeListener;
this.once = super.once; this.once = super.once;
(this._debugProtocol as any).color = '34';
} }
async send<T extends keyof Protocol.CommandParameters>( async send<T extends keyof Protocol.CommandParameters>(
@ -78,14 +75,14 @@ export class FFConnection extends EventEmitter {
} }
_rawSend(message: ProtocolRequest) { _rawSend(message: ProtocolRequest) {
if (this._debugProtocol.enabled) if (debugProtocol.enabled)
this._debugProtocol('SEND ► ' + rewriteInjectedScriptEvaluationLog(message)); debugProtocol('SEND ► ' + rewriteInjectedScriptEvaluationLog(message));
this._transport.send(message); this._transport.send(message);
} }
async _onMessage(message: ProtocolResponse) { async _onMessage(message: ProtocolResponse) {
if (this._debugProtocol.enabled) if (debugProtocol.enabled)
this._debugProtocol('◀ RECV ' + JSON.stringify(message)); debugProtocol('◀ RECV ' + JSON.stringify(message));
if (message.id === kBrowserCloseMessageId) if (message.id === kBrowserCloseMessageId)
return; return;
if (message.sessionId) { if (message.sessionId) {

View file

@ -47,9 +47,10 @@ export class Chromium implements BrowserType<CRBrowser> {
async launch(options: LaunchOptions = {}): Promise<CRBrowser> { async launch(options: LaunchOptions = {}): Promise<CRBrowser> {
assert(!(options as any).userDataDir, 'userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistentContext` instead'); assert(!(options as any).userDataDir, 'userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistentContext` instead');
const { browserServer, transport } = await this._launchServer(options, 'local'); const { browserServer, transport, downloadsPath } = await this._launchServer(options, 'local');
const browser = await CRBrowser.connect(transport!, false, options.slowMo); const browser = await CRBrowser.connect(transport!, false, options.slowMo);
browser._ownedServer = browserServer; browser._ownedServer = browserServer;
browser._downloadsPath = downloadsPath;
return browser; return browser;
} }
@ -69,7 +70,7 @@ export class Chromium implements BrowserType<CRBrowser> {
return browser._defaultContext; return browser._defaultContext;
} }
private async _launchServer(options: LaunchServerOptions, launchType: LaunchType, userDataDir?: string): Promise<{ browserServer: BrowserServer, transport?: ConnectionTransport }> { private async _launchServer(options: LaunchServerOptions, launchType: LaunchType, userDataDir?: string): Promise<{ browserServer: BrowserServer, transport?: ConnectionTransport, downloadsPath: string }> {
const { const {
ignoreDefaultArgs = false, ignoreDefaultArgs = false,
args = [], args = [],
@ -100,7 +101,7 @@ export class Chromium implements BrowserType<CRBrowser> {
const chromeExecutable = executablePath || this._executablePath; const chromeExecutable = executablePath || this._executablePath;
if (!chromeExecutable) if (!chromeExecutable)
throw new Error(`No executable path is specified. Pass "executablePath" option directly.`); throw new Error(`No executable path is specified. Pass "executablePath" option directly.`);
const { launchedProcess, gracefullyClose } = await launchProcess({ const { launchedProcess, gracefullyClose, downloadsPath } = await launchProcess({
executablePath: chromeExecutable, executablePath: chromeExecutable,
args: chromeArguments, args: chromeArguments,
env, env,
@ -129,7 +130,7 @@ export class Chromium implements BrowserType<CRBrowser> {
let browserServer: BrowserServer | undefined = undefined; let browserServer: BrowserServer | undefined = undefined;
transport = new PipeTransport(launchedProcess.stdio[3] as NodeJS.WritableStream, launchedProcess.stdio[4] as NodeJS.ReadableStream); transport = new PipeTransport(launchedProcess.stdio[3] as NodeJS.WritableStream, launchedProcess.stdio[4] as NodeJS.ReadableStream);
browserServer = new BrowserServer(launchedProcess, gracefullyClose, launchType === 'server' ? wrapTransportWithWebSocket(transport, port) : null); browserServer = new BrowserServer(launchedProcess, gracefullyClose, launchType === 'server' ? wrapTransportWithWebSocket(transport, port) : null);
return { browserServer, transport }; return { browserServer, transport, downloadsPath };
} }
async connect(options: ConnectOptions): Promise<CRBrowser> { async connect(options: ConnectOptions): Promise<CRBrowser> {

View file

@ -17,6 +17,9 @@
import * as childProcess from 'child_process'; import * as childProcess from 'child_process';
import * as debug from 'debug'; import * as debug from 'debug';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as readline from 'readline'; import * as readline from 'readline';
import * as removeFolder from 'rimraf'; import * as removeFolder from 'rimraf';
import * as stream from 'stream'; import * as stream from 'stream';
@ -25,6 +28,8 @@ import { TimeoutError } from '../errors';
import { helper } from '../helper'; import { helper } from '../helper';
const removeFolderAsync = util.promisify(removeFolder); const removeFolderAsync = util.promisify(removeFolder);
const mkdtempAsync = util.promisify(fs.mkdtemp);
const DOWNLOADS_FOLDER = path.join(os.tmpdir(), 'playwright_downloads-');
export type LaunchProcessOptions = { export type LaunchProcessOptions = {
executablePath: string, executablePath: string,
@ -43,7 +48,11 @@ export type LaunchProcessOptions = {
onkill: (exitCode: number | null, signal: string | null) => void, onkill: (exitCode: number | null, signal: string | null) => void,
}; };
type LaunchResult = { launchedProcess: childProcess.ChildProcess, gracefullyClose: () => Promise<void> }; type LaunchResult = {
launchedProcess: childProcess.ChildProcess,
gracefullyClose: () => Promise<void>,
downloadsPath: string
};
let lastLaunchedId = 0; let lastLaunchedId = 0;
@ -93,6 +102,8 @@ export async function launchProcess(options: LaunchProcessOptions): Promise<Laun
console.log(`\x1b[31m[err]\x1b[0m ${data}`); // eslint-disable-line no-console console.log(`\x1b[31m[err]\x1b[0m ${data}`); // eslint-disable-line no-console
}); });
const downloadsPath = await mkdtempAsync(DOWNLOADS_FOLDER);
let processClosed = false; let processClosed = false;
const waitForProcessToClose = new Promise((fulfill, reject) => { const waitForProcessToClose = new Promise((fulfill, reject) => {
spawnedProcess.once('exit', (exitCode, signal) => { spawnedProcess.once('exit', (exitCode, signal) => {
@ -101,13 +112,10 @@ export async function launchProcess(options: LaunchProcessOptions): Promise<Laun
helper.removeEventListeners(listeners); helper.removeEventListeners(listeners);
options.onkill(exitCode, signal); options.onkill(exitCode, signal);
// Cleanup as processes exit. // Cleanup as processes exit.
if (options.tempDir) { Promise.all([
removeFolderAsync(options.tempDir) removeFolderAsync(downloadsPath),
.catch((err: Error) => console.error(err)) options.tempDir ? removeFolderAsync(options.tempDir) : Promise.resolve()
.then(fulfill); ]).catch((err: Error) => console.error(err)).then(fulfill);
} else {
fulfill();
}
}); });
}); });
@ -162,7 +170,7 @@ export async function launchProcess(options: LaunchProcessOptions): Promise<Laun
} catch (e) { } } catch (e) { }
} }
return { launchedProcess: spawnedProcess, gracefullyClose }; return { launchedProcess: spawnedProcess, gracefullyClose, downloadsPath };
} }
export function waitForLine(process: childProcess.ChildProcess, inputStream: stream.Readable, regex: RegExp, timeout: number, timeoutError: TimeoutError): Promise<RegExpMatchArray> { export function waitForLine(process: childProcess.ChildProcess, inputStream: stream.Readable, regex: RegExp, timeout: number, timeoutError: TimeoutError): Promise<RegExpMatchArray> {

View file

@ -47,9 +47,10 @@ export class WebKit implements BrowserType<WKBrowser> {
async launch(options: LaunchOptions = {}): Promise<WKBrowser> { async launch(options: LaunchOptions = {}): Promise<WKBrowser> {
assert(!(options as any).userDataDir, 'userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistentContext` instead'); assert(!(options as any).userDataDir, 'userDataDir option is not supported in `browserType.launch`. Use `browserType.launchPersistentContext` instead');
const { browserServer, transport } = await this._launchServer(options, 'local'); const { browserServer, transport, downloadsPath } = await this._launchServer(options, 'local');
const browser = await WKBrowser.connect(transport!, options.slowMo, false, () => browserServer.close()); const browser = await WKBrowser.connect(transport!, options.slowMo, false, () => browserServer.close());
browser._ownedServer = browserServer; browser._ownedServer = browserServer;
browser._downloadsPath = downloadsPath;
return browser; return browser;
} }
@ -68,7 +69,7 @@ export class WebKit implements BrowserType<WKBrowser> {
return browser._defaultContext; return browser._defaultContext;
} }
private async _launchServer(options: LaunchServerOptions, launchType: LaunchType, userDataDir?: string): Promise<{ browserServer: BrowserServer, transport?: ConnectionTransport }> { private async _launchServer(options: LaunchServerOptions, launchType: LaunchType, userDataDir?: string): Promise<{ browserServer: BrowserServer, transport?: ConnectionTransport, downloadsPath: string }> {
const { const {
ignoreDefaultArgs = false, ignoreDefaultArgs = false,
args = [], args = [],
@ -100,7 +101,7 @@ export class WebKit implements BrowserType<WKBrowser> {
if (!webkitExecutable) if (!webkitExecutable)
throw new Error(`No executable path is specified.`); throw new Error(`No executable path is specified.`);
const { launchedProcess, gracefullyClose } = await launchProcess({ const { launchedProcess, gracefullyClose, downloadsPath } = await launchProcess({
executablePath: webkitExecutable, executablePath: webkitExecutable,
args: webkitArguments, args: webkitArguments,
env: { ...env, CURL_COOKIE_JAR_PATH: path.join(userDataDir, 'cookiejar.db') }, env: { ...env, CURL_COOKIE_JAR_PATH: path.join(userDataDir, 'cookiejar.db') },
@ -128,7 +129,7 @@ export class WebKit implements BrowserType<WKBrowser> {
let browserServer: BrowserServer | undefined = undefined; let browserServer: BrowserServer | undefined = undefined;
transport = new PipeTransport(launchedProcess.stdio[3] as NodeJS.WritableStream, launchedProcess.stdio[4] as NodeJS.ReadableStream); transport = new PipeTransport(launchedProcess.stdio[3] as NodeJS.WritableStream, launchedProcess.stdio[4] as NodeJS.ReadableStream);
browserServer = new BrowserServer(launchedProcess, gracefullyClose, launchType === 'server' ? wrapTransportWithWebSocket(transport, port || 0) : null); browserServer = new BrowserServer(launchedProcess, gracefullyClose, launchType === 'server' ? wrapTransportWithWebSocket(transport, port || 0) : null);
return { browserServer, transport }; return { browserServer, transport, downloadsPath };
} }
async connect(options: ConnectOptions): Promise<WKBrowser> { async connect(options: ConnectOptions): Promise<WKBrowser> {

View file

@ -15,6 +15,7 @@
* limitations under the License. * limitations under the License.
*/ */
import * as debug from 'debug';
import * as WebSocket from 'ws'; import * as WebSocket from 'ws';
import { helper } from './helper'; import { helper } from './helper';
@ -43,7 +44,7 @@ export interface ConnectionTransport {
onclose?: () => void, onclose?: () => void,
} }
export class SlowMoTransport { export class SlowMoTransport implements ConnectionTransport {
private readonly _delay: number; private readonly _delay: number;
private readonly _delegate: ConnectionTransport; private readonly _delegate: ConnectionTransport;
@ -184,3 +185,41 @@ export class SequenceNumberMixer<V> {
return value; return value;
} }
} }
export class InterceptingTransport implements ConnectionTransport {
private readonly _delegate: ConnectionTransport;
private _interceptor: (message: ProtocolRequest) => ProtocolRequest;
onmessage?: (message: ProtocolResponse) => void;
onclose?: () => void;
constructor(transport: ConnectionTransport, interceptor: (message: ProtocolRequest) => ProtocolRequest) {
this._delegate = transport;
this._interceptor = interceptor;
this._delegate.onmessage = this._onmessage.bind(this);
this._delegate.onclose = this._onClose.bind(this);
}
private _onmessage(message: ProtocolResponse) {
if (this.onmessage)
this.onmessage(message);
}
private _onClose() {
if (this.onclose)
this.onclose();
this._delegate.onmessage = undefined;
this._delegate.onclose = undefined;
}
send(s: ProtocolRequest) {
this._delegate.send(this._interceptor(s));
}
close() {
this._delegate.close();
}
}
export const debugProtocol = debug('pw:protocol');
(debugProtocol as any).color = '34';

View file

@ -15,7 +15,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { Browser, createPageInNewContext } from '../browser'; import { BrowserBase } from '../browser';
import { assertBrowserContextIsNotOwned, BrowserContext, BrowserContextBase, BrowserContextOptions, validateBrowserContextOptions, verifyGeolocation } from '../browserContext'; import { assertBrowserContextIsNotOwned, BrowserContext, BrowserContextBase, BrowserContextOptions, validateBrowserContextOptions, verifyGeolocation } from '../browserContext';
import { Events } from '../events'; import { Events } from '../events';
import { assert, helper, RegisteredListener } from '../helper'; import { assert, helper, RegisteredListener } from '../helper';
@ -26,12 +26,11 @@ import * as types from '../types';
import { Protocol } from './protocol'; import { Protocol } from './protocol';
import { kPageProxyMessageReceived, PageProxyMessageReceivedPayload, WKConnection, WKSession } from './wkConnection'; import { kPageProxyMessageReceived, PageProxyMessageReceivedPayload, WKConnection, WKSession } from './wkConnection';
import { WKPage } from './wkPage'; import { WKPage } from './wkPage';
import { EventEmitter } from 'events';
import type { BrowserServer } from '../server/browserServer'; import type { BrowserServer } from '../server/browserServer';
const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.4 Safari/605.1.15'; const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.4 Safari/605.1.15';
export class WKBrowser extends EventEmitter implements Browser { export class WKBrowser extends BrowserBase {
private readonly _connection: WKConnection; private readonly _connection: WKConnection;
private readonly _attachToDefaultContext: boolean; private readonly _attachToDefaultContext: boolean;
readonly _browserSession: WKSession; readonly _browserSession: WKSession;
@ -65,6 +64,8 @@ export class WKBrowser extends EventEmitter implements Browser {
helper.addEventListener(this._browserSession, 'Playwright.pageProxyDestroyed', this._onPageProxyDestroyed.bind(this)), helper.addEventListener(this._browserSession, 'Playwright.pageProxyDestroyed', this._onPageProxyDestroyed.bind(this)),
helper.addEventListener(this._browserSession, 'Playwright.provisionalLoadFailed', event => this._onProvisionalLoadFailed(event)), helper.addEventListener(this._browserSession, 'Playwright.provisionalLoadFailed', event => this._onProvisionalLoadFailed(event)),
helper.addEventListener(this._browserSession, 'Playwright.windowOpen', this._onWindowOpen.bind(this)), helper.addEventListener(this._browserSession, 'Playwright.windowOpen', this._onWindowOpen.bind(this)),
helper.addEventListener(this._browserSession, 'Playwright.downloadCreated', this._onDownloadCreated.bind(this)),
helper.addEventListener(this._browserSession, 'Playwright.downloadFinished', this._onDownloadFinished.bind(this)),
helper.addEventListener(this._browserSession, kPageProxyMessageReceived, this._onPageProxyMessageReceived.bind(this)), helper.addEventListener(this._browserSession, kPageProxyMessageReceived, this._onPageProxyMessageReceived.bind(this)),
]; ];
@ -95,10 +96,6 @@ export class WKBrowser extends EventEmitter implements Browser {
return Array.from(this._contexts.values()); return Array.from(this._contexts.values());
} }
async newPage(options?: BrowserContextOptions): Promise<Page> {
return createPageInNewContext(this, options);
}
async _waitForFirstPageTarget(): Promise<void> { async _waitForFirstPageTarget(): Promise<void> {
assert(!this._wkPages.size); assert(!this._wkPages.size);
return this._firstPagePromise; return this._firstPagePromise;
@ -108,6 +105,17 @@ export class WKBrowser extends EventEmitter implements Browser {
this._popupOpeners.push(payload.pageProxyId); this._popupOpeners.push(payload.pageProxyId);
} }
_onDownloadCreated(payload: Protocol.Playwright.downloadCreatedPayload) {
const page = this._wkPages.get(payload.pageProxyId);
if (!page)
return;
this._downloadCreated(page._page, payload.uuid, payload.url);
}
_onDownloadFinished(payload: Protocol.Playwright.downloadFinishedPayload) {
this._downloadFinished(payload.uuid, payload.error);
}
_onPageProxyCreated(event: Protocol.Playwright.pageProxyCreatedPayload) { _onPageProxyCreated(event: Protocol.Playwright.pageProxyCreatedPayload) {
const { pageProxyInfo } = event; const { pageProxyInfo } = event;
const pageProxyId = pageProxyInfo.pageProxyId; const pageProxyId = pageProxyInfo.pageProxyId;
@ -196,10 +204,6 @@ export class WKBrowser extends EventEmitter implements Browser {
else else
await this._disconnect(); await this._disconnect();
} }
_setDebugFunction(debugFunction: debug.IDebugger) {
this._connection._debugProtocol = debugFunction;
}
} }
export class WKBrowserContext extends BrowserContextBase { export class WKBrowserContext extends BrowserContextBase {
@ -215,18 +219,27 @@ export class WKBrowserContext extends BrowserContextBase {
} }
async _initialize() { async _initialize() {
const browserContextId = this._browserContextId;
const promises: Promise<any>[] = [
this._browser._browserSession.send('Playwright.setDownloadBehavior', {
behavior: this._options.acceptDownloads ? 'allow' : 'deny',
downloadPath: this._browser._downloadsPath,
browserContextId
})
];
if (this._options.ignoreHTTPSErrors) if (this._options.ignoreHTTPSErrors)
await this._browser._browserSession.send('Playwright.setIgnoreCertificateErrors', { browserContextId: this._browserContextId, ignore: true }); promises.push(this._browser._browserSession.send('Playwright.setIgnoreCertificateErrors', { browserContextId, ignore: true }));
if (this._options.locale) if (this._options.locale)
await this._browser._browserSession.send('Playwright.setLanguages', { browserContextId: this._browserContextId, languages: [this._options.locale] }); promises.push(this._browser._browserSession.send('Playwright.setLanguages', { browserContextId, languages: [this._options.locale] }));
if (this._options.permissions) if (this._options.permissions)
await this.grantPermissions(this._options.permissions); promises.push(this.grantPermissions(this._options.permissions));
if (this._options.geolocation) if (this._options.geolocation)
await this.setGeolocation(this._options.geolocation); promises.push(this.setGeolocation(this._options.geolocation));
if (this._options.offline) if (this._options.offline)
await this.setOffline(this._options.offline); promises.push(this.setOffline(this._options.offline));
if (this._options.httpCredentials) if (this._options.httpCredentials)
await this.setHTTPCredentials(this._options.httpCredentials); promises.push(this.setHTTPCredentials(this._options.httpCredentials));
await Promise.all(promises);
} }
_wkPages(): WKPage[] { _wkPages(): WKPage[] {
@ -344,6 +357,6 @@ export class WKBrowserContext extends BrowserContextBase {
} }
await this._browser._browserSession.send('Playwright.deleteContext', { browserContextId: this._browserContextId }); await this._browser._browserSession.send('Playwright.deleteContext', { browserContextId: this._browserContextId });
this._browser._contexts.delete(this._browserContextId); this._browser._contexts.delete(this._browserContextId);
this._didCloseInternal(); await this._didCloseInternal();
} }
} }

View file

@ -18,7 +18,7 @@
import * as debug from 'debug'; import * as debug from 'debug';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { assert } from '../helper'; import { assert } from '../helper';
import { ConnectionTransport, ProtocolRequest, ProtocolResponse } from '../transport'; import { ConnectionTransport, ProtocolRequest, ProtocolResponse, debugProtocol } from '../transport';
import { Protocol } from './protocol'; import { Protocol } from './protocol';
// WKPlaywright uses this special id to issue Browser.close command which we // WKPlaywright uses this special id to issue Browser.close command which we
@ -35,7 +35,6 @@ export class WKConnection {
private readonly _onDisconnect: () => void; private readonly _onDisconnect: () => void;
private _lastId = 0; private _lastId = 0;
private _closed = false; private _closed = false;
_debugProtocol = debug('pw:protocol');
readonly browserSession: WKSession; readonly browserSession: WKSession;
@ -47,7 +46,6 @@ export class WKConnection {
this.browserSession = new WKSession(this, '', 'Browser has been closed.', (message: any) => { this.browserSession = new WKSession(this, '', 'Browser has been closed.', (message: any) => {
this.rawSend(message); this.rawSend(message);
}); });
(this._debugProtocol as any).color = '34';
} }
nextMessageId(): number { nextMessageId(): number {
@ -55,14 +53,14 @@ export class WKConnection {
} }
rawSend(message: ProtocolRequest) { rawSend(message: ProtocolRequest) {
if (this._debugProtocol.enabled) if (debugProtocol.enabled)
this._debugProtocol('SEND ► ' + rewriteInjectedScriptEvaluationLog(message)); debugProtocol('SEND ► ' + rewriteInjectedScriptEvaluationLog(message));
this._transport.send(message); this._transport.send(message);
} }
private _dispatchMessage(message: ProtocolResponse) { private _dispatchMessage(message: ProtocolResponse) {
if (this._debugProtocol.enabled) if (debugProtocol.enabled)
this._debugProtocol('◀ RECV ' + message); debugProtocol('◀ RECV ' + JSON.stringify(message));
if (message.id === kBrowserCloseMessageId) if (message.id === kBrowserCloseMessageId)
return; return;
if (message.pageProxyId) { if (message.pageProxyId) {

View file

@ -195,10 +195,6 @@ module.exports.describe = function({testRunner, expect, playwright, MAC, WIN, FF
await page.setContent(`<a href='${httpsServer.EMPTY_PAGE}'>foobar</a>`); await page.setContent(`<a href='${httpsServer.EMPTY_PAGE}'>foobar</a>`);
await page.click('a'); await page.click('a');
}); });
it.fail(FFOX)('clicking on download link', async({page, server, httpsServer}) => {
await page.setContent(`<a href="${server.PREFIX}/wasm/table2.wasm" download=true>table2.wasm</a>`);
await page.click('a');
});
it('calling window.stop async', async({page, server, httpsServer}) => { it('calling window.stop async', async({page, server, httpsServer}) => {
server.setRoute('/empty.html', async (req, res) => {}); server.setRoute('/empty.html', async (req, res) => {});
await page.evaluate((url) => { await page.evaluate((url) => {

125
test/download.spec.js Normal file
View file

@ -0,0 +1,125 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const fs = require('fs');
const path = require('path');
module.exports.describe = function({testRunner, expect, browserType, CHROMIUM, WEBKIT, FFOX, WIN, MAC}) {
const {describe, xdescribe, fdescribe} = testRunner;
const {it, fit, xit, dit} = testRunner;
const {beforeAll, beforeEach, afterAll, afterEach} = testRunner;
describe.fail(FFOX)('Download', function() {
beforeEach(async(state) => {
state.server.setRoute('/download', (req, res) => {
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', 'attachment');
res.end(`Hello world`);
});
});
it('should report downloads with acceptDownloads: false', async({page, server}) => {
await page.setContent(`<a download=true href="${server.PREFIX}/download">download</a>`);
const [ download ] = await Promise.all([
page.waitForEvent('download'),
page.click('a')
]);
let error;
expect(download.url()).toBe(`${server.PREFIX}/download`);
await download.path().catch(e => error = e);
expect(await download.failure()).toContain('acceptDownloads');
expect(error.message).toContain('acceptDownloads: true');
});
it('should report downloads with acceptDownloads: true', async({browser, server}) => {
const page = await browser.newPage({ acceptDownloads: true });
await page.setContent(`<a download=true href="${server.PREFIX}/download">download</a>`);
const [ download ] = await Promise.all([
page.waitForEvent('download'),
page.click('a')
]);
const path = await download.path();
expect(fs.existsSync(path)).toBeTruthy();
expect(fs.readFileSync(path).toString()).toBe('Hello world');
await page.close();
});
it('should delete file', async({browser, server}) => {
const page = await browser.newPage({ acceptDownloads: true });
await page.setContent(`<a download=true href="${server.PREFIX}/download">download</a>`);
const [ download ] = await Promise.all([
page.waitForEvent('download'),
page.click('a')
]);
const path = await download.path();
expect(fs.existsSync(path)).toBeTruthy();
await download.delete();
expect(fs.existsSync(path)).toBeFalsy();
});
it('should expose stream', async({browser, server}) => {
const page = await browser.newPage({ acceptDownloads: true });
await page.setContent(`<a download=true href="${server.PREFIX}/download">download</a>`);
const [ download ] = await Promise.all([
page.waitForEvent('download'),
page.click('a')
]);
const stream = await download.createReadStream();
let content = '';
stream.on('data', data => content += data.toString());
await new Promise(f => stream.on('end', f));
expect(content).toBe('Hello world');
stream.close();
});
it('should delete downloads on context destruction', async({browser, server}) => {
const page = await browser.newPage({ acceptDownloads: true });
await page.setContent(`<a download=true href="${server.PREFIX}/download">download</a>`);
const [ download1 ] = await Promise.all([
page.waitForEvent('download'),
page.click('a')
]);
const [ download2 ] = await Promise.all([
page.waitForEvent('download'),
page.click('a')
]);
const path1 = await download1.path();
const path2 = await download2.path();
expect(fs.existsSync(path1)).toBeTruthy();
expect(fs.existsSync(path2)).toBeTruthy();
await page.context().close();
expect(fs.existsSync(path1)).toBeFalsy();
expect(fs.existsSync(path2)).toBeFalsy();
});
it('should delete downloads on browser gone', async ({ server, defaultBrowserOptions }) => {
const browser = await browserType.launch(defaultBrowserOptions);
const page = await browser.newPage({ acceptDownloads: true });
await page.setContent(`<a download=true href="${server.PREFIX}/download">download</a>`);
const [ download1 ] = await Promise.all([
page.waitForEvent('download'),
page.click('a')
]);
const [ download2 ] = await Promise.all([
page.waitForEvent('download'),
page.click('a')
]);
const path1 = await download1.path();
const path2 = await download2.path();
expect(fs.existsSync(path1)).toBeTruthy();
expect(fs.existsSync(path2)).toBeTruthy();
await browser.close();
expect(fs.existsSync(path1)).toBeFalsy();
expect(fs.existsSync(path2)).toBeFalsy();
expect(fs.existsSync(path.join(path1, '..'))).toBeFalsy();
});
});
};

View file

@ -32,7 +32,8 @@ const BROWSER_CONFIGS = [
...require('../lib/events').Events, ...require('../lib/events').Events,
...require('../lib/chromium/events').Events, ...require('../lib/chromium/events').Events,
}, },
missingCoverage: ['browserContext.setGeolocation', 'browserContext.setOffline', 'cDPSession.send', 'cDPSession.detach'], missingCoverage: ['browserContext.setGeolocation', 'browserContext.setOffline', 'cDPSession.send', 'cDPSession.detach', 'page.emit("download")',
'download.url', 'download.path', 'download.failure', 'download.createReadStream', 'download.delete'],
}, },
{ {
name: 'WebKit', name: 'WebKit',
@ -180,12 +181,12 @@ module.exports.addPlaywrightTests = ({testRunner, platform, products, playwright
state._stdout.on('line', dumpout); state._stdout.on('line', dumpout);
state._stderr.on('line', dumperr); state._stderr.on('line', dumperr);
if (dumpProtocolOnFailure) if (dumpProtocolOnFailure)
state.browser._setDebugFunction(data => test.output.push(`\x1b[32m[pw:protocol]\x1b[0m ${data}`)); state.browser._debugProtocol.log = data => test.output.push(`\x1b[32m[pw:protocol]\x1b[0m ${data}`);
state.tearDown = async () => { state.tearDown = async () => {
state._stdout.off('line', dumpout); state._stdout.off('line', dumpout);
state._stderr.off('line', dumperr); state._stderr.off('line', dumperr);
if (dumpProtocolOnFailure) if (dumpProtocolOnFailure)
state.browser._setDebugFunction(() => void 0); delete state.browser._debugProtocol.log;
}; };
}); });
@ -218,6 +219,7 @@ module.exports.addPlaywrightTests = ({testRunner, platform, products, playwright
loadTests('./click.spec.js'); loadTests('./click.spec.js');
loadTests('./cookies.spec.js'); loadTests('./cookies.spec.js');
loadTests('./dialog.spec.js'); loadTests('./dialog.spec.js');
loadTests('./download.spec.js');
loadTests('./elementhandle.spec.js'); loadTests('./elementhandle.spec.js');
loadTests('./emulation.spec.js'); loadTests('./emulation.spec.js');
loadTests('./evaluation.spec.js'); loadTests('./evaluation.spec.js');

View file

@ -16,6 +16,7 @@
import { Protocol } from './protocol'; import { Protocol } from './protocol';
import { ChildProcess } from 'child_process'; import { ChildProcess } from 'child_process';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { Readable } from 'stream';
/** /**
* Can be converted to JSON * Can be converted to JSON