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: ConsoleMessage](#class-consolemessage)
- [class: Dialog](#class-dialog)
- [class: Download](#class-download)
- [class: Keyboard](#class-keyboard)
- [class: Mouse](#class-mouse)
- [class: Request](#class-request)
@ -191,6 +192,7 @@ Indicates that the browser is connected.
#### browser.newContext([options])
- `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`.
- `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.
@ -230,6 +232,7 @@ Creates a new browser context. It won't share cookies/cache with other browser c
#### browser.newPage([options])
- `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`.
- `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.
@ -629,6 +632,7 @@ page.removeListener('request', logRequest);
- [event: 'console'](#event-console)
- [event: 'dialog'](#event-dialog)
- [event: 'domcontentloaded'](#event-domcontentloaded)
- [event: 'download'](#event-download)
- [event: 'filechooser'](#event-filechooser)
- [event: 'frameattached'](#event-frameattached)
- [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.
#### 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'
- <[Object]>
- `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`.
### 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
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"
[origin]: https://developer.mozilla.org/en-US/docs/Glossary/Origin "Origin"
[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"
[xpath]: https://developer.mozilla.org/en-US/docs/Web/XPath "xpath"

View file

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

View file

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

View file

@ -17,6 +17,8 @@
import { BrowserContext, BrowserContextOptions } from './browserContext';
import { Page } from './page';
import { EventEmitter } from 'events';
import { Download } from './download';
import { debugProtocol } from './transport';
export interface Browser extends EventEmitter {
newContext(options?: BrowserContextOptions): Promise<BrowserContext>;
@ -25,14 +27,38 @@ export interface Browser extends EventEmitter {
isConnected(): boolean;
close(): Promise<void>;
_disconnect(): Promise<void>;
_setDebugFunction(debugFunction: (message: string) => void): void;
}
export async function createPageInNewContext(browser: Browser, options?: BrowserContextOptions): Promise<Page> {
const context = await browser.newContext(options);
const page = await context.newPage();
page._ownedContext = context;
return page;
export abstract class BrowserBase extends EventEmitter implements Browser {
_downloadsPath: string = '';
private _downloads = new Map<string, Download>();
_debugProtocol = debugProtocol;
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';

View file

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

View file

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

View file

@ -16,8 +16,7 @@
*/
import { assert } from '../helper';
import * as debug from 'debug';
import { ConnectionTransport, ProtocolRequest, ProtocolResponse } from '../transport';
import { ConnectionTransport, ProtocolRequest, ProtocolResponse, debugProtocol } from '../transport';
import { Protocol } from './protocol';
import { EventEmitter } from 'events';
@ -35,7 +34,6 @@ export class CRConnection extends EventEmitter {
private readonly _sessions = new Map<string, CRSession>();
readonly rootSession: CRSession;
_closed = false;
_debugProtocol: debug.IDebugger;
constructor(transport: ConnectionTransport) {
super();
@ -44,8 +42,6 @@ export class CRConnection extends EventEmitter {
this._transport.onclose = this._onClose.bind(this);
this.rootSession = new CRSession(this, '', 'browser', '');
this._sessions.set('', this.rootSession);
this._debugProtocol = debug('pw:protocol');
(this._debugProtocol as any).color = '34';
}
static fromSession(session: CRSession): CRConnection {
@ -61,15 +57,15 @@ export class CRConnection extends EventEmitter {
const message: ProtocolRequest = { id, method, params };
if (sessionId)
message.sessionId = sessionId;
if (this._debugProtocol.enabled)
this._debugProtocol('SEND ► ' + rewriteInjectedScriptEvaluationLog(message));
if (debugProtocol.enabled)
debugProtocol('SEND ► ' + rewriteInjectedScriptEvaluationLog(message));
this._transport.send(message);
return id;
}
async _onMessage(message: ProtocolResponse) {
if (this._debugProtocol.enabled)
this._debugProtocol('◀ RECV ' + JSON.stringify(message));
if (debugProtocol.enabled)
debugProtocol('◀ RECV ' + JSON.stringify(message));
if (message.id === kBrowserCloseMessageId)
return;
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.javascriptDialogOpening', event => this._onDialog(event)),
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.consoleAPICalled', event => this._onConsoleAPI(event)),
helper.addEventListener(this._client, 'Runtime.exceptionThrown', exception => this._handleException(exception.exceptionDetails)),
@ -168,7 +170,6 @@ export class CRPage implements PageDelegate {
promises.push(this._firstNonInitialNavigationCommittedPromise);
await Promise.all(promises);
}
didClose() {
helper.removeEventListeners(this._eventListeners);
this._networkManager.dispose();
@ -356,6 +357,17 @@ export class CRPage implements PageDelegate {
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> {
const headers = network.mergeHeaders([
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',
Console: 'console',
Dialog: 'dialog',
Download: 'download',
FileChooser: 'filechooser',
DOMContentLoaded: 'domcontentloaded',
// Can't use just 'error' due to node.js special treatment of error events.

View file

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

View file

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

View file

@ -47,9 +47,10 @@ export class Chromium implements BrowserType<CRBrowser> {
async launch(options: LaunchOptions = {}): Promise<CRBrowser> {
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);
browser._ownedServer = browserServer;
browser._downloadsPath = downloadsPath;
return browser;
}
@ -69,7 +70,7 @@ export class Chromium implements BrowserType<CRBrowser> {
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 {
ignoreDefaultArgs = false,
args = [],
@ -100,7 +101,7 @@ export class Chromium implements BrowserType<CRBrowser> {
const chromeExecutable = executablePath || this._executablePath;
if (!chromeExecutable)
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,
args: chromeArguments,
env,
@ -129,7 +130,7 @@ export class Chromium implements BrowserType<CRBrowser> {
let browserServer: BrowserServer | undefined = undefined;
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);
return { browserServer, transport };
return { browserServer, transport, downloadsPath };
}
async connect(options: ConnectOptions): Promise<CRBrowser> {

View file

@ -17,6 +17,9 @@
import * as childProcess from 'child_process';
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 removeFolder from 'rimraf';
import * as stream from 'stream';
@ -25,6 +28,8 @@ import { TimeoutError } from '../errors';
import { helper } from '../helper';
const removeFolderAsync = util.promisify(removeFolder);
const mkdtempAsync = util.promisify(fs.mkdtemp);
const DOWNLOADS_FOLDER = path.join(os.tmpdir(), 'playwright_downloads-');
export type LaunchProcessOptions = {
executablePath: string,
@ -43,7 +48,11 @@ export type LaunchProcessOptions = {
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;
@ -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
});
const downloadsPath = await mkdtempAsync(DOWNLOADS_FOLDER);
let processClosed = false;
const waitForProcessToClose = new Promise((fulfill, reject) => {
spawnedProcess.once('exit', (exitCode, signal) => {
@ -101,13 +112,10 @@ export async function launchProcess(options: LaunchProcessOptions): Promise<Laun
helper.removeEventListeners(listeners);
options.onkill(exitCode, signal);
// Cleanup as processes exit.
if (options.tempDir) {
removeFolderAsync(options.tempDir)
.catch((err: Error) => console.error(err))
.then(fulfill);
} else {
fulfill();
}
Promise.all([
removeFolderAsync(downloadsPath),
options.tempDir ? removeFolderAsync(options.tempDir) : Promise.resolve()
]).catch((err: Error) => console.error(err)).then(fulfill);
});
});
@ -162,7 +170,7 @@ export async function launchProcess(options: LaunchProcessOptions): Promise<Laun
} 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> {

View file

@ -47,9 +47,10 @@ export class WebKit implements BrowserType<WKBrowser> {
async launch(options: LaunchOptions = {}): Promise<WKBrowser> {
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());
browser._ownedServer = browserServer;
browser._downloadsPath = downloadsPath;
return browser;
}
@ -68,7 +69,7 @@ export class WebKit implements BrowserType<WKBrowser> {
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 {
ignoreDefaultArgs = false,
args = [],
@ -100,7 +101,7 @@ export class WebKit implements BrowserType<WKBrowser> {
if (!webkitExecutable)
throw new Error(`No executable path is specified.`);
const { launchedProcess, gracefullyClose } = await launchProcess({
const { launchedProcess, gracefullyClose, downloadsPath } = await launchProcess({
executablePath: webkitExecutable,
args: webkitArguments,
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;
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);
return { browserServer, transport };
return { browserServer, transport, downloadsPath };
}
async connect(options: ConnectOptions): Promise<WKBrowser> {

View file

@ -15,6 +15,7 @@
* limitations under the License.
*/
import * as debug from 'debug';
import * as WebSocket from 'ws';
import { helper } from './helper';
@ -43,7 +44,7 @@ export interface ConnectionTransport {
onclose?: () => void,
}
export class SlowMoTransport {
export class SlowMoTransport implements ConnectionTransport {
private readonly _delay: number;
private readonly _delegate: ConnectionTransport;
@ -184,3 +185,41 @@ export class SequenceNumberMixer<V> {
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.
*/
import { Browser, createPageInNewContext } from '../browser';
import { BrowserBase } from '../browser';
import { assertBrowserContextIsNotOwned, BrowserContext, BrowserContextBase, BrowserContextOptions, validateBrowserContextOptions, verifyGeolocation } from '../browserContext';
import { Events } from '../events';
import { assert, helper, RegisteredListener } from '../helper';
@ -26,12 +26,11 @@ import * as types from '../types';
import { Protocol } from './protocol';
import { kPageProxyMessageReceived, PageProxyMessageReceivedPayload, WKConnection, WKSession } from './wkConnection';
import { WKPage } from './wkPage';
import { EventEmitter } from 'events';
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';
export class WKBrowser extends EventEmitter implements Browser {
export class WKBrowser extends BrowserBase {
private readonly _connection: WKConnection;
private readonly _attachToDefaultContext: boolean;
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.provisionalLoadFailed', event => this._onProvisionalLoadFailed(event)),
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)),
];
@ -95,10 +96,6 @@ export class WKBrowser extends EventEmitter implements Browser {
return Array.from(this._contexts.values());
}
async newPage(options?: BrowserContextOptions): Promise<Page> {
return createPageInNewContext(this, options);
}
async _waitForFirstPageTarget(): Promise<void> {
assert(!this._wkPages.size);
return this._firstPagePromise;
@ -108,6 +105,17 @@ export class WKBrowser extends EventEmitter implements Browser {
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) {
const { pageProxyInfo } = event;
const pageProxyId = pageProxyInfo.pageProxyId;
@ -196,10 +204,6 @@ export class WKBrowser extends EventEmitter implements Browser {
else
await this._disconnect();
}
_setDebugFunction(debugFunction: debug.IDebugger) {
this._connection._debugProtocol = debugFunction;
}
}
export class WKBrowserContext extends BrowserContextBase {
@ -215,18 +219,27 @@ export class WKBrowserContext extends BrowserContextBase {
}
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)
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)
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)
await this.grantPermissions(this._options.permissions);
promises.push(this.grantPermissions(this._options.permissions));
if (this._options.geolocation)
await this.setGeolocation(this._options.geolocation);
promises.push(this.setGeolocation(this._options.geolocation));
if (this._options.offline)
await this.setOffline(this._options.offline);
promises.push(this.setOffline(this._options.offline));
if (this._options.httpCredentials)
await this.setHTTPCredentials(this._options.httpCredentials);
promises.push(this.setHTTPCredentials(this._options.httpCredentials));
await Promise.all(promises);
}
_wkPages(): WKPage[] {
@ -344,6 +357,6 @@ export class WKBrowserContext extends BrowserContextBase {
}
await this._browser._browserSession.send('Playwright.deleteContext', { browserContextId: 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 { EventEmitter } from 'events';
import { assert } from '../helper';
import { ConnectionTransport, ProtocolRequest, ProtocolResponse } from '../transport';
import { ConnectionTransport, ProtocolRequest, ProtocolResponse, debugProtocol } from '../transport';
import { Protocol } from './protocol';
// WKPlaywright uses this special id to issue Browser.close command which we
@ -35,7 +35,6 @@ export class WKConnection {
private readonly _onDisconnect: () => void;
private _lastId = 0;
private _closed = false;
_debugProtocol = debug('pw:protocol');
readonly browserSession: WKSession;
@ -47,7 +46,6 @@ export class WKConnection {
this.browserSession = new WKSession(this, '', 'Browser has been closed.', (message: any) => {
this.rawSend(message);
});
(this._debugProtocol as any).color = '34';
}
nextMessageId(): number {
@ -55,14 +53,14 @@ export class WKConnection {
}
rawSend(message: ProtocolRequest) {
if (this._debugProtocol.enabled)
this._debugProtocol('SEND ► ' + rewriteInjectedScriptEvaluationLog(message));
if (debugProtocol.enabled)
debugProtocol('SEND ► ' + rewriteInjectedScriptEvaluationLog(message));
this._transport.send(message);
}
private _dispatchMessage(message: ProtocolResponse) {
if (this._debugProtocol.enabled)
this._debugProtocol('◀ RECV ' + message);
if (debugProtocol.enabled)
debugProtocol('◀ RECV ' + JSON.stringify(message));
if (message.id === kBrowserCloseMessageId)
return;
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.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}) => {
server.setRoute('/empty.html', async (req, res) => {});
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/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',
@ -180,12 +181,12 @@ module.exports.addPlaywrightTests = ({testRunner, platform, products, playwright
state._stdout.on('line', dumpout);
state._stderr.on('line', dumperr);
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._stdout.off('line', dumpout);
state._stderr.off('line', dumperr);
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('./cookies.spec.js');
loadTests('./dialog.spec.js');
loadTests('./download.spec.js');
loadTests('./elementhandle.spec.js');
loadTests('./emulation.spec.js');
loadTests('./evaluation.spec.js');

View file

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