diff --git a/docs/api/puppeteer.frame.md b/docs/api/puppeteer.frame.md index ad1806f2..ea076618 100644 --- a/docs/api/puppeteer.frame.md +++ b/docs/api/puppeteer.frame.md @@ -90,6 +90,7 @@ console.log(text); | [title()](./puppeteer.frame.title.md) | | | | [type(selector, text, options)](./puppeteer.frame.type.md) | | Sends a keydown, keypress/input, and keyup event for each character in the text. | | [url()](./puppeteer.frame.url.md) | | | +| [waitForDevicePrompt(options)](./puppeteer.frame.waitfordeviceprompt.md) | |

This method is typically coupled with an action that triggers a device request from an api such as WebBluetooth.

:::caution

This must be called before the device request is made. It will not return a currently active device prompt.

:::

| | [waitForFunction(pageFunction, options, args)](./puppeteer.frame.waitforfunction.md) | | | | [waitForNavigation(options)](./puppeteer.frame.waitfornavigation.md) | |

Waits for the frame to navigate. It is useful for when you run code which will indirectly cause the frame to navigate.

Usage of the [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) to change the URL is considered a navigation.

| | [waitForSelector(selector, options)](./puppeteer.frame.waitforselector.md) | |

Waits for an element matching the given selector to appear in the frame.

This method works across navigations.

| diff --git a/docs/api/puppeteer.frame.waitfordeviceprompt.md b/docs/api/puppeteer.frame.waitfordeviceprompt.md new file mode 100644 index 00000000..e37a0eca --- /dev/null +++ b/docs/api/puppeteer.frame.waitfordeviceprompt.md @@ -0,0 +1,45 @@ +--- +sidebar_label: Frame.waitForDevicePrompt +--- + +# Frame.waitForDevicePrompt() method + +This method is typically coupled with an action that triggers a device request from an api such as WebBluetooth. + +:::caution + +This must be called before the device request is made. It will not return a currently active device prompt. + +::: + +#### Signature: + +```typescript +class Frame { + waitForDevicePrompt( + options?: WaitTimeoutOptions + ): Promise; +} +``` + +## Parameters + +| Parameter | Type | Description | +| --------- | ------------------------------------------------------- | ------------ | +| options | [WaitTimeoutOptions](./puppeteer.waittimeoutoptions.md) | _(Optional)_ | + +**Returns:** + +Promise<DeviceRequestPrompt> + +## Example + +```ts +const [devicePrompt] = Promise.all([ + frame.waitForDevicePrompt(), + frame.click('#connect-bluetooth'), +]); +await devicePrompt.select( + await devicePrompt.waitForDevice(({name}) => name.includes('My Device')) +); +``` diff --git a/docs/api/puppeteer.page.md b/docs/api/puppeteer.page.md index 20fb453b..a8bcd627 100644 --- a/docs/api/puppeteer.page.md +++ b/docs/api/puppeteer.page.md @@ -149,6 +149,7 @@ page.off('request', logRequest); | [type(selector, text, options)](./puppeteer.page.type.md) | |

Sends a keydown, keypress/input, and keyup event for each character in the text.

To press a special key, like Control or ArrowDown, use [Keyboard.press()](./puppeteer.keyboard.press.md).

| | [url()](./puppeteer.page.url.md) | | | | [viewport()](./puppeteer.page.viewport.md) | | | +| [waitForDevicePrompt(options)](./puppeteer.page.waitfordeviceprompt.md) | |

This method is typically coupled with an action that triggers a device request from an api such as WebBluetooth.

:::caution

This must be called before the device request is made. It will not return a currently active device prompt.

:::

| | [waitForFileChooser(options)](./puppeteer.page.waitforfilechooser.md) | |

This method is typically coupled with an action that triggers file choosing.

:::caution

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

:::

| | [waitForFrame(urlOrPredicate, options)](./puppeteer.page.waitforframe.md) | | | | [waitForFunction(pageFunction, options, args)](./puppeteer.page.waitforfunction.md) | | Waits for a function to finish evaluating in the page's context. | diff --git a/docs/api/puppeteer.page.waitfordeviceprompt.md b/docs/api/puppeteer.page.waitfordeviceprompt.md new file mode 100644 index 00000000..9b41e3de --- /dev/null +++ b/docs/api/puppeteer.page.waitfordeviceprompt.md @@ -0,0 +1,45 @@ +--- +sidebar_label: Page.waitForDevicePrompt +--- + +# Page.waitForDevicePrompt() method + +This method is typically coupled with an action that triggers a device request from an api such as WebBluetooth. + +:::caution + +This must be called before the device request is made. It will not return a currently active device prompt. + +::: + +#### Signature: + +```typescript +class Page { + waitForDevicePrompt( + options?: WaitTimeoutOptions + ): Promise; +} +``` + +## Parameters + +| Parameter | Type | Description | +| --------- | ------------------------------------------------------- | ------------ | +| options | [WaitTimeoutOptions](./puppeteer.waittimeoutoptions.md) | _(Optional)_ | + +**Returns:** + +Promise<DeviceRequestPrompt> + +## Example + +```ts +const [devicePrompt] = Promise.all([ + page.waitForDevicePrompt(), + page.click('#connect-bluetooth'), +]); +await devicePrompt.select( + await devicePrompt.waitForDevice(({name}) => name.includes('My Device')) +); +``` diff --git a/packages/puppeteer-core/src/api/Page.ts b/packages/puppeteer-core/src/api/Page.ts index e826c066..d092a6c1 100644 --- a/packages/puppeteer-core/src/api/Page.ts +++ b/packages/puppeteer-core/src/api/Page.ts @@ -24,6 +24,7 @@ import type {Accessibility} from '../common/Accessibility.js'; import type {ConsoleMessage} from '../common/ConsoleMessage.js'; import type {Coverage} from '../common/Coverage.js'; import {Device} from '../common/Device.js'; +import {DeviceRequestPrompt} from '../common/DeviceRequestPrompt.js'; import type {Dialog} from '../common/Dialog.js'; import {EventEmitter, Handler} from '../common/EventEmitter.js'; import type {FileChooser} from '../common/FileChooser.js'; @@ -2558,6 +2559,36 @@ export class Page extends EventEmitter { >(): Promise>>> { throw new Error('Not implemented'); } + + /** + * This method is typically coupled with an action that triggers a device + * request from an api such as WebBluetooth. + * + * :::caution + * + * This must be called before the device request is made. It will not return a + * currently active device prompt. + * + * ::: + * + * @example + * + * ```ts + * const [devicePrompt] = Promise.all([ + * page.waitForDevicePrompt(), + * page.click('#connect-bluetooth'), + * ]); + * await devicePrompt.select( + * await devicePrompt.waitForDevice(({name}) => name.includes('My Device')) + * ); + * ``` + */ + waitForDevicePrompt( + options?: WaitTimeoutOptions + ): Promise; + waitForDevicePrompt(): Promise { + throw new Error('Not implemented'); + } } /** diff --git a/packages/puppeteer-core/src/common/DeviceRequestPrompt.ts b/packages/puppeteer-core/src/common/DeviceRequestPrompt.ts new file mode 100644 index 00000000..aa3b1264 --- /dev/null +++ b/packages/puppeteer-core/src/common/DeviceRequestPrompt.ts @@ -0,0 +1,293 @@ +/** + * Copyright 2022 Google Inc. All rights reserved. + * + * 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 Protocol from 'devtools-protocol'; + +import {WaitTimeoutOptions} from '../api/Page.js'; +import {assert} from '../util/assert.js'; +import { + createDeferredPromise, + DeferredPromise, +} from '../util/DeferredPromise.js'; + +import {CDPSession} from './Connection.js'; +import {TimeoutSettings} from './TimeoutSettings.js'; + +/** + * Device in a request prompt. + * + * @public + */ +export class DeviceRequestPromptDevice { + /** + * Device id during a prompt. + */ + id: string; + + /** + * Device name as it appears in a prompt. + */ + name: string; + + /** + * @internal + */ + constructor(id: string, name: string) { + this.id = id; + this.name = name; + } +} + +/** + * Device request prompts let you respond to the page requesting for a device + * through an API like WebBluetooth. + * + * @remarks + * `DeviceRequestPrompt` instances are returned via the + * {@link Page.waitForDevicePrompt} method. + * + * @example + * + * ```ts + * const [deviceRequest] = Promise.all([ + * page.waitForDevicePrompt(), + * page.click('#connect-bluetooth'), + * ]); + * await devicePrompt.select( + * await devicePrompt.waitForDevice(({name}) => name.includes('My Device')) + * ); + * ``` + * + * @public + */ +export class DeviceRequestPrompt { + #client: CDPSession | null; + #timeoutSettings: TimeoutSettings; + #id: string; + #handled = false; + #updateDevicesHandle = this.#updateDevices.bind(this); + #waitForDevicePromises = new Set<{ + filter: (device: DeviceRequestPromptDevice) => boolean; + promise: DeferredPromise; + }>(); + + /** + * Current list of selectable devices. + */ + devices: DeviceRequestPromptDevice[] = []; + + /** + * @internal + */ + constructor( + client: CDPSession, + timeoutSettings: TimeoutSettings, + firstEvent: Protocol.DeviceAccess.DeviceRequestPromptedEvent + ) { + this.#client = client; + this.#timeoutSettings = timeoutSettings; + this.#id = firstEvent.id; + + this.#client.on( + 'DeviceAccess.deviceRequestPrompted', + this.#updateDevicesHandle + ); + this.#client.on('Target.detachedFromTarget', () => { + this.#client = null; + }); + + this.#updateDevices(firstEvent); + } + + #updateDevices(event: Protocol.DeviceAccess.DeviceRequestPromptedEvent) { + if (event.id !== this.#id) { + return; + } + + for (const rawDevice of event.devices) { + if ( + this.devices.some(device => { + return device.id === rawDevice.id; + }) + ) { + continue; + } + + const newDevice = new DeviceRequestPromptDevice( + rawDevice.id, + rawDevice.name + ); + this.devices.push(newDevice); + + for (const waitForDevicePromise of this.#waitForDevicePromises) { + if (waitForDevicePromise.filter(newDevice)) { + waitForDevicePromise.promise.resolve(newDevice); + } + } + } + } + + /** + * Resolve to the first device in the prompt matching a filter. + */ + async waitForDevice( + filter: (device: DeviceRequestPromptDevice) => boolean, + options: WaitTimeoutOptions = {} + ): Promise { + for (const device of this.devices) { + if (filter(device)) { + return device; + } + } + + const {timeout = this.#timeoutSettings.timeout()} = options; + const promise = createDeferredPromise({ + message: `Waiting for \`DeviceRequestPromptDevice\` failed: ${timeout}ms exceeded`, + timeout, + }); + const handle = {filter, promise}; + this.#waitForDevicePromises.add(handle); + try { + return await promise; + } finally { + this.#waitForDevicePromises.delete(handle); + } + } + + /** + * Select a device in the prompt's list. + */ + async select(device: DeviceRequestPromptDevice): Promise { + assert( + this.#client !== null, + 'Cannot select device through detached session!' + ); + assert(this.devices.includes(device), 'Cannot select unknown device!'); + assert( + !this.#handled, + 'Cannot select DeviceRequestPrompt which is already handled!' + ); + this.#client.off( + 'DeviceAccess.deviceRequestPrompted', + this.#updateDevicesHandle + ); + this.#handled = true; + return this.#client.send('DeviceAccess.selectPrompt', { + id: this.#id, + deviceId: device.id, + }); + } + + /** + * Cancel the prompt. + */ + async cancel(): Promise { + assert( + this.#client !== null, + 'Cannot cancel prompt through detached session!' + ); + assert( + !this.#handled, + 'Cannot cancel DeviceRequestPrompt which is already handled!' + ); + this.#client.off( + 'DeviceAccess.deviceRequestPrompted', + this.#updateDevicesHandle + ); + this.#handled = true; + return this.#client.send('DeviceAccess.cancelPrompt', {id: this.#id}); + } +} + +/** + * @internal + */ +export class DeviceRequestPromptManager { + #client: CDPSession | null; + #timeoutSettings: TimeoutSettings; + #deviceRequestPromptPromises = new Set< + DeferredPromise + >(); + + /** + * @internal + */ + constructor(client: CDPSession, timeoutSettings: TimeoutSettings) { + this.#client = client; + this.#timeoutSettings = timeoutSettings; + + this.#client.on('DeviceAccess.deviceRequestPrompted', event => { + this.#onDeviceRequestPrompted(event); + }); + this.#client.on('Target.detachedFromTarget', () => { + this.#client = null; + }); + } + + /** + * Wait for device prompt created by an action like calling WebBluetooth's + * requestDevice. + */ + async waitForDevicePrompt( + options: WaitTimeoutOptions = {} + ): Promise { + assert( + this.#client !== null, + 'Cannot wait for device prompt through detached session!' + ); + const needsEnable = this.#deviceRequestPromptPromises.size === 0; + let enablePromise: Promise | undefined; + if (needsEnable) { + enablePromise = this.#client.send('DeviceAccess.enable'); + } + + const {timeout = this.#timeoutSettings.timeout()} = options; + const promise = createDeferredPromise({ + message: `Waiting for \`DeviceRequestPrompt\` failed: ${timeout}ms exceeded`, + timeout, + }); + this.#deviceRequestPromptPromises.add(promise); + + try { + const [result] = await Promise.all([promise, enablePromise]); + return result; + } finally { + this.#deviceRequestPromptPromises.delete(promise); + } + } + + /** + * @internal + */ + #onDeviceRequestPrompted( + event: Protocol.DeviceAccess.DeviceRequestPromptedEvent + ) { + if (!this.#deviceRequestPromptPromises.size) { + return; + } + + assert(this.#client !== null); + const devicePrompt = new DeviceRequestPrompt( + this.#client, + this.#timeoutSettings, + event + ); + for (const promise of this.#deviceRequestPromptPromises) { + promise.resolve(devicePrompt); + } + this.#deviceRequestPromptPromises.clear(); + } +} diff --git a/packages/puppeteer-core/src/common/Frame.ts b/packages/puppeteer-core/src/common/Frame.ts index 517c70bc..07a86c6b 100644 --- a/packages/puppeteer-core/src/common/Frame.ts +++ b/packages/puppeteer-core/src/common/Frame.ts @@ -18,10 +18,15 @@ import {Protocol} from 'devtools-protocol'; import {ElementHandle} from '../api/ElementHandle.js'; import {HTTPResponse} from '../api/HTTPResponse.js'; -import {Page} from '../api/Page.js'; +import {Page, WaitTimeoutOptions} from '../api/Page.js'; +import {assert} from '../util/assert.js'; import {isErrorLike} from '../util/ErrorLike.js'; import {CDPSession} from './Connection.js'; +import { + DeviceRequestPrompt, + DeviceRequestPromptManager, +} from './DeviceRequestPrompt.js'; import {ExecutionContext} from './ExecutionContext.js'; import {FrameManager} from './FrameManager.js'; import {getQueryHandlerAndSelector} from './GetQueryHandler.js'; @@ -1083,6 +1088,47 @@ export class Frame { return this.worlds[PUPPETEER_WORLD].title(); } + /** + * @internal + */ + _deviceRequestPromptManager(): DeviceRequestPromptManager { + if (this.isOOPFrame()) { + return this._frameManager._deviceRequestPromptManager(this.#client); + } + const parentFrame = this.parentFrame(); + assert(parentFrame !== null); + return parentFrame._deviceRequestPromptManager(); + } + + /** + * This method is typically coupled with an action that triggers a device + * request from an api such as WebBluetooth. + * + * :::caution + * + * This must be called before the device request is made. It will not return a + * currently active device prompt. + * + * ::: + * + * @example + * + * ```ts + * const [devicePrompt] = Promise.all([ + * frame.waitForDevicePrompt(), + * frame.click('#connect-bluetooth'), + * ]); + * await devicePrompt.select( + * await devicePrompt.waitForDevice(({name}) => name.includes('My Device')) + * ); + * ``` + */ + waitForDevicePrompt( + options: WaitTimeoutOptions = {} + ): Promise { + return this._deviceRequestPromptManager().waitForDevicePrompt(options); + } + /** * @internal */ diff --git a/packages/puppeteer-core/src/common/FrameManager.ts b/packages/puppeteer-core/src/common/FrameManager.ts index 3f07d018..248e2b01 100644 --- a/packages/puppeteer-core/src/common/FrameManager.ts +++ b/packages/puppeteer-core/src/common/FrameManager.ts @@ -21,6 +21,7 @@ import {assert} from '../util/assert.js'; import {isErrorLike} from '../util/ErrorLike.js'; import {CDPSession, isTargetClosedError} from './Connection.js'; +import {DeviceRequestPromptManager} from './DeviceRequestPrompt.js'; import {EventEmitter} from './EventEmitter.js'; import {EVALUATION_SCRIPT_URL, ExecutionContext} from './ExecutionContext.js'; import {Frame} from './Frame.js'; @@ -77,6 +78,11 @@ export class FrameManager extends EventEmitter { */ #frameNavigatedReceived = new Set(); + #deviceRequestPromptManagerMap = new WeakMap< + CDPSession, + DeviceRequestPromptManager + >(); + get timeoutSettings(): TimeoutSettings { return this.#timeoutSettings; } @@ -219,6 +225,18 @@ export class FrameManager extends EventEmitter { this.initialize(target._session()); } + /** + * @internal + */ + _deviceRequestPromptManager(client: CDPSession): DeviceRequestPromptManager { + let manager = this.#deviceRequestPromptManagerMap.get(client); + if (manager === undefined) { + manager = new DeviceRequestPromptManager(client, this.#timeoutSettings); + this.#deviceRequestPromptManagerMap.set(client, manager); + } + return manager; + } + #onLifecycleEvent(event: Protocol.Page.LifecycleEventEvent): void { const frame = this.frame(event.frameId); if (!frame) { diff --git a/packages/puppeteer-core/src/common/Page.ts b/packages/puppeteer-core/src/common/Page.ts index 60814d8b..fb5c0081 100644 --- a/packages/puppeteer-core/src/common/Page.ts +++ b/packages/puppeteer-core/src/common/Page.ts @@ -50,6 +50,7 @@ import { } from './Connection.js'; import {ConsoleMessage, ConsoleMessageType} from './ConsoleMessage.js'; import {Coverage} from './Coverage.js'; +import {DeviceRequestPrompt} from './DeviceRequestPrompt.js'; import {Dialog} from './Dialog.js'; import {EmulationManager} from './EmulationManager.js'; import {FileChooser} from './FileChooser.js'; @@ -1647,6 +1648,35 @@ export class CDPPage extends Page { ): Promise>>> { return this.mainFrame().waitForFunction(pageFunction, options, ...args); } + + /** + * This method is typically coupled with an action that triggers a device + * request from an api such as WebBluetooth. + * + * :::caution + * + * This must be called before the device request is made. It will not return a + * currently active device prompt. + * + * ::: + * + * @example + * + * ```ts + * const [devicePrompt] = Promise.all([ + * page.waitForDevicePrompt(), + * page.click('#connect-bluetooth'), + * ]); + * await devicePrompt.select( + * await devicePrompt.waitForDevice(({name}) => name.includes('My Device')) + * ); + * ``` + */ + override waitForDevicePrompt( + options: WaitTimeoutOptions = {} + ): Promise { + return this.mainFrame().waitForDevicePrompt(options); + } } const supportedMetrics = new Set([ diff --git a/test/src/DeviceRequestPrompt.spec.ts b/test/src/DeviceRequestPrompt.spec.ts new file mode 100644 index 00000000..b898b209 --- /dev/null +++ b/test/src/DeviceRequestPrompt.spec.ts @@ -0,0 +1,457 @@ +import expect from 'expect'; +import {TimeoutError} from 'puppeteer'; +import { + DeviceRequestPrompt, + DeviceRequestPromptDevice, + DeviceRequestPromptManager, +} from 'puppeteer-core/internal/common/DeviceRequestPrompt.js'; +import {EventEmitter} from 'puppeteer-core/internal/common/EventEmitter.js'; +import {TimeoutSettings} from 'puppeteer-core/internal/common/TimeoutSettings.js'; + +class MockCDPSession extends EventEmitter { + async send(): Promise {} + connection() { + return undefined; + } + async detach() {} + id() { + return '1'; + } +} + +describe('DeviceRequestPrompt', function () { + describe('waitForDevicePrompt', function () { + it('should return prompt', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + const [prompt] = await Promise.all([ + manager.waitForDevicePrompt(), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [], + }); + })(), + ]); + expect(prompt).toBeTruthy(); + }); + + it('should respect timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + await expect( + manager.waitForDevicePrompt({timeout: 1}) + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it('should respect default timeout when there is no custom timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + timeoutSettings.setDefaultTimeout(1); + await expect(manager.waitForDevicePrompt()).rejects.toBeInstanceOf( + TimeoutError + ); + }); + + it('should prioritize exact timeout over default timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + timeoutSettings.setDefaultTimeout(0); + await expect( + manager.waitForDevicePrompt({timeout: 1}) + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it('should work with no timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + const [prompt] = await Promise.all([ + manager.waitForDevicePrompt({timeout: 0}), + (async () => { + await new Promise(resolve => { + setTimeout(resolve, 50); + }); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [], + }); + })(), + ]); + expect(prompt).toBeTruthy(); + }); + + it('should return the same prompt when there are many watchdogs simultaneously', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + const [prompt1, prompt2] = await Promise.all([ + manager.waitForDevicePrompt(), + manager.waitForDevicePrompt(), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [], + }); + })(), + ]); + expect(prompt1 === prompt2).toBeTruthy(); + }); + + it('should listen and shortcut when there are no watchdogs', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const manager = new DeviceRequestPromptManager(client, timeoutSettings); + + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [], + }); + + expect(manager).toBeTruthy(); + }); + }); + + describe('DeviceRequestPrompt.devices', function () { + it('lists devices as they arrive', function () { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + expect(prompt.devices).toHaveLength(0); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [{id: '00000000', name: 'Device 0'}], + }); + expect(prompt.devices).toHaveLength(1); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + expect(prompt.devices).toHaveLength(2); + expect(prompt.devices[0]).toBeInstanceOf(DeviceRequestPromptDevice); + expect(prompt.devices[1]).toBeInstanceOf(DeviceRequestPromptDevice); + }); + + it('does not list devices from events of another prompt', function () { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + expect(prompt.devices).toHaveLength(0); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '88888888888888888888888888888888', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + expect(prompt.devices).toHaveLength(0); + }); + }); + + describe('DeviceRequestPrompt.waitForDevice', function () { + it('should return first matching device', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device] = await Promise.all([ + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [{id: '00000000', name: 'Device 0'}], + }); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + expect(device).toBeInstanceOf(DeviceRequestPromptDevice); + }); + + it('should return first matching device from already known devices', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + + const device = await prompt.waitForDevice(({name}) => { + return name.includes('1'); + }); + expect(device).toBeInstanceOf(DeviceRequestPromptDevice); + }); + + it('should return device in the devices list', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device] = await Promise.all([ + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + expect(prompt.devices).toContain(device); + }); + + it('should respect timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + await expect( + prompt.waitForDevice( + ({name}) => { + return name.includes('Device'); + }, + {timeout: 1} + ) + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it('should respect default timeout when there is no custom timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + timeoutSettings.setDefaultTimeout(1); + await expect( + prompt.waitForDevice( + ({name}) => { + return name.includes('Device'); + }, + {timeout: 1} + ) + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it('should prioritize exact timeout over default timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + timeoutSettings.setDefaultTimeout(0); + await expect( + prompt.waitForDevice( + ({name}) => { + return name.includes('Device'); + }, + {timeout: 1} + ) + ).rejects.toBeInstanceOf(TimeoutError); + }); + + it('should work with no timeout', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device] = await Promise.all([ + prompt.waitForDevice( + ({name}) => { + return name.includes('1'); + }, + {timeout: 0} + ), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [{id: '00000000', name: 'Device 0'}], + }); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + expect(device).toBeInstanceOf(DeviceRequestPromptDevice); + }); + + it('should return same device from multiple watchdogs', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device1, device2] = await Promise.all([ + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [{id: '00000000', name: 'Device 0'}], + }); + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + expect(device1 === device2).toBeTruthy(); + }); + }); + + describe('DeviceRequestPrompt.select', function () { + it('should succeed with listed device', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device] = await Promise.all([ + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + await prompt.select(device); + }); + + it('should error for device not listed in devices', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + await expect( + prompt.select(new DeviceRequestPromptDevice('11111111', 'Device 1')) + ).rejects.toThrowError('Cannot select unknown device!'); + }); + + it('should fail when selecting prompt twice', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + + const [device] = await Promise.all([ + prompt.waitForDevice(({name}) => { + return name.includes('1'); + }), + (() => { + client.emit('DeviceAccess.deviceRequestPrompted', { + id: '00000000000000000000000000000000', + devices: [ + {id: '00000000', name: 'Device 0'}, + {id: '11111111', name: 'Device 1'}, + ], + }); + })(), + ]); + await prompt.select(device); + await expect(prompt.select(device)).rejects.toThrowError( + 'Cannot select DeviceRequestPrompt which is already handled!' + ); + }); + }); + + describe('DeviceRequestPrompt.cancel', function () { + it('should succeed on first call', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + await prompt.cancel(); + }); + + it('should fail when canceling prompt twice', async () => { + const client = new MockCDPSession(); + const timeoutSettings = new TimeoutSettings(); + const prompt = new DeviceRequestPrompt(client, timeoutSettings, { + id: '00000000000000000000000000000000', + devices: [], + }); + await prompt.cancel(); + await expect(prompt.cancel()).rejects.toThrowError( + 'Cannot cancel DeviceRequestPrompt which is already handled!' + ); + }); + }); +});