From 75571e8eb897f41da03141a148b34687a5df03b4 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Thu, 2 Apr 2020 17:56:14 -0700 Subject: [PATCH] feat(downloads): support downloads on cr and wk (#1632) --- docs/api.md | 63 +++++++++++++- package.json | 2 +- src/api.ts | 1 + src/browser.ts | 38 +++++++-- src/browserContext.ts | 12 ++- src/chromium/crBrowser.ts | 29 ++++--- src/chromium/crConnection.ts | 14 ++-- src/chromium/crPage.ts | 14 +++- src/download.ts | 88 ++++++++++++++++++++ src/events.ts | 1 + src/firefox/ffBrowser.ts | 15 +--- src/firefox/ffConnection.ts | 13 ++- src/server/chromium.ts | 9 +- src/server/processLauncher.ts | 26 ++++-- src/server/webkit.ts | 9 +- src/transport.ts | 41 ++++++++- src/webkit/wkBrowser.ts | 49 +++++++---- src/webkit/wkConnection.ts | 12 ++- test/autowaiting.spec.js | 4 - test/download.spec.js | 125 ++++++++++++++++++++++++++++ test/playwright.spec.js | 8 +- utils/generate_types/overrides.d.ts | 1 + 22 files changed, 468 insertions(+), 106 deletions(-) create mode 100644 src/download.ts create mode 100644 test/download.spec.js diff --git a/docs/api.md b/docs/api.md index 76332fb91e..6bd9f550a9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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` 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` 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(); +... +``` + + +- [download.createReadStream()](#downloadcreatereadstream) +- [download.delete()](#downloaddelete) +- [download.failure()](#downloadfailure) +- [download.path()](#downloadpath) +- [download.url()](#downloadurl) + + +#### download.createReadStream() +- returns: <[Promise]> + +Returns readable stream for current download or `null` if download failed. + +#### download.delete() +- returns: <[Promise]> + +Deletes the downloaded file. + +#### download.failure() +- returns: <[Promise]> + +Returns download error if any. + +#### download.path() +- returns: <[Promise]> + +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" diff --git a/package.json b/package.json index f3e5bc4121..5c3ad9bbf7 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api.ts b/src/api.ts index ed4694d1bb..070e7b5d74 100644 --- a/src/api.ts +++ b/src/api.ts @@ -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'; diff --git a/src/browser.ts b/src/browser.ts index 986644afa7..4622981b09 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -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; @@ -25,14 +27,38 @@ export interface Browser extends EventEmitter { isConnected(): boolean; close(): Promise; _disconnect(): Promise; - _setDebugFunction(debugFunction: (message: string) => void): void; } -export async function createPageInNewContext(browser: Browser, options?: BrowserContextOptions): Promise { - 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(); + _debugProtocol = debugProtocol; + + abstract newContext(options?: BrowserContextOptions): Promise; + abstract contexts(): BrowserContext[]; + abstract isConnected(): boolean; + abstract close(): Promise; + abstract _disconnect(): Promise; + + async newPage(options?: BrowserContextOptions): Promise { + 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'; diff --git a/src/browserContext.ts b/src/browserContext.ts index 627050536c..a270af4285 100644 --- a/src/browserContext.ts +++ b/src/browserContext.ts @@ -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; private _closePromiseFulfill: ((error: Error) => void) | undefined; readonly _permissions = new Map(); + readonly _downloads = new Set(); 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. diff --git a/src/chromium/crBrowser.ts b/src/chromium/crBrowser.ts index 3958fde9c4..adb0325cf5 100644 --- a/src/chromium/crBrowser.ts +++ b/src/chromium/crBrowser.ts @@ -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 | null = null; @@ -104,10 +103,6 @@ export class CRBrowser extends EventEmitter implements Browser { return Array.from(this._contexts.values()); } - async newPage(options?: BrowserContextOptions): Promise { - 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[] = [ + 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[] { diff --git a/src/chromium/crConnection.ts b/src/chromium/crConnection.ts index da47e1b543..eeb1fe9c58 100644 --- a/src/chromium/crConnection.ts +++ b/src/chromium/crConnection.ts @@ -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(); 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') { diff --git a/src/chromium/crPage.ts b/src/chromium/crPage.ts index 3f61b3bf60..77e2f09e80 100644 --- a/src/chromium/crPage.ts +++ b/src/chromium/crPage.ts @@ -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 { const headers = network.mergeHeaders([ this._browserContext._options.extraHTTPHeaders, diff --git a/src/download.ts b/src/download.ts new file mode 100644 index 0000000000..f67732cfd5 --- /dev/null +++ b/src/download.ts @@ -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; + 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 { + 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 { + if (!this._acceptDownloads) + return 'Pass { acceptDownloads: true } when you are creating your browser context.'; + await this._finishedPromise; + return this._failure; + } + + async createReadStream(): Promise { + const fileName = await this.path(); + return fileName ? fs.createReadStream(fileName) : null; + } + + async delete(): Promise { + 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(); + } +} diff --git a/src/events.ts b/src/events.ts index ee5b63d3a9..20a2729565 100644 --- a/src/events.ts +++ b/src/events.ts @@ -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. diff --git a/src/firefox/ffBrowser.ts b/src/firefox/ffBrowser.ts index 22940a80e4..02f63be688 100644 --- a/src/firefox/ffBrowser.ts +++ b/src/firefox/ffBrowser.ts @@ -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; 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 { - 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(); } } diff --git a/src/firefox/ffConnection.ts b/src/firefox/ffConnection.ts index 98484d68f3..8286796ff3 100644 --- a/src/firefox/ffConnection.ts +++ b/src/firefox/ffConnection.ts @@ -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; private _transport: ConnectionTransport; readonly _sessions: Map; - _debugProtocol = debug('pw:protocol'); _closed: boolean; on: (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( @@ -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) { diff --git a/src/server/chromium.ts b/src/server/chromium.ts index a11c9e11e4..fc55760c46 100644 --- a/src/server/chromium.ts +++ b/src/server/chromium.ts @@ -47,9 +47,10 @@ export class Chromium implements BrowserType { async launch(options: LaunchOptions = {}): Promise { 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 { 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 { 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 { 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 { diff --git a/src/server/processLauncher.ts b/src/server/processLauncher.ts index fd9d0335de..fe680c03f6 100644 --- a/src/server/processLauncher.ts +++ b/src/server/processLauncher.ts @@ -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 }; +type LaunchResult = { + launchedProcess: childProcess.ChildProcess, + gracefullyClose: () => Promise, + downloadsPath: string +}; let lastLaunchedId = 0; @@ -93,6 +102,8 @@ export async function launchProcess(options: LaunchProcessOptions): Promise { spawnedProcess.once('exit', (exitCode, signal) => { @@ -101,13 +112,10 @@ export async function launchProcess(options: LaunchProcessOptions): Promise 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 { diff --git a/src/server/webkit.ts b/src/server/webkit.ts index 0107750a8d..47e07c2985 100644 --- a/src/server/webkit.ts +++ b/src/server/webkit.ts @@ -47,9 +47,10 @@ export class WebKit implements BrowserType { async launch(options: LaunchOptions = {}): Promise { 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 { 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 { 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 { 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 { diff --git a/src/transport.ts b/src/transport.ts index 76bc918116..da60643970 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -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 { 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'; diff --git a/src/webkit/wkBrowser.ts b/src/webkit/wkBrowser.ts index 531662ec34..63c107c715 100644 --- a/src/webkit/wkBrowser.ts +++ b/src/webkit/wkBrowser.ts @@ -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 { - return createPageInNewContext(this, options); - } - async _waitForFirstPageTarget(): Promise { 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[] = [ + 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(); } } diff --git a/src/webkit/wkConnection.ts b/src/webkit/wkConnection.ts index 03fee668ec..1b6ba05cd2 100644 --- a/src/webkit/wkConnection.ts +++ b/src/webkit/wkConnection.ts @@ -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) { diff --git a/test/autowaiting.spec.js b/test/autowaiting.spec.js index e5300c8dc3..7e7725c980 100644 --- a/test/autowaiting.spec.js +++ b/test/autowaiting.spec.js @@ -195,10 +195,6 @@ module.exports.describe = function({testRunner, expect, playwright, MAC, WIN, FF await page.setContent(`foobar`); await page.click('a'); }); - it.fail(FFOX)('clicking on download link', async({page, server, httpsServer}) => { - await page.setContent(`table2.wasm`); - await page.click('a'); - }); it('calling window.stop async', async({page, server, httpsServer}) => { server.setRoute('/empty.html', async (req, res) => {}); await page.evaluate((url) => { diff --git a/test/download.spec.js b/test/download.spec.js new file mode 100644 index 0000000000..1d4337de27 --- /dev/null +++ b/test/download.spec.js @@ -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(`download`); + 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(`download`); + 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(`download`); + 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(`download`); + 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(`download`); + 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(`download`); + 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(); + }); + }); +}; diff --git a/test/playwright.spec.js b/test/playwright.spec.js index feff875f1d..e0af0b399f 100644 --- a/test/playwright.spec.js +++ b/test/playwright.spec.js @@ -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'); diff --git a/utils/generate_types/overrides.d.ts b/utils/generate_types/overrides.d.ts index 42048e0676..351ccab953 100644 --- a/utils/generate_types/overrides.d.ts +++ b/utils/generate_types/overrides.d.ts @@ -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