From b7f5d06fd47f5924f4f46fb4811c440e55581236 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Thu, 21 Jul 2022 13:09:42 +0200 Subject: [PATCH] WebDriver BiDi Implementation (#8505) * initial bidi implementation * add unit tests * remove only * fix vitest path in windows --- .gitpod/dev.dockerfile | 2 +- __mocks__/{cbt_tunnels.js => cbt_tunnels.ts} | 0 __mocks__/got.ts | 2 +- __mocks__/ws.ts | 6 +- e2e/standalone/vitest.config.ts | 2 +- e2e/vitest.config.ts | 2 +- e2e/wdio/wdio.conf.ts | 2 +- .../protocols/webdriverBidi.json | 33 +++++++++ packages/wdio-protocols/src/index.ts | 13 +++- packages/wdio-protocols/src/types.ts | 44 ++++++++++++ packages/wdio-utils/src/monad.ts | 13 +++- packages/webdriver/README.md | 57 +++++++++++---- packages/webdriver/package.json | 10 +-- packages/webdriver/src/bidi.ts | 72 +++++++++++++++++++ packages/webdriver/src/command.ts | 21 +++++- packages/webdriver/src/index.ts | 11 ++- packages/webdriver/src/utils.ts | 6 +- .../tests/__snapshots__/bidi.test.ts.snap | 13 ++++ packages/webdriver/tests/bidi.test.ts | 69 ++++++++++++++++++ packages/webdriver/tests/command.test.ts | 24 +++++++ packages/webdriver/tests/index.test.ts | 27 +++++++ scripts/constants.js | 6 +- scripts/type-generation/constants.js | 3 +- 23 files changed, 403 insertions(+), 35 deletions(-) rename __mocks__/{cbt_tunnels.js => cbt_tunnels.ts} (100%) create mode 100644 packages/wdio-protocols/protocols/webdriverBidi.json create mode 100644 packages/webdriver/src/bidi.ts create mode 100644 packages/webdriver/tests/__snapshots__/bidi.test.ts.snap create mode 100644 packages/webdriver/tests/bidi.test.ts diff --git a/.gitpod/dev.dockerfile b/.gitpod/dev.dockerfile index 3cfa2d66d..314f113a4 100644 --- a/.gitpod/dev.dockerfile +++ b/.gitpod/dev.dockerfile @@ -12,4 +12,4 @@ RUN \ microsoft-edge-dev RUN \ - npm i -g http-server diff-so-fancy chromedriver@103 + npm i -g http-server diff-so-fancy chromedriver@103 geckodriver diff --git a/__mocks__/cbt_tunnels.js b/__mocks__/cbt_tunnels.ts similarity index 100% rename from __mocks__/cbt_tunnels.js rename to __mocks__/cbt_tunnels.ts diff --git a/__mocks__/got.ts b/__mocks__/got.ts index 371ca3205..d1aa63b5c 100644 --- a/__mocks__/got.ts +++ b/__mocks__/got.ts @@ -172,7 +172,7 @@ const requestMock: any = vi.fn().mockImplementation((uri, params) => { const script = Function(params.json.script) const args = params.json.args.map((arg: any) => arg.ELEMENT || arg[ELEMENT_KEY] || arg) - let result = null + let result: any = null if (params.json.script.includes('resq')) { if (params.json.script.includes('react$$')) { result = [ diff --git a/__mocks__/ws.ts b/__mocks__/ws.ts index b9f9fdbb9..e5387b4de 100644 --- a/__mocks__/ws.ts +++ b/__mocks__/ws.ts @@ -1,5 +1,9 @@ import { vi } from 'vitest' export default class WebSocketMock { - on = vi.fn() + on = vi.fn().mockReturnValue({ off: vi.fn() }) + off = vi.fn() + send = vi.fn() + + constructor (public wsUrl: string) {} } diff --git a/e2e/standalone/vitest.config.ts b/e2e/standalone/vitest.config.ts index 5a70d4d3e..d54e090d9 100644 --- a/e2e/standalone/vitest.config.ts +++ b/e2e/standalone/vitest.config.ts @@ -5,7 +5,7 @@ import path from 'node:path' export default defineConfig({ test: { testTimeout: 1000 * 60, - include: [path.resolve(__dirname, '*.e2e.ts')], + include: ['./**/*.e2e.ts'], hookTimeout: 60 * 1000, threads: false } diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts index 80daaee47..8af51abee 100644 --- a/e2e/vitest.config.ts +++ b/e2e/vitest.config.ts @@ -5,7 +5,7 @@ import path from 'node:path' export default defineConfig({ test: { testTimeout: 1000 * 60, - include: [path.resolve(__dirname, '**', '*.test.ts')], + include: ['./**/*.test.ts'], hookTimeout: 60 * 1000, threads: false } diff --git a/e2e/wdio/wdio.conf.ts b/e2e/wdio/wdio.conf.ts index bef9f3e84..a36b17bb6 100644 --- a/e2e/wdio/wdio.conf.ts +++ b/e2e/wdio/wdio.conf.ts @@ -9,7 +9,7 @@ export const config = { /** * specify test files */ - specs: [path.resolve(__dirname, 'headless', '*.e2e.ts')], + specs: ['./headless/*.e2e.ts'], /** * capabilities diff --git a/packages/wdio-protocols/protocols/webdriverBidi.json b/packages/wdio-protocols/protocols/webdriverBidi.json new file mode 100644 index 000000000..2313b6c21 --- /dev/null +++ b/packages/wdio-protocols/protocols/webdriverBidi.json @@ -0,0 +1,33 @@ +{ + "sendCommand": { + "socket": { + "command": "send", + "description": "Send socket commands via WebDriver Bidi", + "ref": "https://github.com/w3c/webdriver-bidi", + "parameters": [{ + "name": "params", + "type": "object", + "description": "socket payload", + "required": true + }], + "returns": { + "type": "Object", + "name": "response", + "description": "WebDriver Bidi response" + } + } + }, + "sendAsyncCommand": { + "socket": { + "command": "sendAsync", + "description": "Send asynchronous socket commands via WebDriver Bidi", + "ref": "https://github.com/w3c/webdriver-bidi", + "parameters": [{ + "name": "params", + "type": "object", + "description": "socket payload", + "required": true + }] + } + } +} diff --git a/packages/wdio-protocols/src/index.ts b/packages/wdio-protocols/src/index.ts index 58fe03540..487d4cff1 100644 --- a/packages/wdio-protocols/src/index.ts +++ b/packages/wdio-protocols/src/index.ts @@ -9,9 +9,11 @@ import type MJSONWPCommands from './commands/mjsonwp' import type SauceLabsCommands from './commands/saucelabs' import type SeleniumCommands from './commands/selenium' import type WebDriverCommands from './commands/webdriver' +import type WebDriverBidiCommands from './commands/webdriverBidi' const require = createRequire(import.meta.url) const WebDriverProtocol: Protocol = require('../protocols/webdriver.json') +const WebDriverBidiProtocol: Protocol = require('../protocols/webdriverBidi.json') const MJsonWProtocol: Protocol = require('../protocols/mjsonwp.json') const JsonWProtocol: Protocol = require('../protocols/jsonwp.json') const AppiumProtocol: Protocol = require('../protocols/appium.json') @@ -24,6 +26,10 @@ type WebDriverCommandsAsync = { [K in keyof WebDriverCommands]: (...args: Parameters) => Promise> } +type WebDriverBidiCommandsAsync = { + [K in keyof WebDriverBidiCommands]: + (...args: Parameters) => Promise> +} type AppiumCommandsAsync = { [K in keyof AppiumCommands]: (...args: Parameters) => Promise> @@ -61,17 +67,20 @@ export { // protocols WebDriverProtocol, MJsonWProtocol, JsonWProtocol, AppiumProtocol, ChromiumProtocol, SauceLabsProtocol, SeleniumProtocol, GeckoProtocol, + WebDriverBidiProtocol, // sync commands AppiumCommands, ChromiumCommands, JSONWPCommands, MJSONWPCommands, SauceLabsCommands, SeleniumCommands, WebDriverCommands, GeckoCommands, + WebDriverBidiCommands, // async commands WebDriverCommandsAsync, AppiumCommandsAsync, ChromiumCommandsAsync, JSONWPCommandsAsync, MJSONWPCommandsAsync, SauceLabsCommandsAsync, - SeleniumCommandsAsync, GeckoCommandsAsync + SeleniumCommandsAsync, GeckoCommandsAsync, + WebDriverBidiCommandsAsync } export const CAPABILITY_KEYS = [ 'browserName', 'browserVersion', 'platformName', 'acceptInsecureCerts', 'pageLoadStrategy', 'proxy', 'setWindowRect', 'timeouts', 'strictFileInteractability', - 'unhandledPromptBehavior' + 'unhandledPromptBehavior', 'webSocketUrl' ] diff --git a/packages/wdio-protocols/src/types.ts b/packages/wdio-protocols/src/types.ts index 9fe151682..02a4f8bbb 100644 --- a/packages/wdio-protocols/src/types.ts +++ b/packages/wdio-protocols/src/types.ts @@ -176,3 +176,47 @@ export type Environments = 'XCUITest' | 'UIAutomation' | 'UiAutomator' * ``` */ export type SupportedEnvironments = Record> + +export type SupportedMethods = ( + 'session.status' | + 'session.new' | + 'session.subscribe' | + 'session.unsubscribe' | + 'browsingContext.captureScreenshot' | + 'browsingContext.close' | + 'browsingContext.create' | + 'browsingContext.getTree' | + 'browsingContext.handleUserPrompt' | + 'browsingContext.navigate' | + 'browsingContext.reload' | + 'browsingContext.contextCreated' | + 'browsingContext.contextDestroyed' | + 'browsingContext.navigationStarted' | + 'browsingContext.fragmentNavigated' | + 'browsingContext.domContentLoaded' | + 'browsingContext.load' | + 'browsingContext.downloadWillBegin' | + 'browsingContext.navigationAborted' | + 'browsingContext.navigationFailed' | + 'browsingContext.userPromptClosed' | + 'browsingContext.userPromptOpened' | + 'script.disown' | + 'script.callFunction' | + 'script.evaluate' | + 'script.getRealms' | + 'script.realmCreated' | + 'script.realmDestoyed' | + 'log.entryAdded' +) + +export interface BidiRequest { + method: SupportedMethods + /** + * types will be more defined later + */ + params: Record +} + +export interface BidiResponse { + id: number +} diff --git a/packages/wdio-utils/src/monad.ts b/packages/wdio-utils/src/monad.ts index 0b7d8461e..0a32a291c 100644 --- a/packages/wdio-utils/src/monad.ts +++ b/packages/wdio-utils/src/monad.ts @@ -32,7 +32,11 @@ export default function WebDriver (options: Record, modifier?: Func /** * WebDriver monad */ - function unit (this: void, sessionId: string, commandWrapper?: Function) { + function unit (this: void, sessionId: string, commandWrapper?: Function, eventMiddleware?: { socket: Partial }) { + if (eventMiddleware) { + prototype.eventMiddleware = eventMiddleware + } + /** * capabilities attached to the instance prototype not being shown if * logging the instance @@ -182,6 +186,13 @@ export default function WebDriver (options: Record, modifier?: Func for (let eventCommand in EVENTHANDLER_FUNCTIONS) { prototype[eventCommand] = function (...args: [any, any]) { eventHandler[eventCommand as keyof EventEmitter](...args as [never, any]) + + if (prototype.eventMiddleware) { + if (typeof prototype.eventMiddleware[eventCommand as keyof EventEmitter] === 'function') { + prototype.eventMiddleware.socket[eventCommand as keyof EventEmitter]!(...args as [never, any]) + } + } + return this } } diff --git a/packages/webdriver/README.md b/packages/webdriver/README.md index d68f0e574..123a8f603 100644 --- a/packages/webdriver/README.md +++ b/packages/webdriver/README.md @@ -1,13 +1,14 @@ WebDriver ========= -> A lightweight, non-opinionated implementation of the [WebDriver specification](https://w3c.github.io/webdriver/webdriver-spec.html) including mobile commands supported by [Appium](http://appium.io/) +> A lightweight, non-opinionated implementation of the [WebDriver](https://w3c.github.io/webdriver/webdriver-spec.html) and [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/) specification including mobile commands supported by [Appium](http://appium.io/) There are [tons](https://github.com/christian-bromann/awesome-selenium#javascript) of Selenium and WebDriver binding implementations in the Node.js world. Every one of them have an opinionated API and recommended way to use. This binding is the most non-opinionated you will find as it just represents the [WebDriver specification](https://w3c.github.io/webdriver/webdriver-spec.html) and doesn't come with any extra or higher level abstraction. It is lightweight and comes with support for the [WebDriver specification](https://w3c.github.io/webdriver/webdriver-spec.html) and Appium's [Mobile JSONWire Protocol](https://github.com/appium/appium-base-driver/blob/master/docs/mjsonwp/protocol-methods.md). The package supports the following protocols: - [WebDriver](https://w3c.github.io/webdriver/) +- [WebDriver Bidi](https://w3c.github.io/webdriver-bidi/) - [Appium](http://appium.io/) - [Chromium](http://chromedriver.chromium.org/) (additional Chromedriver specific commands) - [Selenium](https://www.selenium.dev/) (additional Selenium WebDriver specific commands) @@ -25,31 +26,57 @@ To install this package from NPM run: npm i webdriver ``` -## Example +## WebDriver Example The following example demonstrates a simple Google Search scenario: ```js import WebDriver from 'webdriver'; -(async () => { - const client = await WebDriver.newSession({ - path: '/', - capabilities: { browserName: 'firefox' } - }) +const client = await WebDriver.newSession({ + path: '/', + capabilities: { browserName: 'firefox' } +}) - await client.navigateTo('https://www.google.com/ncr') +await client.navigateTo('https://www.google.com/ncr') - const searchInput = await client.findElement('css selector', '#lst-ib') - await client.elementSendKeys(searchInput['element-6066-11e4-a52e-4f735466cecf'], 'WebDriver') +const searchInput = await client.findElement('css selector', '#lst-ib') +await client.elementSendKeys(searchInput['element-6066-11e4-a52e-4f735466cecf'], 'WebDriver') - const searchBtn = await client.findElement('css selector', 'input[value="Google Search"]') - await client.elementClick(searchBtn['element-6066-11e4-a52e-4f735466cecf']) +const searchBtn = await client.findElement('css selector', 'input[value="Google Search"]') +await client.elementClick(searchBtn['element-6066-11e4-a52e-4f735466cecf']) - console.log(await client.getTitle()) // outputs "WebDriver - Google Search" +console.log(await client.getTitle()) // outputs "WebDriver - Google Search" - await client.deleteSession() -})() +await client.deleteSession() +``` + +## WebDriver Bidi Example + +To connect to the WebDriver Bidi protocol you have to send along a `webSocketUrl` flag to tell the browser driver to opt-in to the protocol: + +```js +import WebDriver from './packages/webdriver/build/index.js' + +const browser = await WebDriver.newSession({ + capabilities: { + webSocketUrl: true, + browserName: 'firefox' + } +}) + +await browser.send({ + method: 'session.subscribe', + params: { events: ['log.entryAdded'] } +}) + +/** + * returns: {"method":"log.entryAdded","params":{"type":"console","method":"log","realm":null,"args":[{"type":"string","value":"Hello Bidi"}],"level":"info","text":"Hello Bidi","timestamp":1657282076037}} + */ +browser.on('message', (data) => console.log('received %s', data)) + +await browser.executeScript('console.log("Hello Bidi")', []) +await browser.deleteSession() ``` # Configuration diff --git a/packages/webdriver/package.json b/packages/webdriver/package.json index ff4a4ae9e..01152efc9 100644 --- a/packages/webdriver/package.json +++ b/packages/webdriver/package.json @@ -20,7 +20,7 @@ "types": "./build/index.d.ts", "typeScriptVersion": "3.8.3", "engines": { - "node": "^16.13 || >=18" + "node": "^16.13 || >=18" }, "repository": { "type": "git", @@ -33,7 +33,8 @@ "url": "https://github.com/webdriverio/webdriverio/issues" }, "devDependencies": { - "@types/lodash.merge": "^4.6.7" + "@types/lodash.merge": "^4.6.7", + "@types/ws": "^8.5.3" }, "dependencies": { "@types/node": "^18.0.0", @@ -43,7 +44,8 @@ "@wdio/types": "7.20.7", "@wdio/utils": "7.20.7", "got": "^12.1.0", - "ky": "0.31.1", - "lodash.merge": "^4.6.2" + "ky": "^0.31.1", + "lodash.merge": "^4.6.2", + "ws": "^8.8.0" } } diff --git a/packages/webdriver/src/bidi.ts b/packages/webdriver/src/bidi.ts new file mode 100644 index 000000000..63598dbf4 --- /dev/null +++ b/packages/webdriver/src/bidi.ts @@ -0,0 +1,72 @@ +import { EventEmitter } from 'node:events' +import WebSocket from 'ws' +import logger from '@wdio/logger' +import { BidiRequest, BidiResponse } from '@wdio/protocols' + +const log = logger('webdriver:BidiHandler') +const RESPONSE_TIMEOUT = 1000 * 60 + +export class BidiHandler extends EventEmitter { + #id = 0 + #ws: WebSocket + #isConnected = false + + constructor (private _webSocketUrl: string) { + super() + log.info(`Connect to webSocketUrl ${this._webSocketUrl}`) + this.#ws = new WebSocket(this._webSocketUrl) + } + + public connect () { + return new Promise((resolve) => this.#ws.on('open', () => { + this.#isConnected = true + resolve() + })) + } + + get socket () { + return this.#ws + } + + get isConnected () { + return this.#isConnected + } + + public send (params: BidiRequest) { + if (!this.#isConnected) { + throw new Error('No connection to WebDriver Bidi was established') + } + + const id = ++this.#id + this.#ws.send(JSON.stringify({ id, ...params })) + return new Promise((resolve, reject) => { + const t = setTimeout(() => { + reject(new Error(`Request with id ${id} timed out`)) + h.off('message', listener) + }, RESPONSE_TIMEOUT) + + const listener = (data: WebSocket.RawData) => { + try { + const payload = JSON.parse(data.toString()) as BidiResponse + if (payload.id === id) { + clearTimeout(t) + h.off('message', listener) + resolve(payload) + } + } catch (err: any) { + log.error(`Failed parse message: ${err.message}`) + } + } + const h = this.#ws.on('message', listener) + }) + } + + public sendAsync (params: BidiRequest) { + if (!this.#isConnected) { + throw new Error('No connection to WebDriver Bidi was established') + } + + const id = ++this.#id + this.#ws.send(JSON.stringify({ id, ...params })) + } +} diff --git a/packages/webdriver/src/command.ts b/packages/webdriver/src/command.ts index f78267ff2..0a3a7353c 100644 --- a/packages/webdriver/src/command.ts +++ b/packages/webdriver/src/command.ts @@ -1,12 +1,18 @@ import logger from '@wdio/logger' import { commandCallStructure, isValidParameter, getArgumentType } from '@wdio/utils' -import type { CommandEndpoint } from '@wdio/protocols' +import type { CommandEndpoint, BidiResponse } from '@wdio/protocols' import RequestFactory from './request/factory.js' +import { BidiHandler } from './bidi.js' import type { WebDriverResponse } from './request' import type { BaseClient } from './types' const log = logger('webdriver') +const BIDI_COMMANDS = ['send', 'sendAsync'] as const + +interface BaseClientWithEventHandler extends BaseClient { + eventMiddleware: BidiHandler +} export default function ( method: string, @@ -16,7 +22,7 @@ export default function ( ) { const { command, ref, parameters, variables = [], isHubCommand = false } = commandInfo - return async function protocolCommand (this: BaseClient, ...args: any[]): Promise { + return async function protocolCommand (this: BaseClientWithEventHandler, ...args: any[]): Promise { let endpoint = endpointUri // clone endpointUri in case we change it const commandParams = [...variables.map((v) => Object.assign(v, { /** @@ -88,6 +94,17 @@ export default function ( body[commandParams[i].name] = arg } + /** + * Handle Bidi calls + */ + if (this.sessionId && BIDI_COMMANDS.includes(command as typeof BIDI_COMMANDS[number])) { + if (!this.eventMiddleware) { + throw new Error('Your WebDriver session doesn\'t support WebDriver Bidi') + } + + return this.eventMiddleware[command as typeof BIDI_COMMANDS[number]](body.params) + } + const request = await RequestFactory.getInstance(method, endpoint, body, isHubCommand) request.on('performance', (...args) => this.emit('request.performance', ...args)) this.emit('command', { method, endpoint, body }) diff --git a/packages/webdriver/src/index.ts b/packages/webdriver/src/index.ts index b02b7cf56..d866c43de 100644 --- a/packages/webdriver/src/index.ts +++ b/packages/webdriver/src/index.ts @@ -6,6 +6,7 @@ import { validateConfig } from '@wdio/config' import type { Options, Capabilities } from '@wdio/types' import command from './command.js' +import { BidiHandler } from './bidi.js' import { DEFAULTS } from './constants.js' import { startWebDriverSession, getPrototype, getEnvironmentVars, setupDirectConnect @@ -48,7 +49,15 @@ export default class WebDriver { modifier, prototype ) - const client = monad(sessionId, customCommandWrapper) + + let handler: BidiHandler | undefined + if (capabilities.webSocketUrl) { + log.info(`Register BiDi handler for session with id ${sessionId}`) + const socketUrl = (capabilities.webSocketUrl as any as string).replace('localhost', '127.0.0.1') + handler = new BidiHandler(socketUrl) + await handler.connect() + } + const client = monad(sessionId, customCommandWrapper, handler) /** * if the server responded with direct connect information, update the diff --git a/packages/webdriver/src/utils.ts b/packages/webdriver/src/utils.ts index e700978c4..ce795a5f7 100644 --- a/packages/webdriver/src/utils.ts +++ b/packages/webdriver/src/utils.ts @@ -3,7 +3,7 @@ import logger from '@wdio/logger' import Protocols from '@wdio/protocols' import { WebDriverProtocol, MJsonWProtocol, JsonWProtocol, AppiumProtocol, ChromiumProtocol, - SauceLabsProtocol, SeleniumProtocol, GeckoProtocol + SauceLabsProtocol, SeleniumProtocol, GeckoProtocol, WebDriverBidiProtocol } from '@wdio/protocols' import { transformCommandLogResult } from '@wdio/utils' import { CAPABILITY_KEYS } from '@wdio/protocols' @@ -178,6 +178,10 @@ export function getPrototype ({ isW3C, isChrome, isFirefox, isMobile, isSauce, i isMobile ? merge({}, JsonWProtocol, WebDriverProtocol) : isW3C ? WebDriverProtocol : JsonWProtocol, + /** + * enable Bidi protocol for W3C sessions + */ + isW3C ? WebDriverBidiProtocol : {}, /** * only apply mobile protocol if session is actually for mobile */ diff --git a/packages/webdriver/tests/__snapshots__/bidi.test.ts.snap b/packages/webdriver/tests/__snapshots__/bidi.test.ts.snap new file mode 100644 index 000000000..c22e6ab4c --- /dev/null +++ b/packages/webdriver/tests/__snapshots__/bidi.test.ts.snap @@ -0,0 +1,13 @@ +// Vitest Snapshot v1 + +exports[`BidiHandler > send > fails if sending a message while not connected 1`] = `[Error: No connection to WebDriver Bidi was established]`; + +exports[`BidiHandler > sendAsync > can send without getting an result 1`] = ` +[ + [ + "{\\"id\\":1,\\"method\\":\\"session.new\\",\\"params\\":{}}", + ], +] +`; + +exports[`BidiHandler > sendAsync > fails if sending a message while not connected 1`] = `[Error: No connection to WebDriver Bidi was established]`; diff --git a/packages/webdriver/tests/bidi.test.ts b/packages/webdriver/tests/bidi.test.ts new file mode 100644 index 000000000..18ba7ce93 --- /dev/null +++ b/packages/webdriver/tests/bidi.test.ts @@ -0,0 +1,69 @@ +import path from 'node:path' +import { describe, it, vi, expect } from 'vitest' +import { BidiHandler } from '../src/bidi' + +vi.mock('ws') +vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))) + +describe('BidiHandler', () => { + it('iniates with a WebSocket', () => { + const handler = new BidiHandler('ws://foo/bar') + // @ts-expect-error "wsUrl" is a mock property + expect(handler.socket.wsUrl).toBe('ws://foo/bar') + expect(handler.isConnected).toBe(false) + }) + + it('can connect', async () => { + const handler = new BidiHandler('ws://foo/bar') + handler.connect() + expect(handler.socket.on).toBeCalledWith('open', expect.any(Function)) + + const [, cb] = vi.mocked(handler.socket.on).mock.calls[0] + cb.call(this as any) + expect(handler.isConnected).toBe(true) + }) + + describe('send', () => { + it('fails if sending a message while not connected', async () => { + const handler = new BidiHandler('ws://foo/bar') + await expect(async () => handler.send({ method: 'session.new', params: {} })) + .rejects.toMatchSnapshot() + }) + + it('sends and waits for result', async () => { + const handler = new BidiHandler('ws://foo/bar') + handler.connect() + const [, cb] = vi.mocked(handler.socket.on).mock.calls[0] + cb.call(this as any) + + vi.mocked(handler.socket.on).mockClear() + const promise = handler.send({ method: 'session.new', params: {} }) + expect(handler.socket.on).toBeCalledWith('message', expect.any(Function)) + const [, messageCallback] = vi.mocked(handler.socket.on).mock.calls[0] + + messageCallback.call(this as any, Buffer.from('{somewrongmessage')) + messageCallback.call(this as any, Buffer.from(JSON.stringify({ id: 1, result: 'foobar' }))) + const result = await promise + expect(result).toEqual({ id: 1, result: 'foobar' }) + }) + }) + + describe('sendAsync', () => { + it('fails if sending a message while not connected', async () => { + const handler = new BidiHandler('ws://foo/bar') + await expect(async () => handler.sendAsync({ method: 'session.new', params: {} })) + .rejects.toMatchSnapshot() + }) + + it('can send without getting an result', async () => { + const handler = new BidiHandler('ws://foo/bar') + handler.connect() + const [, cb] = vi.mocked(handler.socket.on).mock.calls[0] + cb.call(this as any) + + expect(handler.sendAsync({ method: 'session.new', params: {} })) + .toEqual(undefined) + expect(vi.mocked(handler.socket.send).mock.calls).toMatchSnapshot() + }) + }) +}) diff --git a/packages/webdriver/tests/command.test.ts b/packages/webdriver/tests/command.test.ts index 86e215af3..9ac4ad5b1 100644 --- a/packages/webdriver/tests/command.test.ts +++ b/packages/webdriver/tests/command.test.ts @@ -176,6 +176,30 @@ describe('command wrapper', () => { }) }) +describe('Bidi support', () => { + it('fails if bidi is not supported', async () => { + scope.sessionId = 'foo123' + const commandFn = commandWrapper('POST', 'sendCommand', { + command: 'send', + parameters: [] + } as any) + await expect(async () => commandFn.call(scope)) + .rejects.toThrow(/doesn't support WebDriver Bidi/) + }) + + it('it propagates command to middleware', async () => { + (scope as any).eventMiddleware = { send: vi.fn() } + const commandFn = commandWrapper('POST', 'sendCommand', { + command: 'send', + required: true, + parameters: [{ type: 'object', name: 'params' }] + } as any) + await commandFn.call(scope, { params: 'foobar' }) + expect((scope as any).eventMiddleware.send).toHaveBeenCalledTimes(1) + expect((scope as any).eventMiddleware.send).toHaveBeenCalledWith({ params: 'foobar' }) + }) +}) + describe('command wrapper result log', () => { async function getRequestCallback (method: string, path: string, endpoint: Protocols.CommandEndpoint) { const commandFn = commandWrapper(method, path, endpoint) diff --git a/packages/webdriver/tests/index.test.ts b/packages/webdriver/tests/index.test.ts index 478eff4d9..b0c6c748e 100644 --- a/packages/webdriver/tests/index.test.ts +++ b/packages/webdriver/tests/index.test.ts @@ -7,13 +7,29 @@ import { sessionEnvironmentDetector } from '@wdio/utils' import type { Capabilities } from '@wdio/types' import WebDriver, { getPrototype, DEFAULTS, command } from '../src' +// @ts-expect-error mock feature +import { initCount } from '../src/bidi' import { Client } from '../src/types' vi.mock('@wdio/utils', () => import(path.join(process.cwd(), '__mocks__', '@wdio/utils'))) vi.mock('@wdio/logger', () => import(path.join(process.cwd(), '__mocks__', '@wdio/logger'))) vi.mock('fs') +vi.mock('ws') vi.mock('got') +vi.mock('../src/bidi', () => { + let initCount = 0 + return { + BidiHandler: class BidiHandlerMock { + connect = vi.fn() + constructor () { + ++initCount + } + }, + initCount: () => initCount + } +}) + const sessionOptions = { protocol: 'http', hostname: 'localhost', @@ -181,6 +197,17 @@ describe('WebDriver', () => { expect((browser.capabilities as Capabilities.DesiredCapabilities).browserName).toBe('mockBrowser') expect((browser.requestedCapabilities as Capabilities.DesiredCapabilities).browserName).toBe('firefox') }) + + it('attaches bidi handler if socket url is given', async () => { + vi.mocked(got).mockResolvedValue({ + body: { value: { webSocketUrl: 'ws://foo/bar' } } + }) + await WebDriver.newSession({ + path: '/', + capabilities: { browserName: 'firefox' } + }) + expect(initCount()).toBe(1) + }) }) describe('attachToSession', () => { diff --git a/scripts/constants.js b/scripts/constants.js index 3e9595480..6317eb8a7 100644 --- a/scripts/constants.js +++ b/scripts/constants.js @@ -1,4 +1,5 @@ import webdriver from '../packages/wdio-protocols/protocols/webdriver.json' assert { type: 'json' } +import webdriverBidi from '../packages/wdio-protocols/protocols/webdriverBidi.json' assert { type: 'json' } import appium from '../packages/wdio-protocols/protocols/appium.json' assert { type: 'json' } import mjsonwp from '../packages/wdio-protocols/protocols/mjsonwp.json' assert { type: 'json' } import chromium from '../packages/wdio-protocols/protocols/chromium.json' assert { type: 'json' } @@ -8,14 +9,15 @@ import selenium from '../packages/wdio-protocols/protocols/selenium.json' assert import jsonwp from '../packages/wdio-protocols/protocols/jsonwp.json' assert { type: 'json' } export const PROTOCOLS = { - webdriver, appium, mjsonwp, chromium, - gecko, saucelabs, selenium, jsonwp + webdriver, webdriverBidi, appium, mjsonwp, + chromium, gecko, saucelabs, selenium, jsonwp } export const PROTOCOL_NAMES = { appium: 'Appium', jsonwp: 'JSON Wire Protocol', mjsonwp: 'Mobile JSON Wire Protocol', webdriver: 'WebDriver Protocol', + webdriverBidi: 'WebDriver Bidi Protocol', chromium: 'Chromium', gecko: 'Firefox', saucelabs: 'Sauce Labs', diff --git a/scripts/type-generation/constants.js b/scripts/type-generation/constants.js index 78a0ed927..8ff451420 100644 --- a/scripts/type-generation/constants.js +++ b/scripts/type-generation/constants.js @@ -24,5 +24,6 @@ export const returnTypeMap = { findElements: 'ElementReference[]', findElementFromElement: 'ElementReference', findElementsFromElement: 'ElementReference[]', - getAllCookies: 'Cookie[]' + getAllCookies: 'Cookie[]', + send: 'BidiResponse' }