Skip to content

Commit 75571e8

Browse files
authored
feat(downloads): support downloads on cr and wk (#1632)
1 parent 3d6d9db commit 75571e8

22 files changed

+468
-106
lines changed

docs/api.md

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- [class: JSHandle](#class-jshandle)
1515
- [class: ConsoleMessage](#class-consolemessage)
1616
- [class: Dialog](#class-dialog)
17+
- [class: Download](#class-download)
1718
- [class: Keyboard](#class-keyboard)
1819
- [class: Mouse](#class-mouse)
1920
- [class: Request](#class-request)
@@ -191,6 +192,7 @@ Indicates that the browser is connected.
191192

192193
#### browser.newContext([options])
193194
- `options` <[Object]>
195+
- `acceptDownloads` <[boolean]> Whether to automatically download all the attachments. Defaults to `false` where all the downloads are canceled.
194196
- `ignoreHTTPSErrors` <[boolean]> Whether to ignore HTTPS errors during navigation. Defaults to `false`.
195197
- `bypassCSP` <[boolean]> Toggles bypassing page's Content-Security-Policy.
196198
- `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
230232

231233
#### browser.newPage([options])
232234
- `options` <[Object]>
235+
- `acceptDownloads` <[boolean]> Whether to automatically download all the attachments. Defaults to `false` where all the downloads are canceled.
233236
- `ignoreHTTPSErrors` <[boolean]> Whether to ignore HTTPS errors during navigation. Defaults to `false`.
234237
- `bypassCSP` <[boolean]> Toggles bypassing page's Content-Security-Policy.
235238
- `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);
629632
- [event: 'console'](#event-console)
630633
- [event: 'dialog'](#event-dialog)
631634
- [event: 'domcontentloaded'](#event-domcontentloaded)
635+
- [event: 'download'](#event-download)
632636
- [event: 'filechooser'](#event-filechooser)
633637
- [event: 'frameattached'](#event-frameattached)
634638
- [event: 'framedetached'](#event-framedetached)
@@ -729,6 +733,11 @@ Emitted when a JavaScript dialog appears, such as `alert`, `prompt`, `confirm` o
729733

730734
Emitted when the JavaScript [`DOMContentLoaded`](https://developer.mozilla.org/en-US/docs/Web/Events/DOMContentLoaded) event is dispatched.
731735

736+
#### event: 'download'
737+
- <[Download]>
738+
739+
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.
740+
732741
#### event: 'filechooser'
733742
- <[Object]>
734743
- `element` <[ElementHandle]> handle to the input element that was clicked
@@ -2971,6 +2980,58 @@ const { chromium } = require('playwright'); // Or 'firefox' or 'webkit'.
29712980
- returns: <[string]> Dialog's type, can be one of `alert`, `beforeunload`, `confirm` or `prompt`.
29722981

29732982

2983+
### class: Download
2984+
2985+
[Download] objects are dispatched by page via the ['download'](#event-download) event.
2986+
2987+
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.
2988+
2989+
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.
2990+
2991+
An example of using `Download` class:
2992+
```js
2993+
const [ download ] = await Promise.all([
2994+
page.waitForEvent('download'),
2995+
page.click('a')
2996+
]);
2997+
const path = await download.path();
2998+
...
2999+
```
3000+
3001+
<!-- GEN:toc -->
3002+
- [download.createReadStream()](#downloadcreatereadstream)
3003+
- [download.delete()](#downloaddelete)
3004+
- [download.failure()](#downloadfailure)
3005+
- [download.path()](#downloadpath)
3006+
- [download.url()](#downloadurl)
3007+
<!-- GEN:stop -->
3008+
3009+
#### download.createReadStream()
3010+
- returns: <[Promise]<null|[Readable]>>
3011+
3012+
Returns readable stream for current download or `null` if download failed.
3013+
3014+
#### download.delete()
3015+
- returns: <[Promise]>
3016+
3017+
Deletes the downloaded file.
3018+
3019+
#### download.failure()
3020+
- returns: <[Promise]<null|[string]>>
3021+
3022+
Returns download error if any.
3023+
3024+
#### download.path()
3025+
- returns: <[Promise]<null|[string]>>
3026+
3027+
Returns path to the downloaded file in case of successful download.
3028+
3029+
#### download.url()
3030+
- returns: <[string]>
3031+
3032+
Returns downloaded url.
3033+
3034+
29743035
### class: Keyboard
29753036

29763037
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');
41124173
[number]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type "Number"
41134174
[origin]: https://developer.mozilla.org/en-US/docs/Glossary/Origin "Origin"
41144175
[selector]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors "selector"
4115-
[stream.Readable]: https://nodejs.org/api/stream.html#stream_class_stream_readable "stream.Readable"
4176+
[Readable]: https://nodejs.org/api/stream.html#stream_class_stream_readable "Readable"
41164177
[string]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type "String"
41174178
[xpath]: https://developer.mozilla.org/en-US/docs/Web/XPath "xpath"

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"playwright": {
1111
"chromium_revision": "754895",
1212
"firefox_revision": "1069",
13-
"webkit_revision": "1185"
13+
"webkit_revision": "1186"
1414
},
1515
"scripts": {
1616
"ctest": "cross-env BROWSER=chromium node test/test.js",

src/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export { Browser } from './browser';
1919
export { BrowserContext } from './browserContext';
2020
export { ConsoleMessage } from './console';
2121
export { Dialog } from './dialog';
22+
export { Download } from './download';
2223
export { ElementHandle } from './dom';
2324
export { TimeoutError } from './errors';
2425
export { Frame } from './frames';

src/browser.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import { BrowserContext, BrowserContextOptions } from './browserContext';
1818
import { Page } from './page';
1919
import { EventEmitter } from 'events';
20+
import { Download } from './download';
21+
import { debugProtocol } from './transport';
2022

2123
export interface Browser extends EventEmitter {
2224
newContext(options?: BrowserContextOptions): Promise<BrowserContext>;
@@ -25,14 +27,38 @@ export interface Browser extends EventEmitter {
2527
isConnected(): boolean;
2628
close(): Promise<void>;
2729
_disconnect(): Promise<void>;
28-
_setDebugFunction(debugFunction: (message: string) => void): void;
2930
}
3031

31-
export async function createPageInNewContext(browser: Browser, options?: BrowserContextOptions): Promise<Page> {
32-
const context = await browser.newContext(options);
33-
const page = await context.newPage();
34-
page._ownedContext = context;
35-
return page;
32+
export abstract class BrowserBase extends EventEmitter implements Browser {
33+
_downloadsPath: string = '';
34+
private _downloads = new Map<string, Download>();
35+
_debugProtocol = debugProtocol;
36+
37+
abstract newContext(options?: BrowserContextOptions): Promise<BrowserContext>;
38+
abstract contexts(): BrowserContext[];
39+
abstract isConnected(): boolean;
40+
abstract close(): Promise<void>;
41+
abstract _disconnect(): Promise<void>;
42+
43+
async newPage(options?: BrowserContextOptions): Promise<Page> {
44+
const context = await this.newContext(options);
45+
const page = await context.newPage();
46+
page._ownedContext = context;
47+
return page;
48+
}
49+
50+
_downloadCreated(page: Page, uuid: string, url: string) {
51+
const download = new Download(page, this._downloadsPath, uuid, url);
52+
this._downloads.set(uuid, download);
53+
}
54+
55+
_downloadFinished(uuid: string, error: string) {
56+
const download = this._downloads.get(uuid);
57+
if (!download)
58+
return;
59+
download._reportFinished(error);
60+
this._downloads.delete(uuid);
61+
}
3662
}
3763

3864
export type LaunchType = 'local' | 'server' | 'persistent';

src/browserContext.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { TimeoutSettings } from './timeoutSettings';
2222
import * as types from './types';
2323
import { Events } from './events';
2424
import { ExtendedEventEmitter } from './extendedEventEmitter';
25+
import { Download } from './download';
2526

2627
export type BrowserContextOptions = {
2728
viewport?: types.Size | null,
@@ -38,7 +39,8 @@ export type BrowserContextOptions = {
3839
httpCredentials?: types.Credentials,
3940
deviceScaleFactor?: number,
4041
isMobile?: boolean,
41-
hasTouch?: boolean
42+
hasTouch?: boolean,
43+
acceptDownloads?: boolean
4244
};
4345

4446
export interface BrowserContext {
@@ -71,6 +73,7 @@ export abstract class BrowserContextBase extends ExtendedEventEmitter implements
7173
private readonly _closePromise: Promise<Error>;
7274
private _closePromiseFulfill: ((error: Error) => void) | undefined;
7375
readonly _permissions = new Map<string, string[]>();
76+
readonly _downloads = new Set<Download>();
7477

7578
constructor(options: BrowserContextOptions) {
7679
super();
@@ -89,13 +92,16 @@ export abstract class BrowserContextBase extends ExtendedEventEmitter implements
8992
_browserClosed() {
9093
for (const page of this.pages())
9194
page._didClose();
92-
this._didCloseInternal();
95+
this._didCloseInternal(true);
9396
}
9497

95-
_didCloseInternal() {
98+
async _didCloseInternal(omitDeleteDownloads = false) {
9699
this._closed = true;
97100
this.emit(Events.BrowserContext.Close);
98101
this._closePromiseFulfill!(new Error('Context closed'));
102+
if (!omitDeleteDownloads)
103+
await Promise.all([...this._downloads].map(d => d.delete()));
104+
this._downloads.clear();
99105
}
100106

101107
// BrowserContext methods.

src/chromium/crBrowser.ts

Lines changed: 14 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { Browser, createPageInNewContext } from '../browser';
18+
import { BrowserBase } from '../browser';
1919
import { assertBrowserContextIsNotOwned, BrowserContext, BrowserContextBase, BrowserContextOptions, validateBrowserContextOptions, verifyGeolocation } from '../browserContext';
2020
import { Events as CommonEvents } from '../events';
2121
import { assert, debugError, helper } from '../helper';
@@ -29,10 +29,9 @@ import { readProtocolStream } from './crProtocolHelper';
2929
import { Events } from './events';
3030
import { Protocol } from './protocol';
3131
import { CRExecutionContext } from './crExecutionContext';
32-
import { EventEmitter } from 'events';
3332
import type { BrowserServer } from '../server/browserServer';
3433

35-
export class CRBrowser extends EventEmitter implements Browser {
34+
export class CRBrowser extends BrowserBase {
3635
readonly _connection: CRConnection;
3736
_session: CRSession;
3837
private _clientRootSessionPromise: Promise<CRSession> | null = null;
@@ -104,10 +103,6 @@ export class CRBrowser extends EventEmitter implements Browser {
104103
return Array.from(this._contexts.values());
105104
}
106105

107-
async newPage(options?: BrowserContextOptions): Promise<Page> {
108-
return createPageInNewContext(this, options);
109-
}
110-
111106
_onAttachedToTarget({targetInfo, sessionId, waitingForDebugger}: Protocol.Target.attachedToTargetPayload) {
112107
const session = this._connection.session(sessionId)!;
113108
const context = (targetInfo.browserContextId && this._contexts.has(targetInfo.browserContextId)) ?
@@ -250,10 +245,6 @@ export class CRBrowser extends EventEmitter implements Browser {
250245
this._clientRootSessionPromise = this._connection.createBrowserSession();
251246
return this._clientRootSessionPromise;
252247
}
253-
254-
_setDebugFunction(debugFunction: debug.IDebugger) {
255-
this._connection._debugProtocol = debugFunction;
256-
}
257248
}
258249

259250
class CRServiceWorker extends Worker {
@@ -284,12 +275,20 @@ export class CRBrowserContext extends BrowserContextBase {
284275
}
285276

286277
async _initialize() {
278+
const promises: Promise<any>[] = [
279+
this._browser._session.send('Browser.setDownloadBehavior', {
280+
behavior: this._options.acceptDownloads ? 'allowAndName' : 'deny',
281+
browserContextId: this._browserContextId || undefined,
282+
downloadPath: this._browser._downloadsPath
283+
})
284+
];
287285
if (this._options.permissions)
288-
await this.grantPermissions(this._options.permissions);
286+
promises.push(this.grantPermissions(this._options.permissions));
289287
if (this._options.offline)
290-
await this.setOffline(this._options.offline);
288+
promises.push(this.setOffline(this._options.offline));
291289
if (this._options.httpCredentials)
292-
await this.setHTTPCredentials(this._options.httpCredentials);
290+
promises.push(this.setHTTPCredentials(this._options.httpCredentials));
291+
await Promise.all(promises);
293292
}
294293

295294
pages(): Page[] {
@@ -435,7 +434,7 @@ export class CRBrowserContext extends BrowserContextBase {
435434
}
436435
await this._browser._session.send('Target.disposeBrowserContext', { browserContextId: this._browserContextId });
437436
this._browser._contexts.delete(this._browserContextId);
438-
this._didCloseInternal();
437+
await this._didCloseInternal();
439438
}
440439

441440
backgroundPages(): Page[] {

src/chromium/crConnection.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,7 @@
1616
*/
1717

1818
import { assert } from '../helper';
19-
import * as debug from 'debug';
20-
import { ConnectionTransport, ProtocolRequest, ProtocolResponse } from '../transport';
19+
import { ConnectionTransport, ProtocolRequest, ProtocolResponse, debugProtocol } from '../transport';
2120
import { Protocol } from './protocol';
2221
import { EventEmitter } from 'events';
2322

@@ -35,7 +34,6 @@ export class CRConnection extends EventEmitter {
3534
private readonly _sessions = new Map<string, CRSession>();
3635
readonly rootSession: CRSession;
3736
_closed = false;
38-
_debugProtocol: debug.IDebugger;
3937

4038
constructor(transport: ConnectionTransport) {
4139
super();
@@ -44,8 +42,6 @@ export class CRConnection extends EventEmitter {
4442
this._transport.onclose = this._onClose.bind(this);
4543
this.rootSession = new CRSession(this, '', 'browser', '');
4644
this._sessions.set('', this.rootSession);
47-
this._debugProtocol = debug('pw:protocol');
48-
(this._debugProtocol as any).color = '34';
4945
}
5046

5147
static fromSession(session: CRSession): CRConnection {
@@ -61,15 +57,15 @@ export class CRConnection extends EventEmitter {
6157
const message: ProtocolRequest = { id, method, params };
6258
if (sessionId)
6359
message.sessionId = sessionId;
64-
if (this._debugProtocol.enabled)
65-
this._debugProtocol('SEND ► ' + rewriteInjectedScriptEvaluationLog(message));
60+
if (debugProtocol.enabled)
61+
debugProtocol('SEND ► ' + rewriteInjectedScriptEvaluationLog(message));
6662
this._transport.send(message);
6763
return id;
6864
}
6965

7066
async _onMessage(message: ProtocolResponse) {
71-
if (this._debugProtocol.enabled)
72-
this._debugProtocol('◀ RECV ' + JSON.stringify(message));
67+
if (debugProtocol.enabled)
68+
debugProtocol('◀ RECV ' + JSON.stringify(message));
7369
if (message.id === kBrowserCloseMessageId)
7470
return;
7571
if (message.method === 'Target.attachedToTarget') {

src/chromium/crPage.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ export class CRPage implements PageDelegate {
9494
helper.addEventListener(this._client, 'Page.frameStoppedLoading', event => this._onFrameStoppedLoading(event.frameId)),
9595
helper.addEventListener(this._client, 'Page.javascriptDialogOpening', event => this._onDialog(event)),
9696
helper.addEventListener(this._client, 'Page.navigatedWithinDocument', event => this._onFrameNavigatedWithinDocument(event.frameId, event.url)),
97+
helper.addEventListener(this._client, 'Page.downloadWillBegin', event => this._onDownloadWillBegin(event)),
98+
helper.addEventListener(this._client, 'Page.downloadProgress', event => this._onDownloadProgress(event)),
9799
helper.addEventListener(this._client, 'Runtime.bindingCalled', event => this._onBindingCalled(event)),
98100
helper.addEventListener(this._client, 'Runtime.consoleAPICalled', event => this._onConsoleAPI(event)),
99101
helper.addEventListener(this._client, 'Runtime.exceptionThrown', exception => this._handleException(exception.exceptionDetails)),
@@ -168,7 +170,6 @@ export class CRPage implements PageDelegate {
168170
promises.push(this._firstNonInitialNavigationCommittedPromise);
169171
await Promise.all(promises);
170172
}
171-
172173
didClose() {
173174
helper.removeEventListeners(this._eventListeners);
174175
this._networkManager.dispose();
@@ -356,6 +357,17 @@ export class CRPage implements PageDelegate {
356357
this._page._onFileChooserOpened(handle);
357358
}
358359

360+
_onDownloadWillBegin(payload: Protocol.Page.downloadWillBeginPayload) {
361+
this._browserContext._browser._downloadCreated(this._page, payload.guid, payload.url);
362+
}
363+
364+
_onDownloadProgress(payload: Protocol.Page.downloadProgressPayload) {
365+
if (payload.state === 'completed')
366+
this._browserContext._browser._downloadFinished(payload.guid, '');
367+
if (payload.state === 'canceled')
368+
this._browserContext._browser._downloadFinished(payload.guid, 'canceled');
369+
}
370+
359371
async updateExtraHTTPHeaders(): Promise<void> {
360372
const headers = network.mergeHeaders([
361373
this._browserContext._options.extraHTTPHeaders,

0 commit comments

Comments
 (0)