From 75249c9d26682904440b8011d9d60324191a021e Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Wed, 20 May 2026 17:02:47 -0700 Subject: [PATCH 1/2] feat(electron): intercept main-process dialog API Adds two events on ElectronApplication for intercepting the Electron main-process dialog API: - `filechooser` (ElectronFileChooser) for `dialog.showOpenDialog` and `dialog.showSaveDialog`, with `setFiles(paths)` / `cancel()`. - `dialog` (ElectronDialog) for `dialog.showMessageBox` and `dialog.showCertificateTrustDialog`, with `accept(result)` / `dismiss()`. The hook is installed via CDP `Runtime.evaluate` against the Node session, so it works for both packaged and non-packaged apps. The patched dialog methods stream call payloads back via a `Runtime.addBinding` binding and await results from the Playwright server. Fixes https://github.com/microsoft/playwright/issues/8278 --- .../electron-api/class-electronapplication.md | 39 ++ docs/src/electron-api/class-electrondialog.md | 47 ++ .../electron-api/class-electronfilechooser.md | 51 ++ packages/isomorphic/protocolMetainfo.ts | 4 + packages/playwright-client/types/types.d.ts | 482 ++++++++++++++++++ .../playwright-core/src/client/connection.ts | 4 +- .../playwright-core/src/client/electron.ts | 69 +++ packages/playwright-core/src/client/events.ts | 2 + .../playwright-core/src/protocol/validator.ts | 28 +- .../server/dispatchers/electronDispatcher.ts | 57 ++- .../src/server/electron/dialogInterception.ts | 56 ++ .../src/server/electron/electron.ts | 48 ++ .../src/server/electron/electronDialogs.ts | 101 ++++ packages/playwright-core/types/types.d.ts | 482 ++++++++++++++++++ packages/protocol/spec/electron.yml | 54 ++ packages/protocol/src/channels.d.ts | 70 ++- tests/electron/electron-dialog.spec.ts | 49 ++ tests/electron/electron-filechooser.spec.ts | 90 ++++ 18 files changed, 1729 insertions(+), 4 deletions(-) create mode 100644 docs/src/electron-api/class-electrondialog.md create mode 100644 docs/src/electron-api/class-electronfilechooser.md create mode 100644 packages/playwright-core/src/server/electron/dialogInterception.ts create mode 100644 packages/playwright-core/src/server/electron/electronDialogs.ts create mode 100644 tests/electron/electron-dialog.spec.ts create mode 100644 tests/electron/electron-filechooser.spec.ts diff --git a/docs/src/electron-api/class-electronapplication.md b/docs/src/electron-api/class-electronapplication.md index 87e7975b352d7..d732986fddf2f 100644 --- a/docs/src/electron-api/class-electronapplication.md +++ b/docs/src/electron-api/class-electronapplication.md @@ -41,6 +41,45 @@ const { _electron: electron } = require('playwright'); This event is issued when the application process has been terminated. +## event: ElectronApplication.dialog +* since: v1.61 +- argument: <[ElectronDialog]> + +Emitted when the Electron main process invokes a message dialog method — +`dialog.showMessageBox` or `dialog.showCertificateTrustDialog`. The handler is expected to +either [`method: ElectronDialog.accept`] with the desired result or +[`method: ElectronDialog.dismiss`] the dialog. If no handler is attached, the original +Electron dialog methods are called as usual. + +For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see +[`event: ElectronApplication.fileChooser`]. + +**Usage** + +```js +electronApp.on('dialog', async dialog => { + await dialog.accept({ response: 0, checkboxChecked: false }); +}); +``` + +## event: ElectronApplication.fileChooser +* since: v1.61 +- argument: <[ElectronFileChooser]> + +Emitted when the Electron main process invokes a file dialog method — +`dialog.showOpenDialog` or `dialog.showSaveDialog`. The handler is expected to either +[`method: ElectronFileChooser.setFiles`] to fulfill the dialog with file paths or +[`method: ElectronFileChooser.cancel`] to cancel it. If no handler is attached, the +original Electron dialog methods are called as usual. + +**Usage** + +```js +electronApp.on('fileChooser', async chooser => { + await chooser.setFiles(['/path/to/file.txt']); +}); +``` + ## event: ElectronApplication.console * since: v1.42 - argument: <[ConsoleMessage]> diff --git a/docs/src/electron-api/class-electrondialog.md b/docs/src/electron-api/class-electrondialog.md new file mode 100644 index 0000000000000..9f51ccf5787fe --- /dev/null +++ b/docs/src/electron-api/class-electrondialog.md @@ -0,0 +1,47 @@ +# class: ElectronDialog +* since: v1.61 +* langs: js + +Represents a message dialog initiated by the Electron main process via [`dialog.showMessageBox`](https://www.electronjs.org/docs/latest/api/dialog#dialogshowmessageboxbrowserwindow-options) +or [`dialog.showCertificateTrustDialog`](https://www.electronjs.org/docs/latest/api/dialog#dialogshowcertificatetrustdialogbrowserwindow-options-macos-windows). + +Instances of this class are received via the [`event: ElectronApplication.dialog`] event. +For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see [ElectronFileChooser]. + +```js +electronApp.on('dialog', async dialog => { + // dialog.method() === 'showMessageBox' | 'showCertificateTrustDialog' + await dialog.accept({ response: 1, checkboxChecked: false }); +}); +``` + +## async method: ElectronDialog.accept +* since: v1.61 + +Resolves the underlying Electron dialog with the provided result. For `showMessageBox` the +expected shape is `{ response: number, checkboxChecked?: boolean }`. + +### param: ElectronDialog.accept.result +* since: v1.61 +- `result` <[Serializable]> + +The value to resolve the dialog with. + +## async method: ElectronDialog.dismiss +* since: v1.61 + +Dismisses the dialog. The Electron dialog method resolves with a default value — for +`showMessageBox` that is `{ response: 0, checkboxChecked: false }`. + +## method: ElectronDialog.method +* since: v1.61 +- returns: <[string]<"showMessageBox"|"showCertificateTrustDialog">> + +The name of the [Electron dialog method](https://www.electronjs.org/docs/latest/api/dialog) that triggered the +event. + +## method: ElectronDialog.options +* since: v1.61 +- returns: <[Serializable]> + +The options object that was passed to the underlying Electron dialog method. diff --git a/docs/src/electron-api/class-electronfilechooser.md b/docs/src/electron-api/class-electronfilechooser.md new file mode 100644 index 0000000000000..ae227d91bafb6 --- /dev/null +++ b/docs/src/electron-api/class-electronfilechooser.md @@ -0,0 +1,51 @@ +# class: ElectronFileChooser +* since: v1.61 +* langs: js + +Represents a file selection dialog initiated by the Electron main process via +[`dialog.showOpenDialog`](https://www.electronjs.org/docs/latest/api/dialog#dialogshowopendialogbrowserwindow-options) +or [`dialog.showSaveDialog`](https://www.electronjs.org/docs/latest/api/dialog#dialogshowsavedialogbrowserwindow-options). + +Instances of this class are received via the [`event: ElectronApplication.fileChooser`] event. +Tests can call [`method: ElectronFileChooser.setFiles`] to fulfill the dialog with the given +file paths, or [`method: ElectronFileChooser.cancel`] to cancel the dialog. + +```js +electronApp.on('fileChooser', async chooser => { + await chooser.setFiles(['/path/to/file.txt']); +}); +``` + +## async method: ElectronFileChooser.setFiles +* since: v1.61 + +Resolves the underlying Electron file dialog with the provided paths. + +For [`dialog.showOpenDialog`] the dialog resolves with +`{ canceled: false, filePaths: [...] }`. For [`dialog.showSaveDialog`] the dialog resolves +with `{ canceled: false, filePath: filePaths[0] }`. + +### param: ElectronFileChooser.setFiles.filePaths +* since: v1.61 +- `filePaths` <[string]|[Array]<[string]>> + +The file path(s) to provide to the Electron dialog. + +## async method: ElectronFileChooser.cancel +* since: v1.61 + +Cancels the file dialog. The Electron dialog method resolves with `{ canceled: true, filePaths: [] }` +for `showOpenDialog`, or `{ canceled: true, filePath: '' }` for `showSaveDialog`. + +## method: ElectronFileChooser.method +* since: v1.61 +- returns: <[string]<"showOpenDialog"|"showSaveDialog">> + +The name of the [Electron dialog method](https://www.electronjs.org/docs/latest/api/dialog) that triggered the +event. + +## method: ElectronFileChooser.options +* since: v1.61 +- returns: <[Serializable]> + +The options object that was passed to the underlying Electron dialog method. diff --git a/packages/isomorphic/protocolMetainfo.ts b/packages/isomorphic/protocolMetainfo.ts index b0e779d1b061a..5b851dffc38a6 100644 --- a/packages/isomorphic/protocolMetainfo.ts +++ b/packages/isomorphic/protocolMetainfo.ts @@ -122,6 +122,10 @@ export const methodMetainfo = new Map([ ['ElectronApplication.evaluateExpression', { title: 'Evaluate', }], ['ElectronApplication.evaluateExpressionHandle', { title: 'Evaluate', }], ['ElectronApplication.updateSubscription', { internal: true, }], + ['ElectronDialog.accept', { title: 'Accept dialog', }], + ['ElectronDialog.dismiss', { title: 'Dismiss dialog', }], + ['ElectronFileChooser.setFiles', { title: 'Set files for the dialog', }], + ['ElectronFileChooser.cancel', { title: 'Cancel the file chooser', }], ['Frame.evalOnSelector', { title: 'Evaluate', snapshot: true, pause: true, }], ['Frame.evalOnSelectorAll', { title: 'Evaluate', snapshot: true, pause: true, }], ['Frame.addScriptTag', { title: 'Add script tag', snapshot: true, pause: true, }], diff --git a/packages/playwright-client/types/types.d.ts b/packages/playwright-client/types/types.d.ts index 05fe4dc968db1..cb2d5784884bc 100644 --- a/packages/playwright-client/types/types.d.ts +++ b/packages/playwright-client/types/types.d.ts @@ -16998,6 +16998,47 @@ export interface ElectronApplication { */ on(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Emitted when the Electron main process invokes a message dialog method — `dialog.showMessageBox` or + * `dialog.showCertificateTrustDialog`. The handler is expected to either + * [electronDialog.accept(result)](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-accept) with + * the desired result or + * [electronDialog.dismiss()](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-dismiss) the + * dialog. If no handler is attached, the original Electron dialog methods are called as usual. + * + * For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser). + * + * **Usage** + * + * ```js + * electronApp.on('dialog', async dialog => { + * await dialog.accept({ response: 0, checkboxChecked: false }); + * }); + * ``` + * + */ + on(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Emitted when the Electron main process invokes a file dialog method — `dialog.showOpenDialog` or + * `dialog.showSaveDialog`. The handler is expected to either + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with file paths or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel it. If no handler is attached, the original Electron dialog methods are called as usual. + * + * **Usage** + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ + on(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * This event is issued for every window that is created **and loaded** in Electron. It contains a * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. @@ -17014,6 +17055,16 @@ export interface ElectronApplication { */ once(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. */ @@ -17046,6 +17097,47 @@ export interface ElectronApplication { */ addListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Emitted when the Electron main process invokes a message dialog method — `dialog.showMessageBox` or + * `dialog.showCertificateTrustDialog`. The handler is expected to either + * [electronDialog.accept(result)](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-accept) with + * the desired result or + * [electronDialog.dismiss()](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-dismiss) the + * dialog. If no handler is attached, the original Electron dialog methods are called as usual. + * + * For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser). + * + * **Usage** + * + * ```js + * electronApp.on('dialog', async dialog => { + * await dialog.accept({ response: 0, checkboxChecked: false }); + * }); + * ``` + * + */ + addListener(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Emitted when the Electron main process invokes a file dialog method — `dialog.showOpenDialog` or + * `dialog.showSaveDialog`. The handler is expected to either + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with file paths or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel it. If no handler is attached, the original Electron dialog methods are called as usual. + * + * **Usage** + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ + addListener(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * This event is issued for every window that is created **and loaded** in Electron. It contains a * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. @@ -17062,6 +17154,16 @@ export interface ElectronApplication { */ removeListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -17077,6 +17179,16 @@ export interface ElectronApplication { */ off(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -17109,6 +17221,47 @@ export interface ElectronApplication { */ prependListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Emitted when the Electron main process invokes a message dialog method — `dialog.showMessageBox` or + * `dialog.showCertificateTrustDialog`. The handler is expected to either + * [electronDialog.accept(result)](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-accept) with + * the desired result or + * [electronDialog.dismiss()](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-dismiss) the + * dialog. If no handler is attached, the original Electron dialog methods are called as usual. + * + * For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser). + * + * **Usage** + * + * ```js + * electronApp.on('dialog', async dialog => { + * await dialog.accept({ response: 0, checkboxChecked: false }); + * }); + * ``` + * + */ + prependListener(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Emitted when the Electron main process invokes a file dialog method — `dialog.showOpenDialog` or + * `dialog.showSaveDialog`. The handler is expected to either + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with file paths or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel it. If no handler is attached, the original Electron dialog methods are called as usual. + * + * **Usage** + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ + prependListener(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * This event is issued for every window that is created **and loaded** in Electron. It contains a * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. @@ -17187,6 +17340,47 @@ export interface ElectronApplication { */ waitForEvent(event: 'console', optionsOrPredicate?: { predicate?: (consoleMessage: ConsoleMessage) => boolean | Promise, timeout?: number } | ((consoleMessage: ConsoleMessage) => boolean | Promise)): Promise; + /** + * Emitted when the Electron main process invokes a message dialog method — `dialog.showMessageBox` or + * `dialog.showCertificateTrustDialog`. The handler is expected to either + * [electronDialog.accept(result)](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-accept) with + * the desired result or + * [electronDialog.dismiss()](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-dismiss) the + * dialog. If no handler is attached, the original Electron dialog methods are called as usual. + * + * For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser). + * + * **Usage** + * + * ```js + * electronApp.on('dialog', async dialog => { + * await dialog.accept({ response: 0, checkboxChecked: false }); + * }); + * ``` + * + */ + waitForEvent(event: 'dialog', optionsOrPredicate?: { predicate?: (electronDialog: ElectronDialog) => boolean | Promise, timeout?: number } | ((electronDialog: ElectronDialog) => boolean | Promise)): Promise; + + /** + * Emitted when the Electron main process invokes a file dialog method — `dialog.showOpenDialog` or + * `dialog.showSaveDialog`. The handler is expected to either + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with file paths or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel it. If no handler is attached, the original Electron dialog methods are called as usual. + * + * **Usage** + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ + waitForEvent(event: 'filechooser', optionsOrPredicate?: { predicate?: (electronFileChooser: ElectronFileChooser) => boolean | Promise, timeout?: number } | ((electronFileChooser: ElectronFileChooser) => boolean | Promise)): Promise; + /** * This event is issued for every window that is created **and loaded** in Electron. It contains a * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. @@ -17550,6 +17744,47 @@ export interface ElectronApplication { */ on(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Emitted when the Electron main process invokes a message dialog method — `dialog.showMessageBox` or + * `dialog.showCertificateTrustDialog`. The handler is expected to either + * [electronDialog.accept(result)](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-accept) with + * the desired result or + * [electronDialog.dismiss()](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-dismiss) the + * dialog. If no handler is attached, the original Electron dialog methods are called as usual. + * + * For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser). + * + * **Usage** + * + * ```js + * electronApp.on('dialog', async dialog => { + * await dialog.accept({ response: 0, checkboxChecked: false }); + * }); + * ``` + * + */ + on(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Emitted when the Electron main process invokes a file dialog method — `dialog.showOpenDialog` or + * `dialog.showSaveDialog`. The handler is expected to either + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with file paths or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel it. If no handler is attached, the original Electron dialog methods are called as usual. + * + * **Usage** + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ + on(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * This event is issued for every window that is created **and loaded** in Electron. It contains a * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. @@ -17566,6 +17801,16 @@ export interface ElectronApplication { */ once(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. */ @@ -17598,6 +17843,47 @@ export interface ElectronApplication { */ addListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Emitted when the Electron main process invokes a message dialog method — `dialog.showMessageBox` or + * `dialog.showCertificateTrustDialog`. The handler is expected to either + * [electronDialog.accept(result)](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-accept) with + * the desired result or + * [electronDialog.dismiss()](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-dismiss) the + * dialog. If no handler is attached, the original Electron dialog methods are called as usual. + * + * For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser). + * + * **Usage** + * + * ```js + * electronApp.on('dialog', async dialog => { + * await dialog.accept({ response: 0, checkboxChecked: false }); + * }); + * ``` + * + */ + addListener(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Emitted when the Electron main process invokes a file dialog method — `dialog.showOpenDialog` or + * `dialog.showSaveDialog`. The handler is expected to either + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with file paths or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel it. If no handler is attached, the original Electron dialog methods are called as usual. + * + * **Usage** + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ + addListener(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * This event is issued for every window that is created **and loaded** in Electron. It contains a * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. @@ -17614,6 +17900,16 @@ export interface ElectronApplication { */ removeListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -17629,6 +17925,16 @@ export interface ElectronApplication { */ off(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -17661,6 +17967,47 @@ export interface ElectronApplication { */ prependListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Emitted when the Electron main process invokes a message dialog method — `dialog.showMessageBox` or + * `dialog.showCertificateTrustDialog`. The handler is expected to either + * [electronDialog.accept(result)](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-accept) with + * the desired result or + * [electronDialog.dismiss()](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-dismiss) the + * dialog. If no handler is attached, the original Electron dialog methods are called as usual. + * + * For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser). + * + * **Usage** + * + * ```js + * electronApp.on('dialog', async dialog => { + * await dialog.accept({ response: 0, checkboxChecked: false }); + * }); + * ``` + * + */ + prependListener(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Emitted when the Electron main process invokes a file dialog method — `dialog.showOpenDialog` or + * `dialog.showSaveDialog`. The handler is expected to either + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with file paths or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel it. If no handler is attached, the original Electron dialog methods are called as usual. + * + * **Usage** + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ + prependListener(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * This event is issued for every window that is created **and loaded** in Electron. It contains a * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. @@ -17739,6 +18086,47 @@ export interface ElectronApplication { */ waitForEvent(event: 'console', optionsOrPredicate?: { predicate?: (consoleMessage: ConsoleMessage) => boolean | Promise, timeout?: number } | ((consoleMessage: ConsoleMessage) => boolean | Promise)): Promise; + /** + * Emitted when the Electron main process invokes a message dialog method — `dialog.showMessageBox` or + * `dialog.showCertificateTrustDialog`. The handler is expected to either + * [electronDialog.accept(result)](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-accept) with + * the desired result or + * [electronDialog.dismiss()](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-dismiss) the + * dialog. If no handler is attached, the original Electron dialog methods are called as usual. + * + * For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser). + * + * **Usage** + * + * ```js + * electronApp.on('dialog', async dialog => { + * await dialog.accept({ response: 0, checkboxChecked: false }); + * }); + * ``` + * + */ + waitForEvent(event: 'dialog', optionsOrPredicate?: { predicate?: (electronDialog: ElectronDialog) => boolean | Promise, timeout?: number } | ((electronDialog: ElectronDialog) => boolean | Promise)): Promise; + + /** + * Emitted when the Electron main process invokes a file dialog method — `dialog.showOpenDialog` or + * `dialog.showSaveDialog`. The handler is expected to either + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with file paths or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel it. If no handler is attached, the original Electron dialog methods are called as usual. + * + * **Usage** + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ + waitForEvent(event: 'filechooser', optionsOrPredicate?: { predicate?: (electronFileChooser: ElectronFileChooser) => boolean | Promise, timeout?: number } | ((electronFileChooser: ElectronFileChooser) => boolean | Promise)): Promise; + /** * This event is issued for every window that is created **and loaded** in Electron. It contains a * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. @@ -22431,6 +22819,100 @@ export interface Electron { }): Promise; } +/** + * Represents a message dialog initiated by the Electron main process via + * [`dialog.showMessageBox`](https://www.electronjs.org/docs/latest/api/dialog#dialogshowmessageboxbrowserwindow-options) + * or + * [`dialog.showCertificateTrustDialog`](https://www.electronjs.org/docs/latest/api/dialog#dialogshowcertificatetrustdialogbrowserwindow-options-macos-windows). + * + * Instances of this class are received via the + * [electronApplication.on('dialog')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-dialog) + * event. For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [ElectronFileChooser](https://playwright.dev/docs/api/class-electronfilechooser). + * + * ```js + * electronApp.on('dialog', async dialog => { + * // dialog.method() === 'showMessageBox' | 'showCertificateTrustDialog' + * await dialog.accept({ response: 1, checkboxChecked: false }); + * }); + * ``` + * + */ +export interface ElectronDialog { + /** + * Resolves the underlying Electron dialog with the provided result. For `showMessageBox` the expected shape is `{ + * response: number, checkboxChecked?: boolean }`. + * @param result The value to resolve the dialog with. + */ + accept(result: Serializable): Promise; + + /** + * Dismisses the dialog. The Electron dialog method resolves with a default value — for `showMessageBox` that is `{ + * response: 0, checkboxChecked: false }`. + */ + dismiss(): Promise; + + /** + * The name of the [Electron dialog method](https://www.electronjs.org/docs/latest/api/dialog) that triggered the + * event. + */ + method(): "showMessageBox"|"showCertificateTrustDialog"; + + /** + * The options object that was passed to the underlying Electron dialog method. + */ + options(): Serializable; +} + +/** + * Represents a file selection dialog initiated by the Electron main process via + * [`dialog.showOpenDialog`](https://www.electronjs.org/docs/latest/api/dialog#dialogshowopendialogbrowserwindow-options) + * or + * [`dialog.showSaveDialog`](https://www.electronjs.org/docs/latest/api/dialog#dialogshowsavedialogbrowserwindow-options). + * + * Instances of this class are received via the + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser) + * event. Tests can call + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with the given file paths, or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel the dialog. + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ +export interface ElectronFileChooser { + /** + * Cancels the file dialog. The Electron dialog method resolves with `{ canceled: true, filePaths: [] }` for + * `showOpenDialog`, or `{ canceled: true, filePath: '' }` for `showSaveDialog`. + */ + cancel(): Promise; + + /** + * The name of the [Electron dialog method](https://www.electronjs.org/docs/latest/api/dialog) that triggered the + * event. + */ + method(): "showOpenDialog"|"showSaveDialog"; + + /** + * The options object that was passed to the underlying Electron dialog method. + */ + options(): Serializable; + + /** + * Resolves the underlying Electron file dialog with the provided paths. + * + * For [`dialog.showOpenDialog`] the dialog resolves with `{ canceled: false, filePaths: [...] }`. For + * [`dialog.showSaveDialog`] the dialog resolves with `{ canceled: false, filePath: filePaths[0] }`. + * @param filePaths The file path(s) to provide to the Electron dialog. + */ + setFiles(filePaths: string|ReadonlyArray): Promise; +} + /** * Playwright has **experimental** support for Android automation. This includes Chrome for Android and Android * WebView. diff --git a/packages/playwright-core/src/client/connection.ts b/packages/playwright-core/src/client/connection.ts index 4e72253fb1f0d..9f3dbd7d4e5d0 100644 --- a/packages/playwright-core/src/client/connection.ts +++ b/packages/playwright-core/src/client/connection.ts @@ -27,7 +27,7 @@ import { createInstrumentation } from './clientInstrumentation'; import { Debugger } from './debugger'; import { Dialog } from './dialog'; import { DisposableObject } from './disposable'; -import { Electron, ElectronApplication } from './electron'; +import { Electron, ElectronApplication, ElectronDialog, ElectronFileChooser } from './electron'; import { ElementHandle } from './elementHandle'; import { TargetClosedError, parseError } from './errors'; import { APIRequestContext } from './fetch'; @@ -106,6 +106,8 @@ export class Connection extends EventEmitter { Disposable: (parent, type, guid, init) => new DisposableObject(parent, type, guid, init), Electron: (parent, type, guid, init) => new Electron(parent, type, guid, init), ElectronApplication: (parent, type, guid, init) => new ElectronApplication(parent, type, guid, init), + ElectronDialog: (parent, type, guid, init) => new ElectronDialog(parent, type, guid, init), + ElectronFileChooser: (parent, type, guid, init) => new ElectronFileChooser(parent, type, guid, init), ElementHandle: (parent, type, guid, init) => new ElementHandle(parent, type, guid, init), Frame: (parent, type, guid, init) => new Frame(parent, type, guid, init), JSHandle: (parent, type, guid, init) => new JSHandle(parent, type, guid, init), diff --git a/packages/playwright-core/src/client/electron.ts b/packages/playwright-core/src/client/electron.ts index 2399aa61ff1ce..f9143fcbecada 100644 --- a/packages/playwright-core/src/client/electron.ts +++ b/packages/playwright-core/src/client/electron.ts @@ -33,6 +33,71 @@ import type * as childProcess from 'child_process'; import type { BrowserWindow } from 'electron'; import type { Playwright } from './playwright'; +export class ElectronDialog extends ChannelOwner implements api.ElectronDialog { + static from(dialog: channels.ElectronDialogChannel): ElectronDialog { + return (dialog as any)._object; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.ElectronDialogInitializer) { + super(parent, type, guid, initializer); + } + + method(): channels.ElectronDialogInitializer['method'] { + return this._initializer.method; + } + + options(): any { + return this._initializer.options; + } + + async accept(result: any): Promise { + await this._channel.accept({ result }); + } + + async dismiss(): Promise { + try { + await this._channel.dismiss(); + } catch (e) { + if (isTargetClosedError(e)) + return; + throw e; + } + } +} + +export class ElectronFileChooser extends ChannelOwner implements api.ElectronFileChooser { + static from(fileChooser: channels.ElectronFileChooserChannel): ElectronFileChooser { + return (fileChooser as any)._object; + } + + constructor(parent: ChannelOwner, type: string, guid: string, initializer: channels.ElectronFileChooserInitializer) { + super(parent, type, guid, initializer); + } + + method(): channels.ElectronFileChooserInitializer['method'] { + return this._initializer.method; + } + + options(): any { + return this._initializer.options; + } + + async setFiles(filePaths: string | string[]): Promise { + const paths = Array.isArray(filePaths) ? filePaths : [filePaths]; + await this._channel.setFiles({ filePaths: paths }); + } + + async cancel(): Promise { + try { + await this._channel.cancel(); + } catch (e) { + if (isTargetClosedError(e)) + return; + throw e; + } + } +} + type ElectronOptions = Omit & { env?: NodeJS.ProcessEnv, extraHTTPHeaders?: Headers, @@ -94,8 +159,12 @@ export class ElectronApplication extends ChannelOwner this.emit(Events.ElectronApplication.Console, new ConsoleMessage(this._platform, event, null, null))); + this._channel.on('dialog', event => this.emit(Events.ElectronApplication.Dialog, ElectronDialog.from(event.dialog))); + this._channel.on('fileChooser', event => this.emit(Events.ElectronApplication.FileChooser, ElectronFileChooser.from(event.fileChooser))); this._setEventToSubscriptionMapping(new Map([ [Events.ElectronApplication.Console, 'console'], + [Events.ElectronApplication.Dialog, 'dialog'], + [Events.ElectronApplication.FileChooser, 'fileChooser'], ])); } diff --git a/packages/playwright-core/src/client/events.ts b/packages/playwright-core/src/client/events.ts index c69b3aa5a47b6..8a3f4e3839fb0 100644 --- a/packages/playwright-core/src/client/events.ts +++ b/packages/playwright-core/src/client/events.ts @@ -104,6 +104,8 @@ export const Events = { ElectronApplication: { Close: 'close', Console: 'console', + Dialog: 'dialog', + FileChooser: 'filechooser', Window: 'window', }, }; diff --git a/packages/playwright-core/src/protocol/validator.ts b/packages/playwright-core/src/protocol/validator.ts index a89255ed206c1..75d84f765a87c 100644 --- a/packages/playwright-core/src/protocol/validator.ts +++ b/packages/playwright-core/src/protocol/validator.ts @@ -1192,6 +1192,12 @@ scheme.ElectronApplicationConsoleEvent = tObject({ }), timestamp: tFloat, }); +scheme.ElectronApplicationDialogEvent = tObject({ + dialog: tChannel(['ElectronDialog']), +}); +scheme.ElectronApplicationFileChooserEvent = tObject({ + fileChooser: tChannel(['ElectronFileChooser']), +}); scheme.ElectronApplicationBrowserWindowParams = tObject({ page: tChannel(['Page']), }); @@ -1215,10 +1221,30 @@ scheme.ElectronApplicationEvaluateExpressionHandleResult = tObject({ handle: tChannel(['ElementHandle', 'JSHandle']), }); scheme.ElectronApplicationUpdateSubscriptionParams = tObject({ - event: tEnum(['console']), + event: tEnum(['console', 'dialog', 'fileChooser']), enabled: tBoolean, }); scheme.ElectronApplicationUpdateSubscriptionResult = tOptional(tObject({})); +scheme.ElectronDialogInitializer = tObject({ + method: tEnum(['showMessageBox', 'showCertificateTrustDialog']), + options: tAny, +}); +scheme.ElectronDialogAcceptParams = tObject({ + result: tAny, +}); +scheme.ElectronDialogAcceptResult = tOptional(tObject({})); +scheme.ElectronDialogDismissParams = tOptional(tObject({})); +scheme.ElectronDialogDismissResult = tOptional(tObject({})); +scheme.ElectronFileChooserInitializer = tObject({ + method: tEnum(['showOpenDialog', 'showSaveDialog']), + options: tAny, +}); +scheme.ElectronFileChooserSetFilesParams = tObject({ + filePaths: tArray(tString), +}); +scheme.ElectronFileChooserSetFilesResult = tOptional(tObject({})); +scheme.ElectronFileChooserCancelParams = tOptional(tObject({})); +scheme.ElectronFileChooserCancelResult = tOptional(tObject({})); scheme.FrameInitializer = tObject({ url: tString, name: tString, diff --git a/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts b/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts index 4dcf68d0d1db6..7c9bc2f9f0df0 100644 --- a/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts @@ -5,7 +5,7 @@ * 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 + * 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, @@ -18,6 +18,7 @@ import { BrowserContextDispatcher } from './browserContextDispatcher'; import { Dispatcher } from './dispatcher'; import { JSHandleDispatcher, parseArgument, serializeResult } from './jsHandleDispatcher'; import { ElectronApplication } from '../electron/electron'; +import { ElectronDialog, ElectronFileChooser } from '../electron/electronDialogs'; import type { RootDispatcher } from './dispatcher'; import type { PageDispatcher } from './pageDispatcher'; @@ -68,6 +69,20 @@ export class ElectronApplicationDispatcher extends Dispatcher { + if (!this._subscriptions.has('dialog')) { + dialog.dismiss().catch(() => {}); + return; + } + this._dispatchEvent('dialog', { dialog: new ElectronDialogDispatcher(this, dialog) }); + }); + this.addObjectListener(ElectronApplication.Events.FileChooser, (fileChooser: ElectronFileChooser) => { + if (!this._subscriptions.has('fileChooser')) { + fileChooser.cancel().catch(() => {}); + return; + } + this._dispatchEvent('fileChooser', { fileChooser: new ElectronFileChooserDispatcher(this, fileChooser) }); + }); } async browserWindow(params: channels.ElectronApplicationBrowserWindowParams, progress: Progress): Promise { @@ -91,5 +106,45 @@ export class ElectronApplicationDispatcher extends Dispatcher implements channels.ElectronDialogChannel { + _type_ElectronDialog = true; + + constructor(scope: ElectronApplicationDispatcher, dialog: ElectronDialog) { + super(scope, dialog, 'ElectronDialog', { + method: dialog.method(), + options: dialog.options(), + }); + } + + async accept(params: channels.ElectronDialogAcceptParams, progress: Progress): Promise { + await progress.race(this._object.accept(params.result)); + } + + async dismiss(_: channels.ElectronDialogDismissParams, progress: Progress): Promise { + await progress.race(this._object.dismiss()); + } +} + +class ElectronFileChooserDispatcher extends Dispatcher implements channels.ElectronFileChooserChannel { + _type_ElectronFileChooser = true; + + constructor(scope: ElectronApplicationDispatcher, fileChooser: ElectronFileChooser) { + super(scope, fileChooser, 'ElectronFileChooser', { + method: fileChooser.method(), + options: fileChooser.options(), + }); + } + + async setFiles(params: channels.ElectronFileChooserSetFilesParams, progress: Progress): Promise { + await progress.race(this._object.setFiles(params.filePaths)); + } + + async cancel(_: channels.ElectronFileChooserCancelParams, progress: Progress): Promise { + await progress.race(this._object.cancel()); } } diff --git a/packages/playwright-core/src/server/electron/dialogInterception.ts b/packages/playwright-core/src/server/electron/dialogInterception.ts new file mode 100644 index 0000000000000..c83b0c6aa63ad --- /dev/null +++ b/packages/playwright-core/src/server/electron/dialogInterception.ts @@ -0,0 +1,56 @@ +/** + * 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. + */ + +// Serialized via .toString() and evaluated in the Electron main process; must not capture any closure. +export function installDialogInterception() { + const g = globalThis as any; + if (g.__pw_dialog_patched) + return; + g.__pw_dialog_patched = true; + const dialog = (require as any)('electron').dialog; + if (!dialog) + return; + type Method = 'showOpenDialog' | 'showSaveDialog' | 'showMessageBox' | 'showCertificateTrustDialog'; + const pending = new Map void>(); + let nextId = 0; + g.__pw_dialog_interceptors = g.__pw_dialog_interceptors || { dialog: false, fileChooser: false }; + g.__pw_resolve_dialog = (id: number, result: any) => { + const resolver = pending.get(id); + if (!resolver) + return; + pending.delete(id); + resolver(result); + }; + const kindOf = (m: Method): 'fileChooser' | 'dialog' => + (m === 'showOpenDialog' || m === 'showSaveDialog') ? 'fileChooser' : 'dialog'; + const methods: Method[] = ['showOpenDialog', 'showSaveDialog', 'showMessageBox', 'showCertificateTrustDialog']; + for (const method of methods) { + const original = dialog[method]; + if (typeof original !== 'function') + continue; + dialog[method] = function(...args: any[]) { + const callBinding = g.__pw_dialog_call; + if (typeof callBinding !== 'function' || !g.__pw_dialog_interceptors[kindOf(method)]) + return original.apply(dialog, args); + const last = args.length ? args[args.length - 1] : null; + const options = last && typeof last === 'object' ? last : {}; + const id = ++nextId; + const promise = new Promise(resolve => pending.set(id, resolve)); + callBinding(JSON.stringify({ id, method, options })); + return promise; + }; + } +} diff --git a/packages/playwright-core/src/server/electron/electron.ts b/packages/playwright-core/src/server/electron/electron.ts index 4064465579f51..94c3f2503809f 100644 --- a/packages/playwright-core/src/server/electron/electron.ts +++ b/packages/playwright-core/src/server/electron/electron.ts @@ -31,12 +31,15 @@ import { CRConnection } from '../chromium/crConnection'; import { createHandle, CRExecutionContext } from '../chromium/crExecutionContext'; import { stackTraceToLocation } from '../chromium/crProtocolHelper'; import { ConsoleMessage } from '../console'; +import { installDialogInterception } from './dialogInterception'; +import { ElectronDialog, ElectronFileChooser } from './electronDialogs'; import { helper } from '../helper'; import { SdkObject } from '../instrumentation'; import * as js from '../javascript'; import { WebSocketTransport } from '../transport'; import { nullProgress } from '../progress'; +import type { ElectronInterceptedMethod } from './electronDialogs'; import type { BrowserOptions, BrowserProcess } from '../browser'; import type { BrowserContext } from '../browserContext'; import type { CRBrowserContext } from '../chromium/crBrowser'; @@ -52,11 +55,14 @@ import type * as childProcess from 'child_process'; import type { BrowserWindow } from 'electron'; const ARTIFACTS_FOLDER = path.join(os.tmpdir(), 'playwright-artifacts-'); +const kDialogBindingName = '__pw_dialog_call'; export class ElectronApplication extends SdkObject { static Events = { Close: 'close', Console: 'console', + Dialog: 'dialog', + FileChooser: 'fileChooser', }; private _browserContext: CRBrowserContext; @@ -65,6 +71,8 @@ export class ElectronApplication extends SdkObject { private _nodeExecutionContext: js.ExecutionContext | undefined; _nodeElectronHandlePromise: ManualPromise> = new ManualPromise(); private _process: childProcess.ChildProcess; + private _dialogInterceptors = { dialog: false, fileChooser: false }; + private _dialogBindingInstalled = false; constructor(parent: SdkObject, browser: CRBrowser, nodeConnection: CRConnection, process: childProcess.ChildProcess) { super(parent, 'electron-app'); @@ -86,6 +94,7 @@ export class ElectronApplication extends SdkObject { this._nodeElectronHandlePromise.resolve(new js.JSHandle(this._nodeExecutionContext!, 'object', 'ElectronModule', remoteObject.objectId!)); }); this._nodeSession.on('Runtime.consoleAPICalled', event => this._onConsoleAPI(event)); + this._nodeSession.on('Runtime.bindingCalled', event => this._onBindingCalled(event)); const appClosePromise = new Promise(f => this.once(ElectronApplication.Events.Close, f)); this._browserContext.setCustomCloseHandler(async () => { const electronHandle = await this._nodeElectronHandlePromise; @@ -119,8 +128,47 @@ export class ElectronApplication extends SdkObject { this.emit(ElectronApplication.Events.Console, message); } + private _onBindingCalled(event: Protocol.Runtime.bindingCalledPayload) { + if (event.name !== kDialogBindingName) + return; + try { + const { id, method, options } = JSON.parse(event.payload) as { id: number, method: ElectronInterceptedMethod, options: any }; + const onResolve = (result: any) => this._resolveInterceptedDialog(id, result); + if (method === 'showOpenDialog' || method === 'showSaveDialog') + this.emit(ElectronApplication.Events.FileChooser, new ElectronFileChooser(this, method, options, onResolve)); + else + this.emit(ElectronApplication.Events.Dialog, new ElectronDialog(this, method, options, onResolve)); + } catch { + } + } + + async setDialogInterception(kind: 'dialog' | 'fileChooser', enabled: boolean) { + if (this._dialogInterceptors[kind] === enabled) + return; + this._dialogInterceptors[kind] = enabled; + if (!this._dialogBindingInstalled) { + await this._nodeSession.send('Runtime.addBinding', { name: kDialogBindingName }).catch(() => {}); + this._dialogBindingInstalled = true; + } + await this._nodeSession.send('Runtime.evaluate', { + expression: `Object.assign(globalThis.__pw_dialog_interceptors, ${JSON.stringify(this._dialogInterceptors)})`, + }).catch(() => {}); + } + + private async _resolveInterceptedDialog(id: number, result: any) { + const serializedResult = JSON.stringify(result === undefined ? null : result); + await this._nodeSession.send('Runtime.evaluate', { + expression: `globalThis.__pw_resolve_dialog(${id}, ${serializedResult})`, + }); + } + async initialize() { await this._nodeSession.send('Runtime.enable', {}); + await this._nodeSession.send('Runtime.evaluate', { + expression: `(${installDialogInterception.toString()})()`, + // Needed for `require` access on Electron 28+, see https://github.com/microsoft/playwright/issues/28048. + includeCommandLineAPI: true, + }).catch(() => {}); // Delay loading the app until browser is started and the browser targets are configured to auto-attach. await this._nodeSession.send('Runtime.evaluate', { expression: '__playwright_run()' }); } diff --git a/packages/playwright-core/src/server/electron/electronDialogs.ts b/packages/playwright-core/src/server/electron/electronDialogs.ts new file mode 100644 index 0000000000000..0d32d65387cf8 --- /dev/null +++ b/packages/playwright-core/src/server/electron/electronDialogs.ts @@ -0,0 +1,101 @@ +/** + * 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 { assert } from '@isomorphic/assert'; +import { SdkObject } from '../instrumentation'; + +import type { ElectronApplication } from './electron'; + +export type ElectronFileChooserMethod = 'showOpenDialog' | 'showSaveDialog'; +export type ElectronDialogMethod = 'showMessageBox' | 'showCertificateTrustDialog'; +export type ElectronInterceptedMethod = ElectronFileChooserMethod | ElectronDialogMethod; + +type OnResolve = (result: any) => Promise; + +export class ElectronFileChooser extends SdkObject { + private _method: ElectronFileChooserMethod; + private _options: any; + private _onResolve: OnResolve; + private _handled = false; + + constructor(app: ElectronApplication, method: ElectronFileChooserMethod, options: any, onResolve: OnResolve) { + super(app, 'electron-file-chooser'); + this._method = method; + this._options = options; + this._onResolve = onResolve; + } + + method(): ElectronFileChooserMethod { + return this._method; + } + + options(): any { + return this._options; + } + + async setFiles(filePaths: string[]) { + assert(!this._handled, 'File chooser is already handled'); + this._handled = true; + const result = this._method === 'showOpenDialog' + ? { canceled: false, filePaths } + : { canceled: false, filePath: filePaths[0] ?? '' }; + await this._onResolve(result); + } + + async cancel() { + assert(!this._handled, 'File chooser is already handled'); + this._handled = true; + const result = this._method === 'showOpenDialog' + ? { canceled: true, filePaths: [] } + : { canceled: true, filePath: '' }; + await this._onResolve(result); + } +} + +export class ElectronDialog extends SdkObject { + private _method: ElectronDialogMethod; + private _options: any; + private _onResolve: OnResolve; + private _handled = false; + + constructor(app: ElectronApplication, method: ElectronDialogMethod, options: any, onResolve: OnResolve) { + super(app, 'electron-dialog'); + this._method = method; + this._options = options; + this._onResolve = onResolve; + } + + method(): ElectronDialogMethod { + return this._method; + } + + options(): any { + return this._options; + } + + async accept(result: any) { + assert(!this._handled, 'Dialog is already handled'); + this._handled = true; + await this._onResolve(result); + } + + async dismiss() { + assert(!this._handled, 'Dialog is already handled'); + this._handled = true; + const defaultResult = this._method === 'showMessageBox' ? { response: 0, checkboxChecked: false } : undefined; + await this._onResolve(defaultResult); + } +} diff --git a/packages/playwright-core/types/types.d.ts b/packages/playwright-core/types/types.d.ts index 05fe4dc968db1..cb2d5784884bc 100644 --- a/packages/playwright-core/types/types.d.ts +++ b/packages/playwright-core/types/types.d.ts @@ -16998,6 +16998,47 @@ export interface ElectronApplication { */ on(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Emitted when the Electron main process invokes a message dialog method — `dialog.showMessageBox` or + * `dialog.showCertificateTrustDialog`. The handler is expected to either + * [electronDialog.accept(result)](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-accept) with + * the desired result or + * [electronDialog.dismiss()](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-dismiss) the + * dialog. If no handler is attached, the original Electron dialog methods are called as usual. + * + * For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser). + * + * **Usage** + * + * ```js + * electronApp.on('dialog', async dialog => { + * await dialog.accept({ response: 0, checkboxChecked: false }); + * }); + * ``` + * + */ + on(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Emitted when the Electron main process invokes a file dialog method — `dialog.showOpenDialog` or + * `dialog.showSaveDialog`. The handler is expected to either + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with file paths or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel it. If no handler is attached, the original Electron dialog methods are called as usual. + * + * **Usage** + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ + on(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * This event is issued for every window that is created **and loaded** in Electron. It contains a * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. @@ -17014,6 +17055,16 @@ export interface ElectronApplication { */ once(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. */ @@ -17046,6 +17097,47 @@ export interface ElectronApplication { */ addListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Emitted when the Electron main process invokes a message dialog method — `dialog.showMessageBox` or + * `dialog.showCertificateTrustDialog`. The handler is expected to either + * [electronDialog.accept(result)](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-accept) with + * the desired result or + * [electronDialog.dismiss()](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-dismiss) the + * dialog. If no handler is attached, the original Electron dialog methods are called as usual. + * + * For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser). + * + * **Usage** + * + * ```js + * electronApp.on('dialog', async dialog => { + * await dialog.accept({ response: 0, checkboxChecked: false }); + * }); + * ``` + * + */ + addListener(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Emitted when the Electron main process invokes a file dialog method — `dialog.showOpenDialog` or + * `dialog.showSaveDialog`. The handler is expected to either + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with file paths or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel it. If no handler is attached, the original Electron dialog methods are called as usual. + * + * **Usage** + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ + addListener(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * This event is issued for every window that is created **and loaded** in Electron. It contains a * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. @@ -17062,6 +17154,16 @@ export interface ElectronApplication { */ removeListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -17077,6 +17179,16 @@ export interface ElectronApplication { */ off(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -17109,6 +17221,47 @@ export interface ElectronApplication { */ prependListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Emitted when the Electron main process invokes a message dialog method — `dialog.showMessageBox` or + * `dialog.showCertificateTrustDialog`. The handler is expected to either + * [electronDialog.accept(result)](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-accept) with + * the desired result or + * [electronDialog.dismiss()](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-dismiss) the + * dialog. If no handler is attached, the original Electron dialog methods are called as usual. + * + * For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser). + * + * **Usage** + * + * ```js + * electronApp.on('dialog', async dialog => { + * await dialog.accept({ response: 0, checkboxChecked: false }); + * }); + * ``` + * + */ + prependListener(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Emitted when the Electron main process invokes a file dialog method — `dialog.showOpenDialog` or + * `dialog.showSaveDialog`. The handler is expected to either + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with file paths or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel it. If no handler is attached, the original Electron dialog methods are called as usual. + * + * **Usage** + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ + prependListener(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * This event is issued for every window that is created **and loaded** in Electron. It contains a * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. @@ -17187,6 +17340,47 @@ export interface ElectronApplication { */ waitForEvent(event: 'console', optionsOrPredicate?: { predicate?: (consoleMessage: ConsoleMessage) => boolean | Promise, timeout?: number } | ((consoleMessage: ConsoleMessage) => boolean | Promise)): Promise; + /** + * Emitted when the Electron main process invokes a message dialog method — `dialog.showMessageBox` or + * `dialog.showCertificateTrustDialog`. The handler is expected to either + * [electronDialog.accept(result)](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-accept) with + * the desired result or + * [electronDialog.dismiss()](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-dismiss) the + * dialog. If no handler is attached, the original Electron dialog methods are called as usual. + * + * For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser). + * + * **Usage** + * + * ```js + * electronApp.on('dialog', async dialog => { + * await dialog.accept({ response: 0, checkboxChecked: false }); + * }); + * ``` + * + */ + waitForEvent(event: 'dialog', optionsOrPredicate?: { predicate?: (electronDialog: ElectronDialog) => boolean | Promise, timeout?: number } | ((electronDialog: ElectronDialog) => boolean | Promise)): Promise; + + /** + * Emitted when the Electron main process invokes a file dialog method — `dialog.showOpenDialog` or + * `dialog.showSaveDialog`. The handler is expected to either + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with file paths or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel it. If no handler is attached, the original Electron dialog methods are called as usual. + * + * **Usage** + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ + waitForEvent(event: 'filechooser', optionsOrPredicate?: { predicate?: (electronFileChooser: ElectronFileChooser) => boolean | Promise, timeout?: number } | ((electronFileChooser: ElectronFileChooser) => boolean | Promise)): Promise; + /** * This event is issued for every window that is created **and loaded** in Electron. It contains a * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. @@ -17550,6 +17744,47 @@ export interface ElectronApplication { */ on(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Emitted when the Electron main process invokes a message dialog method — `dialog.showMessageBox` or + * `dialog.showCertificateTrustDialog`. The handler is expected to either + * [electronDialog.accept(result)](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-accept) with + * the desired result or + * [electronDialog.dismiss()](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-dismiss) the + * dialog. If no handler is attached, the original Electron dialog methods are called as usual. + * + * For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser). + * + * **Usage** + * + * ```js + * electronApp.on('dialog', async dialog => { + * await dialog.accept({ response: 0, checkboxChecked: false }); + * }); + * ``` + * + */ + on(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Emitted when the Electron main process invokes a file dialog method — `dialog.showOpenDialog` or + * `dialog.showSaveDialog`. The handler is expected to either + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with file paths or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel it. If no handler is attached, the original Electron dialog methods are called as usual. + * + * **Usage** + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ + on(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * This event is issued for every window that is created **and loaded** in Electron. It contains a * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. @@ -17566,6 +17801,16 @@ export interface ElectronApplication { */ once(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. + */ + once(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * Adds an event listener that will be automatically removed after it is triggered once. See `addListener` for more information about this event. */ @@ -17598,6 +17843,47 @@ export interface ElectronApplication { */ addListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Emitted when the Electron main process invokes a message dialog method — `dialog.showMessageBox` or + * `dialog.showCertificateTrustDialog`. The handler is expected to either + * [electronDialog.accept(result)](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-accept) with + * the desired result or + * [electronDialog.dismiss()](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-dismiss) the + * dialog. If no handler is attached, the original Electron dialog methods are called as usual. + * + * For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser). + * + * **Usage** + * + * ```js + * electronApp.on('dialog', async dialog => { + * await dialog.accept({ response: 0, checkboxChecked: false }); + * }); + * ``` + * + */ + addListener(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Emitted when the Electron main process invokes a file dialog method — `dialog.showOpenDialog` or + * `dialog.showSaveDialog`. The handler is expected to either + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with file paths or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel it. If no handler is attached, the original Electron dialog methods are called as usual. + * + * **Usage** + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ + addListener(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * This event is issued for every window that is created **and loaded** in Electron. It contains a * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. @@ -17614,6 +17900,16 @@ export interface ElectronApplication { */ removeListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + removeListener(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -17629,6 +17925,16 @@ export interface ElectronApplication { */ off(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Removes an event listener added by `on` or `addListener`. + */ + off(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * Removes an event listener added by `on` or `addListener`. */ @@ -17661,6 +17967,47 @@ export interface ElectronApplication { */ prependListener(event: 'console', listener: (consoleMessage: ConsoleMessage) => any): this; + /** + * Emitted when the Electron main process invokes a message dialog method — `dialog.showMessageBox` or + * `dialog.showCertificateTrustDialog`. The handler is expected to either + * [electronDialog.accept(result)](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-accept) with + * the desired result or + * [electronDialog.dismiss()](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-dismiss) the + * dialog. If no handler is attached, the original Electron dialog methods are called as usual. + * + * For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser). + * + * **Usage** + * + * ```js + * electronApp.on('dialog', async dialog => { + * await dialog.accept({ response: 0, checkboxChecked: false }); + * }); + * ``` + * + */ + prependListener(event: 'dialog', listener: (electronDialog: ElectronDialog) => any): this; + + /** + * Emitted when the Electron main process invokes a file dialog method — `dialog.showOpenDialog` or + * `dialog.showSaveDialog`. The handler is expected to either + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with file paths or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel it. If no handler is attached, the original Electron dialog methods are called as usual. + * + * **Usage** + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ + prependListener(event: 'filechooser', listener: (electronFileChooser: ElectronFileChooser) => any): this; + /** * This event is issued for every window that is created **and loaded** in Electron. It contains a * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. @@ -17739,6 +18086,47 @@ export interface ElectronApplication { */ waitForEvent(event: 'console', optionsOrPredicate?: { predicate?: (consoleMessage: ConsoleMessage) => boolean | Promise, timeout?: number } | ((consoleMessage: ConsoleMessage) => boolean | Promise)): Promise; + /** + * Emitted when the Electron main process invokes a message dialog method — `dialog.showMessageBox` or + * `dialog.showCertificateTrustDialog`. The handler is expected to either + * [electronDialog.accept(result)](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-accept) with + * the desired result or + * [electronDialog.dismiss()](https://playwright.dev/docs/api/class-electrondialog#electron-dialog-dismiss) the + * dialog. If no handler is attached, the original Electron dialog methods are called as usual. + * + * For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser). + * + * **Usage** + * + * ```js + * electronApp.on('dialog', async dialog => { + * await dialog.accept({ response: 0, checkboxChecked: false }); + * }); + * ``` + * + */ + waitForEvent(event: 'dialog', optionsOrPredicate?: { predicate?: (electronDialog: ElectronDialog) => boolean | Promise, timeout?: number } | ((electronDialog: ElectronDialog) => boolean | Promise)): Promise; + + /** + * Emitted when the Electron main process invokes a file dialog method — `dialog.showOpenDialog` or + * `dialog.showSaveDialog`. The handler is expected to either + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with file paths or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel it. If no handler is attached, the original Electron dialog methods are called as usual. + * + * **Usage** + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ + waitForEvent(event: 'filechooser', optionsOrPredicate?: { predicate?: (electronFileChooser: ElectronFileChooser) => boolean | Promise, timeout?: number } | ((electronFileChooser: ElectronFileChooser) => boolean | Promise)): Promise; + /** * This event is issued for every window that is created **and loaded** in Electron. It contains a * [Page](https://playwright.dev/docs/api/class-page) that can be used for Playwright automation. @@ -22431,6 +22819,100 @@ export interface Electron { }): Promise; } +/** + * Represents a message dialog initiated by the Electron main process via + * [`dialog.showMessageBox`](https://www.electronjs.org/docs/latest/api/dialog#dialogshowmessageboxbrowserwindow-options) + * or + * [`dialog.showCertificateTrustDialog`](https://www.electronjs.org/docs/latest/api/dialog#dialogshowcertificatetrustdialogbrowserwindow-options-macos-windows). + * + * Instances of this class are received via the + * [electronApplication.on('dialog')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-dialog) + * event. For file selection dialogs (`showOpenDialog`/`showSaveDialog`) see + * [ElectronFileChooser](https://playwright.dev/docs/api/class-electronfilechooser). + * + * ```js + * electronApp.on('dialog', async dialog => { + * // dialog.method() === 'showMessageBox' | 'showCertificateTrustDialog' + * await dialog.accept({ response: 1, checkboxChecked: false }); + * }); + * ``` + * + */ +export interface ElectronDialog { + /** + * Resolves the underlying Electron dialog with the provided result. For `showMessageBox` the expected shape is `{ + * response: number, checkboxChecked?: boolean }`. + * @param result The value to resolve the dialog with. + */ + accept(result: Serializable): Promise; + + /** + * Dismisses the dialog. The Electron dialog method resolves with a default value — for `showMessageBox` that is `{ + * response: 0, checkboxChecked: false }`. + */ + dismiss(): Promise; + + /** + * The name of the [Electron dialog method](https://www.electronjs.org/docs/latest/api/dialog) that triggered the + * event. + */ + method(): "showMessageBox"|"showCertificateTrustDialog"; + + /** + * The options object that was passed to the underlying Electron dialog method. + */ + options(): Serializable; +} + +/** + * Represents a file selection dialog initiated by the Electron main process via + * [`dialog.showOpenDialog`](https://www.electronjs.org/docs/latest/api/dialog#dialogshowopendialogbrowserwindow-options) + * or + * [`dialog.showSaveDialog`](https://www.electronjs.org/docs/latest/api/dialog#dialogshowsavedialogbrowserwindow-options). + * + * Instances of this class are received via the + * [electronApplication.on('filechooser')](https://playwright.dev/docs/api/class-electronapplication#electron-application-event-file-chooser) + * event. Tests can call + * [electronFileChooser.setFiles(filePaths)](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-set-files) + * to fulfill the dialog with the given file paths, or + * [electronFileChooser.cancel()](https://playwright.dev/docs/api/class-electronfilechooser#electron-file-chooser-cancel) + * to cancel the dialog. + * + * ```js + * electronApp.on('fileChooser', async chooser => { + * await chooser.setFiles(['/path/to/file.txt']); + * }); + * ``` + * + */ +export interface ElectronFileChooser { + /** + * Cancels the file dialog. The Electron dialog method resolves with `{ canceled: true, filePaths: [] }` for + * `showOpenDialog`, or `{ canceled: true, filePath: '' }` for `showSaveDialog`. + */ + cancel(): Promise; + + /** + * The name of the [Electron dialog method](https://www.electronjs.org/docs/latest/api/dialog) that triggered the + * event. + */ + method(): "showOpenDialog"|"showSaveDialog"; + + /** + * The options object that was passed to the underlying Electron dialog method. + */ + options(): Serializable; + + /** + * Resolves the underlying Electron file dialog with the provided paths. + * + * For [`dialog.showOpenDialog`] the dialog resolves with `{ canceled: false, filePaths: [...] }`. For + * [`dialog.showSaveDialog`] the dialog resolves with `{ canceled: false, filePath: filePaths[0] }`. + * @param filePaths The file path(s) to provide to the Electron dialog. + */ + setFiles(filePaths: string|ReadonlyArray): Promise; +} + /** * Playwright has **experimental** support for Android automation. This includes Chrome for Android and Android * WebView. diff --git a/packages/protocol/spec/electron.yml b/packages/protocol/spec/electron.yml index 72f3a818c5c13..2ab370db6ca64 100644 --- a/packages/protocol/spec/electron.yml +++ b/packages/protocol/spec/electron.yml @@ -129,6 +129,8 @@ ElectronApplication: type: enum literals: - console + - dialog + - fileChooser enabled: boolean events: @@ -136,5 +138,57 @@ ElectronApplication: console: parameters: $mixin: ConsoleMessage + dialog: + parameters: + dialog: ElectronDialog + fileChooser: + parameters: + fileChooser: ElectronFileChooser + + +ElectronDialog: + type: interface + + initializer: + method: + type: enum + literals: + - showMessageBox + - showCertificateTrustDialog + options: json + + commands: + + accept: + title: Accept dialog + parameters: + result: json + + dismiss: + title: Dismiss dialog + + +ElectronFileChooser: + type: interface + + initializer: + method: + type: enum + literals: + - showOpenDialog + - showSaveDialog + options: json + + commands: + + setFiles: + title: Set files for the dialog + parameters: + filePaths: + type: array + items: string + + cancel: + title: Cancel the file chooser diff --git a/packages/protocol/src/channels.d.ts b/packages/protocol/src/channels.d.ts index 4990815165f80..ebaad04e19697 100644 --- a/packages/protocol/src/channels.d.ts +++ b/packages/protocol/src/channels.d.ts @@ -40,6 +40,8 @@ export type InitializerTraits = T extends DisposableChannel ? DisposableInitializer : T extends ElectronChannel ? ElectronInitializer : T extends ElectronApplicationChannel ? ElectronApplicationInitializer : + T extends ElectronDialogChannel ? ElectronDialogInitializer : + T extends ElectronFileChooserChannel ? ElectronFileChooserInitializer : T extends FrameChannel ? FrameInitializer : T extends JSHandleChannel ? JSHandleInitializer : T extends LocalUtilsChannel ? LocalUtilsInitializer : @@ -78,6 +80,8 @@ export type EventsTraits = T extends DisposableChannel ? DisposableEvents : T extends ElectronChannel ? ElectronEvents : T extends ElectronApplicationChannel ? ElectronApplicationEvents : + T extends ElectronDialogChannel ? ElectronDialogEvents : + T extends ElectronFileChooserChannel ? ElectronFileChooserEvents : T extends FrameChannel ? FrameEvents : T extends JSHandleChannel ? JSHandleEvents : T extends LocalUtilsChannel ? LocalUtilsEvents : @@ -116,6 +120,8 @@ export type EventTargetTraits = T extends DisposableChannel ? DisposableEventTarget : T extends ElectronChannel ? ElectronEventTarget : T extends ElectronApplicationChannel ? ElectronApplicationEventTarget : + T extends ElectronDialogChannel ? ElectronDialogEventTarget : + T extends ElectronFileChooserChannel ? ElectronFileChooserEventTarget : T extends FrameChannel ? FrameEventTarget : T extends JSHandleChannel ? JSHandleEventTarget : T extends LocalUtilsChannel ? LocalUtilsEventTarget : @@ -2239,6 +2245,8 @@ export type ElectronApplicationInitializer = { export interface ElectronApplicationEventTarget { on(event: 'close', callback: (params: ElectronApplicationCloseEvent) => void): this; on(event: 'console', callback: (params: ElectronApplicationConsoleEvent) => void): this; + on(event: 'dialog', callback: (params: ElectronApplicationDialogEvent) => void): this; + on(event: 'fileChooser', callback: (params: ElectronApplicationFileChooserEvent) => void): this; } export interface ElectronApplicationChannel extends ElectronApplicationEventTarget, EventTargetChannel { _type_ElectronApplication: boolean; @@ -2259,6 +2267,12 @@ export type ElectronApplicationConsoleEvent = { }, timestamp: number, }; +export type ElectronApplicationDialogEvent = { + dialog: ElectronDialogChannel, +}; +export type ElectronApplicationFileChooserEvent = { + fileChooser: ElectronFileChooserChannel, +}; export type ElectronApplicationBrowserWindowParams = { page: PageChannel, }; @@ -2291,7 +2305,7 @@ export type ElectronApplicationEvaluateExpressionHandleResult = { handle: JSHandleChannel, }; export type ElectronApplicationUpdateSubscriptionParams = { - event: 'console', + event: 'console' | 'dialog' | 'fileChooser', enabled: boolean, }; export type ElectronApplicationUpdateSubscriptionOptions = { @@ -2302,6 +2316,60 @@ export type ElectronApplicationUpdateSubscriptionResult = void; export interface ElectronApplicationEvents { 'close': ElectronApplicationCloseEvent; 'console': ElectronApplicationConsoleEvent; + 'dialog': ElectronApplicationDialogEvent; + 'fileChooser': ElectronApplicationFileChooserEvent; +} + +// ----------- ElectronDialog ----------- +export type ElectronDialogInitializer = { + method: 'showMessageBox' | 'showCertificateTrustDialog', + options: any, +}; +export interface ElectronDialogEventTarget { +} +export interface ElectronDialogChannel extends ElectronDialogEventTarget, Channel { + _type_ElectronDialog: boolean; + accept(params: ElectronDialogAcceptParams, progress?: Progress): Promise; + dismiss(params?: ElectronDialogDismissParams, progress?: Progress): Promise; +} +export type ElectronDialogAcceptParams = { + result: any, +}; +export type ElectronDialogAcceptOptions = { + +}; +export type ElectronDialogAcceptResult = void; +export type ElectronDialogDismissParams = {}; +export type ElectronDialogDismissOptions = {}; +export type ElectronDialogDismissResult = void; + +export interface ElectronDialogEvents { +} + +// ----------- ElectronFileChooser ----------- +export type ElectronFileChooserInitializer = { + method: 'showOpenDialog' | 'showSaveDialog', + options: any, +}; +export interface ElectronFileChooserEventTarget { +} +export interface ElectronFileChooserChannel extends ElectronFileChooserEventTarget, Channel { + _type_ElectronFileChooser: boolean; + setFiles(params: ElectronFileChooserSetFilesParams, progress?: Progress): Promise; + cancel(params?: ElectronFileChooserCancelParams, progress?: Progress): Promise; +} +export type ElectronFileChooserSetFilesParams = { + filePaths: string[], +}; +export type ElectronFileChooserSetFilesOptions = { + +}; +export type ElectronFileChooserSetFilesResult = void; +export type ElectronFileChooserCancelParams = {}; +export type ElectronFileChooserCancelOptions = {}; +export type ElectronFileChooserCancelResult = void; + +export interface ElectronFileChooserEvents { } // ----------- Frame ----------- diff --git a/tests/electron/electron-dialog.spec.ts b/tests/electron/electron-dialog.spec.ts new file mode 100644 index 0000000000000..5c9a14a23003e --- /dev/null +++ b/tests/electron/electron-dialog.spec.ts @@ -0,0 +1,49 @@ +/** + * 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 { electronTest as test, expect } from './electronTest'; + +test('should intercept dialog.showMessageBox', async ({ launchElectronApp }) => { + const app = await launchElectronApp('electron-app.js'); + const dialogPromise = app.waitForEvent('dialog'); + const resultPromise = app.evaluate(({ dialog, BrowserWindow }) => { + const win = new BrowserWindow({ width: 400, height: 300, show: false }); + return dialog.showMessageBox(win, { buttons: ['OK', 'Cancel'], message: 'Hi' }); + }); + const dialog = await dialogPromise; + expect(dialog.method()).toBe('showMessageBox'); + expect(dialog.options()).toEqual(expect.objectContaining({ buttons: ['OK', 'Cancel'] })); + await dialog.accept({ response: 1, checkboxChecked: true }); + expect(await resultPromise).toEqual({ response: 1, checkboxChecked: true }); +}); + +test('should dismiss dialog.showMessageBox with default result', async ({ launchElectronApp }) => { + const app = await launchElectronApp('electron-app.js'); + const dialogPromise = app.waitForEvent('dialog'); + const resultPromise = app.evaluate(({ dialog, BrowserWindow }) => { + const win = new BrowserWindow({ width: 400, height: 300, show: false }); + return dialog.showMessageBox(win, { buttons: ['OK', 'Cancel'], message: 'Hi' }); + }); + const dialog = await dialogPromise; + await dialog.dismiss(); + expect(await resultPromise).toEqual({ response: 0, checkboxChecked: false }); +}); + +test('should still expose showMessageBox when no handler attached', async ({ launchElectronApp }) => { + const app = await launchElectronApp('electron-app.js'); + const present = await app.evaluate(({ dialog }) => typeof dialog.showMessageBox === 'function'); + expect(present).toBe(true); +}); diff --git a/tests/electron/electron-filechooser.spec.ts b/tests/electron/electron-filechooser.spec.ts new file mode 100644 index 0000000000000..8766c0a9f87bf --- /dev/null +++ b/tests/electron/electron-filechooser.spec.ts @@ -0,0 +1,90 @@ +/** + * 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 { electronTest as test, expect } from './electronTest'; + +test('should intercept dialog.showOpenDialog', async ({ launchElectronApp }) => { + const app = await launchElectronApp('electron-app.js'); + const chooserPromise = app.waitForEvent('filechooser'); + const resultPromise = app.evaluate(({ dialog, BrowserWindow }) => { + const win = new BrowserWindow({ width: 400, height: 300, show: false }); + return dialog.showOpenDialog(win, { properties: ['openFile'] }); + }); + const chooser = await chooserPromise; + expect(chooser.method()).toBe('showOpenDialog'); + expect(chooser.options()).toEqual(expect.objectContaining({ properties: ['openFile'] })); + await chooser.setFiles(['/tmp/foo.txt']); + expect(await resultPromise).toEqual({ canceled: false, filePaths: ['/tmp/foo.txt'] }); +}); + +test('should accept a single file path as a string for setFiles', async ({ launchElectronApp }) => { + const app = await launchElectronApp('electron-app.js'); + const chooserPromise = app.waitForEvent('filechooser'); + const resultPromise = app.evaluate(({ dialog, BrowserWindow }) => { + const win = new BrowserWindow({ width: 400, height: 300, show: false }); + return dialog.showOpenDialog(win, { properties: ['openFile'] }); + }); + const chooser = await chooserPromise; + await chooser.setFiles('/tmp/single.txt'); + expect(await resultPromise).toEqual({ canceled: false, filePaths: ['/tmp/single.txt'] }); +}); + +test('should cancel dialog.showOpenDialog', async ({ launchElectronApp }) => { + const app = await launchElectronApp('electron-app.js'); + const chooserPromise = app.waitForEvent('filechooser'); + const resultPromise = app.evaluate(({ dialog, BrowserWindow }) => { + const win = new BrowserWindow({ width: 400, height: 300, show: false }); + return dialog.showOpenDialog(win, { properties: ['openFile'] }); + }); + const chooser = await chooserPromise; + await chooser.cancel(); + expect(await resultPromise).toEqual({ canceled: true, filePaths: [] }); +}); + +test('should intercept dialog.showSaveDialog', async ({ launchElectronApp }) => { + const app = await launchElectronApp('electron-app.js'); + const chooserPromise = app.waitForEvent('filechooser'); + const resultPromise = app.evaluate(({ dialog, BrowserWindow }) => { + const win = new BrowserWindow({ width: 400, height: 300, show: false }); + return dialog.showSaveDialog(win, { defaultPath: 'foo.txt' }); + }); + const chooser = await chooserPromise; + expect(chooser.method()).toBe('showSaveDialog'); + expect(chooser.options()).toEqual(expect.objectContaining({ defaultPath: 'foo.txt' })); + await chooser.setFiles(['/tmp/bar.txt']); + expect(await resultPromise).toEqual({ canceled: false, filePath: '/tmp/bar.txt' }); +}); + +test('should cancel dialog.showSaveDialog', async ({ launchElectronApp }) => { + const app = await launchElectronApp('electron-app.js'); + const chooserPromise = app.waitForEvent('filechooser'); + const resultPromise = app.evaluate(({ dialog, BrowserWindow }) => { + const win = new BrowserWindow({ width: 400, height: 300, show: false }); + return dialog.showSaveDialog(win, {}); + }); + const chooser = await chooserPromise; + await chooser.cancel(); + expect(await resultPromise).toEqual({ canceled: true, filePath: '' }); +}); + +test('should still expose dialog functions when no handler attached', async ({ launchElectronApp }) => { + const app = await launchElectronApp('electron-app.js'); + const present = await app.evaluate(({ dialog }) => ({ + open: typeof dialog.showOpenDialog === 'function', + save: typeof dialog.showSaveDialog === 'function', + })); + expect(present).toEqual({ open: true, save: true }); +}); From 3ca32ef8f8686280539d5681fc23c715de941580 Mon Sep 17 00:00:00 2001 From: Yury Semikhatsky Date: Thu, 21 May 2026 10:47:39 -0700 Subject: [PATCH 2/2] chore(electron): revert unrelated license-header reindent in electronDispatcher.ts --- .../src/server/dispatchers/electronDispatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts b/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts index 7c9bc2f9f0df0..77f4ea2c8a406 100644 --- a/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts +++ b/packages/playwright-core/src/server/dispatchers/electronDispatcher.ts @@ -5,7 +5,7 @@ * 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 + * 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,