Skip to content

Commit

Permalink
feat(page): introduce file chooser interception (#4653)
Browse files Browse the repository at this point in the history
This patch introduces a page.waitForFileChooser() method
that adds a watchdog to wait for file chooser dialogs.

This lets Puppeteer users to capture file chooser requests
and fulfill/cancel them if necessary.

Fixes #2946
  • Loading branch information
aslushnikov authored and JoelEinbinder committed Jul 23, 2019
1 parent 2abaac1 commit ea28ccc
Show file tree
Hide file tree
Showing 6 changed files with 400 additions and 46 deletions.
128 changes: 94 additions & 34 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@
* [page.url()](#pageurl)
* [page.viewport()](#pageviewport)
* [page.waitFor(selectorOrFunctionOrTimeout[, options[, ...args]])](#pagewaitforselectororfunctionortimeout-options-args)
* [page.waitForFileChooser([options])](#pagewaitforfilechooseroptions)
* [page.waitForFunction(pageFunction[, options[, ...args]])](#pagewaitforfunctionpagefunction-options-args)
* [page.waitForNavigation([options])](#pagewaitfornavigationoptions)
* [page.waitForRequest(urlOrPredicate[, options])](#pagewaitforrequesturlorpredicate-options)
Expand Down Expand Up @@ -182,6 +183,10 @@
- [class: Tracing](#class-tracing)
* [tracing.start([options])](#tracingstartoptions)
* [tracing.stop()](#tracingstop)
- [class: FileChooser](#class-filechooser)
* [fileChooser.accept(filePaths)](#filechooseracceptfilepaths)
* [fileChooser.cancel()](#filechoosercancel)
* [fileChooser.isMultiple()](#filechooserismultiple)
- [class: Dialog](#class-dialog)
* [dialog.accept([promptText])](#dialogacceptprompttext)
* [dialog.defaultValue()](#dialogdefaultvalue)
Expand Down Expand Up @@ -1727,6 +1732,7 @@ This setting will change the default maximum time for the following methods and
- [page.reload([options])](#pagereloadoptions)
- [page.setContent(html[, options])](#pagesetcontenthtml-options)
- [page.waitFor(selectorOrFunctionOrTimeout[, options[, ...args]])](#pagewaitforselectororfunctionortimeout-options-args)
- [page.waitForFileChooser([options])](#pagewaitforfilechooseroptions)
- [page.waitForFunction(pageFunction[, options[, ...args]])](#pagewaitforfunctionpagefunction-options-args)
- [page.waitForNavigation([options])](#pagewaitfornavigationoptions)
- [page.waitForRequest(urlOrPredicate[, options])](#pagewaitforrequesturlorpredicate-options)
Expand Down Expand Up @@ -1914,6 +1920,28 @@ await page.waitFor(selector => !!document.querySelector(selector), {}, selector)

Shortcut for [page.mainFrame().waitFor(selectorOrFunctionOrTimeout[, options[, ...args]])](#framewaitforselectororfunctionortimeout-options-args).

#### page.waitForFileChooser([options])
- `options` <[Object]> Optional waiting parameters
- `timeout` <[number]> Maximum wait time in milliseconds, defaults to 30 seconds, pass `0` to disable the timeout. The default value can be changed by using the [page.setDefaultTimeout(timeout)](#pagesetdefaulttimeouttimeout) method.
- returns: <[Promise]<[FileChooser]>> A promise that resolves after a page requests a file picker.

> **NOTE** In non-headless Chromium, this method results in the native file picker dialog **not showing up** for the user.
This method is typically coupled with an action that triggers file choosing.
The following example clicks a button that issues a file chooser, and then
responds with `/tmp/myfile.pdf` as if a user has selected this file.

```js
const [fileChooser] = await Promise.all([
page.waitForFileChooser(),
page.click('#upload-file-button'), // some button that triggers file selection
]);
await fileChooser.accept(['/tmp/myfile.pdf']);
```

> **NOTE** This must be called *before* the file chooser is launched. It will not return a currently active file chooser.

#### page.waitForFunction(pageFunction[, options[, ...args]])
- `pageFunction` <[function]|[string]> Function to be evaluated in browser context
- `options` <[Object]> Optional waiting parameters
Expand Down Expand Up @@ -2351,6 +2379,37 @@ Only one trace can be active at a time per browser.
#### tracing.stop()
- returns: <[Promise]<[Buffer]>> Promise which resolves to buffer with trace data.

### class: FileChooser

[FileChooser] objects are returned via the ['page.waitForFileChooser'](#pagewaitforfilechooseroptions) method.

File choosers let you react to the page requesting for a file.

An example of using [FileChooser]:

```js
const [fileChooser] = await Promise.all([
page.waitForFileChooser(),
page.click('#upload-file-button'), // some button that triggers file selection
]);
await fileChooser.accept(['/tmp/myfile.pdf']);
```

> **NOTE** In browsers, only one file chooser can be opened at a time.
> All file choosers must be accepted or canceled. Not doing so will prevent subsequent file choosers from appearing.
#### fileChooser.accept(filePaths)
- `filePaths` <[Array]<[string]>> Accept the file chooser request with given paths. If some of the `filePaths` are relative paths, then they are resolved relative to the [current working directory](https://nodejs.org/api/process.html#process_process_cwd).
- returns: <[Promise]>

#### fileChooser.cancel()
- returns: <[Promise]>

Closes the file chooser without selecting any files.

#### fileChooser.isMultiple()
- returns: <[boolean]> Whether file chooser allow for [multiple](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#attr-multiple) file selection.

### class: Dialog

[Dialog] objects are dispatched by page via the ['dialog'](#event-dialog) event.
Expand Down Expand Up @@ -3626,50 +3685,51 @@ TimeoutError is emitted whenever certain operations are terminated due to timeou



[AXNode]: #accessibilitysnapshotoptions "AXNode"
[Accessibility]: #class-accessibility "Accessibility"
[Array]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array "Array"
[boolean]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Boolean_type "Boolean"
[Body]: #class-body "Body"
[BrowserContext]: #class-browsercontext "BrowserContext"
[BrowserFetcher]: #class-browserfetcher "BrowserFetcher"
[Browser]: #class-browser "Browser"
[Buffer]: https://nodejs.org/api/buffer.html#buffer_class_buffer "Buffer"
[function]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function "Function"
[number]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Number_type "Number"
[Object]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object "Object"
[origin]: https://developer.mozilla.org/en-US/docs/Glossary/Origin "Origin"
[Page]: #class-page "Page"
[Promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise "Promise"
[string]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#String_type "String"
[stream.Readable]: https://nodejs.org/api/stream.html#stream_class_stream_readable "stream.Readable"
[CDPSession]: #class-cdpsession "CDPSession"
[BrowserFetcher]: #class-browserfetcher "BrowserFetcher"
[BrowserContext]: #class-browsercontext "BrowserContext"
[Error]: https://nodejs.org/api/errors.html#errors_class_error "Error"
[Frame]: #class-frame "Frame"
[ConsoleMessage]: #class-consolemessage "ConsoleMessage"
[ChildProcess]: https://nodejs.org/api/child_process.html "ChildProcess"
[ConnectionTransport]: ../lib/WebSocketTransport.js "ConnectionTransport"
[ConsoleMessage]: #class-consolemessage "ConsoleMessage"
[Coverage]: #class-coverage "Coverage"
[iterator]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols "Iterator"
[Response]: #class-response "Response"
[Request]: #class-request "Request"
[Browser]: #class-browser "Browser"
[TimeoutError]: #class-timeouterror "TimeoutError"
[Body]: #class-body "Body"
[Dialog]: #class-dialog "Dialog"
[ElementHandle]: #class-elementhandle "ElementHandle"
[Element]: https://developer.mozilla.org/en-US/docs/Web/API/element "Element"
[Keyboard]: #class-keyboard "Keyboard"
[Dialog]: #class-dialog "Dialog"
[JSHandle]: #class-jshandle "JSHandle"
[Error]: https://nodejs.org/api/errors.html#errors_class_error "Error"
[ExecutionContext]: #class-executioncontext "ExecutionContext"
[Mouse]: #class-mouse "Mouse"
[FileChooser]: #class-filechooser "FileChooser"
[Frame]: #class-frame "Frame"
[JSHandle]: #class-jshandle "JSHandle"
[Keyboard]: #class-keyboard "Keyboard"
[Map]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map "Map"
[selector]: https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors "selector"
[Tracing]: #class-tracing "Tracing"
[ElementHandle]: #class-elementhandle "ElementHandle"
[UIEvent.detail]: https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/detail "UIEvent.detail"
[Mouse]: #class-mouse "Mouse"
[Object]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object "Object"
[Page]: #class-page "Page"
[Promise]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise "Promise"
[Request]: #class-request "Request"
[Response]: #class-response "Response"
[SecurityDetails]: #class-securitydetails "SecurityDetails"
[Serializable]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#Description "Serializable"
[Touchscreen]: #class-touchscreen "Touchscreen"
[Target]: #class-target "Target"
[TimeoutError]: #class-timeouterror "TimeoutError"
[Touchscreen]: #class-touchscreen "Touchscreen"
[Tracing]: #class-tracing "Tracing"
[UIEvent.detail]: https://developer.mozilla.org/en-US/docs/Web/API/UIEvent/detail "UIEvent.detail"
[USKeyboardLayout]: ../lib/USKeyboardLayout.js "USKeyboardLayout"
[xpath]: https://developer.mozilla.org/en-US/docs/Web/XPath "xpath"
[UnixTime]: https://en.wikipedia.org/wiki/Unix_time "Unix Time"
[SecurityDetails]: #class-securitydetails "SecurityDetails"
[Worker]: #class-worker "Worker"
[Accessibility]: #class-accessibility "Accessibility"
[AXNode]: #accessibilitysnapshotoptions "AXNode"
[ConnectionTransport]: ../lib/WebSocketTransport.js "ConnectionTransport"
[boolean]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#Boolean_type "Boolean"
[function]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function "Function"
[iterator]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols "Iterator"
[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"
[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"
102 changes: 95 additions & 7 deletions lib/Page.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

const fs = require('fs');
const path = require('path');
const EventEmitter = require('events');
const mime = require('mime');
const {Events} = require('./Events');
Expand Down Expand Up @@ -43,12 +44,7 @@ class Page extends EventEmitter {
*/
static async create(client, target, ignoreHTTPSErrors, defaultViewport, screenshotTaskQueue) {
const page = new Page(client, target, ignoreHTTPSErrors, screenshotTaskQueue);
await Promise.all([
page._frameManager.initialize(),
client.send('Target.setAutoAttach', {autoAttach: true, waitForDebuggerOnStart: false, flatten: true}),
client.send('Performance.enable', {}),
client.send('Log.enable', {}),
]);
await page._initialize();
if (defaultViewport)
await page.setViewport(defaultViewport);
return page;
Expand Down Expand Up @@ -115,6 +111,8 @@ class Page extends EventEmitter {
networkManager.on(Events.NetworkManager.Response, event => this.emit(Events.Page.Response, event));
networkManager.on(Events.NetworkManager.RequestFailed, event => this.emit(Events.Page.RequestFailed, event));
networkManager.on(Events.NetworkManager.RequestFinished, event => this.emit(Events.Page.RequestFinished, event));
this._fileChooserInterceptionIsDisabled = false;
this._fileChooserInterceptors = new Set();

client.on('Page.domContentEventFired', event => this.emit(Events.Page.DOMContentLoaded));
client.on('Page.loadEventFired', event => this.emit(Events.Page.Load));
Expand All @@ -125,12 +123,59 @@ class Page extends EventEmitter {
client.on('Inspector.targetCrashed', event => this._onTargetCrashed());
client.on('Performance.metrics', event => this._emitMetrics(event));
client.on('Log.entryAdded', event => this._onLogEntryAdded(event));
client.on('Page.fileChooserOpened', event => this._onFileChooser(event));
this._target._isClosedPromise.then(() => {
this.emit(Events.Page.Close);
this._closed = true;
});
}

async _initialize() {
await Promise.all([
this._frameManager.initialize(),
this._client.send('Target.setAutoAttach', {autoAttach: true, waitForDebuggerOnStart: false, flatten: true}),
this._client.send('Performance.enable', {}),
this._client.send('Log.enable', {}),
this._client.send('Page.setInterceptFileChooserDialog', {enabled: true}).catch(e => {
this._fileChooserInterceptionIsDisabled = true;
}),
]);
}

/**
* @param {!Protocol.Page.fileChooserOpenedPayload} event
*/
_onFileChooser(event) {
if (!this._fileChooserInterceptors.size) {
this._client.send('Page.handleFileChooser', { action: 'fallback' }).catch(debugError);
return;
}
const interceptors = Array.from(this._fileChooserInterceptors);
this._fileChooserInterceptors.clear();
const fileChooser = new FileChooser(this._client, event);
for (const interceptor of interceptors)
interceptor.call(null, fileChooser);
}

/**
* @param {!{timeout?: number}=} options
* @return !Promise<!FileChooser>}
*/
async waitForFileChooser(options = {}) {
if (this._fileChooserInterceptionIsDisabled)
throw new Error('File chooser handling does not work with multiple connections to the same page');
const {
timeout = this._timeoutSettings.timeout(),
} = options;
let callback;
const promise = new Promise(x => callback = x);
this._fileChooserInterceptors.add(callback);
return helper.waitWithTimeout(promise, 'waiting for file chooser', timeout).catch(e => {
this._fileChooserInterceptors.delete(callback);
throw e;
});
}

/**
* @param {!{longitude: number, latitude: number, accuracy: (number|undefined)}} options
*/
Expand Down Expand Up @@ -1258,5 +1303,48 @@ class ConsoleMessage {
}
}

class FileChooser {
/**
* @param {Puppeteer.CDPSession} client
* @param {!Protocol.Page.fileChooserOpenedPayload} event
*/
constructor(client, event) {
this._client = client;
this._multiple = event.mode !== 'selectSingle';
this._handled = false;
}

/**
* @return {boolean}
*/
isMultiple() {
return this._multiple;
}

/**
* @param {!Array<string>} filePaths
* @return {!Promise}
*/
async accept(filePaths) {
assert(!this._handled, 'Cannot accept FileChooser which is already handled!');
this._handled = true;
const files = filePaths.map(filePath => path.resolve(filePath));
await this._client.send('Page.handleFileChooser', {
action: 'accept',
files,
});
}

/**
* @return {!Promise}
*/
async cancel() {
assert(!this._handled, 'Cannot cancel FileChooser which is already handled!');
this._handled = true;
await this._client.send('Page.handleFileChooser', {
action: 'cancel',
});
}
}

module.exports = {Page, ConsoleMessage};
module.exports = {Page, ConsoleMessage, FileChooser};
1 change: 1 addition & 0 deletions lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ module.exports = {
Dialog: require('./Dialog').Dialog,
ElementHandle: require('./JSHandle').ElementHandle,
ExecutionContext: require('./ExecutionContext').ExecutionContext,
FileChooser: require('./Page').FileChooser,
Frame: require('./FrameManager').Frame,
JSHandle: require('./JSHandle').JSHandle,
Keyboard: require('./Input').Keyboard,
Expand Down
7 changes: 5 additions & 2 deletions lib/helper.js
Original file line number Diff line number Diff line change
Expand Up @@ -214,11 +214,14 @@ class Helper {
let reject;
const timeoutError = new TimeoutError(`waiting for ${taskName} failed: timeout ${timeout}ms exceeded`);
const timeoutPromise = new Promise((resolve, x) => reject = x);
const timeoutTimer = setTimeout(() => reject(timeoutError), timeout);
let timeoutTimer = null;
if (timeout)
timeoutTimer = setTimeout(() => reject(timeoutError), timeout);
try {
return await Promise.race([promise, timeoutPromise]);
} finally {
clearTimeout(timeoutTimer);
if (timeoutTimer)
clearTimeout(timeoutTimer);
}
}

Expand Down
17 changes: 17 additions & 0 deletions test/chromiumonly.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,23 @@ module.exports.addLauncherTests = function({testRunner, expect, defaultBrowserOp
await disconnectedEventPromise;
});
});

describe('Page.waitForFileChooser', () => {
it('should fail gracefully when trying to work with filechoosers within multiple connections', async() => {
// 1. Launch a browser and connect to all pages.
const originalBrowser = await puppeteer.launch(defaultBrowserOptions);
await originalBrowser.pages();
// 2. Connect a remote browser and connect to first page.
const remoteBrowser = await puppeteer.connect({browserWSEndpoint: originalBrowser.wsEndpoint()});
const [page] = await remoteBrowser.pages();
// 3. Make sure |page.waitForFileChooser()| does not work with multiclient.
let error = null;
await page.waitForFileChooser().catch(e => error = e);
expect(error.message).toBe('File chooser handling does not work with multiple connections to the same page');
originalBrowser.close();
});

});
});
};

Expand Down
Loading

0 comments on commit ea28ccc

Please sign in to comment.