diff --git a/src/bidiMapper/BidiNoOpParser.ts b/src/bidiMapper/BidiNoOpParser.ts index 6f499982e7..448271ddfd 100644 --- a/src/bidiMapper/BidiNoOpParser.ts +++ b/src/bidiMapper/BidiNoOpParser.ts @@ -26,6 +26,7 @@ import type { Storage, Permissions, Bluetooth, + WebExtension, } from '../protocol/protocol.js'; import type {BidiCommandParameterParser} from './BidiParser.js'; @@ -232,4 +233,14 @@ export class BidiNoOpParser implements BidiCommandParameterParser { return params as Storage.SetCookieParameters; } // keep-sorted end + + // WebExtenstion module + // keep-sorted start block=yes + parseInstallParams(params: unknown): WebExtension.InstallParameters { + return params as WebExtension.InstallParameters; + } + parseUninstallParams(params: unknown): WebExtension.UninstallParameters { + return params as WebExtension.UninstallParameters; + } + // keep-sorted end } diff --git a/src/bidiMapper/BidiParser.ts b/src/bidiMapper/BidiParser.ts index 29682f210b..64e4661998 100644 --- a/src/bidiMapper/BidiParser.ts +++ b/src/bidiMapper/BidiParser.ts @@ -26,6 +26,7 @@ import type { Script, Session, Storage, + WebExtension, } from '../protocol/protocol.js'; export interface BidiCommandParameterParser { @@ -146,4 +147,10 @@ export interface BidiCommandParameterParser { parseGetCookiesParams(params: unknown): Storage.GetCookiesParameters; parseSetCookieParams(params: unknown): Storage.SetCookieParameters; // keep-sorted end + + // WebExtenstion module + // keep-sorted start block=yes + parseInstallParams(params: unknown): WebExtension.InstallParameters; + parseUninstallParams(params: unknown): WebExtension.UninstallParameters; + // keep-sorted end } diff --git a/src/bidiMapper/CommandProcessor.ts b/src/bidiMapper/CommandProcessor.ts index d64b907b9b..c547f42c13 100644 --- a/src/bidiMapper/CommandProcessor.ts +++ b/src/bidiMapper/CommandProcessor.ts @@ -46,6 +46,7 @@ import {ScriptProcessor} from './modules/script/ScriptProcessor.js'; import type {EventManager} from './modules/session/EventManager.js'; import {SessionProcessor} from './modules/session/SessionProcessor.js'; import {StorageProcessor} from './modules/storage/StorageProcessor.js'; +import {WebExtensionProcessor} from './modules/webExtension/WebExtensionProcessor.js'; import {OutgoingMessage} from './OutgoingMessage.js'; export const enum CommandProcessorEvents { @@ -71,6 +72,7 @@ export class CommandProcessor extends EventEmitter { #scriptProcessor: ScriptProcessor; #sessionProcessor: SessionProcessor; #storageProcessor: StorageProcessor; + #webExtensionProcessor: WebExtensionProcessor; // keep-sorted end #parser: BidiCommandParameterParser; @@ -134,6 +136,7 @@ export class CommandProcessor extends EventEmitter { browsingContextStorage, logger, ); + this.#webExtensionProcessor = new WebExtensionProcessor(browserCdpClient); // keep-sorted end } @@ -402,8 +405,8 @@ export class CommandProcessor extends EventEmitter { // WebExtension module // keep-sorted start block=yes case 'webExtension.install': - throw new UnknownErrorException( - `Method ${command.method} is not implemented.`, + return await this.#webExtensionProcessor.install( + this.#parser.parseInstallParams(command.params), ); case 'webExtension.uninstall': throw new UnknownErrorException( diff --git a/src/bidiMapper/modules/webExtension/WebExtensionProcessor.ts b/src/bidiMapper/modules/webExtension/WebExtensionProcessor.ts new file mode 100644 index 0000000000..fe9052d33d --- /dev/null +++ b/src/bidiMapper/modules/webExtension/WebExtensionProcessor.ts @@ -0,0 +1,60 @@ +/** + * Copyright 2025 Google LLC. + * 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 type {CdpClient} from '../../../cdp/CdpClient.js'; +import { + InvalidWebExtensionException, + type WebExtension, + UnsupportedOperationException, +} from '../../../protocol/protocol.js'; + +/** + * Responsible for handling the `webModule` module. + */ +export class WebExtensionProcessor { + readonly #browserCdpClient: CdpClient; + + constructor(browserCdpClient: CdpClient) { + this.#browserCdpClient = browserCdpClient; + } + + async install( + params: WebExtension.InstallParameters, + ): Promise { + if (!(params.extensionData as WebExtension.ExtensionPath).path) { + throw new UnsupportedOperationException( + 'Archived and Base64 extensions are not supported', + ); + } + try { + const response = await this.#browserCdpClient.sendCommand( + 'Extensions.loadUnpacked', + { + path: (params.extensionData as WebExtension.ExtensionPath).path, + }, + ); + return { + extension: response.id, + }; + } catch (err) { + if ((err as Error).message.startsWith('invalid web extension')) { + throw new InvalidWebExtensionException((err as Error).message); + } + throw err; + } + } +} diff --git a/src/bidiServer/BrowserInstance.ts b/src/bidiServer/BrowserInstance.ts index d67d818883..4324c554a5 100644 --- a/src/bidiServer/BrowserInstance.ts +++ b/src/bidiServer/BrowserInstance.ts @@ -30,6 +30,7 @@ import WebSocket from 'ws'; import {MapperCdpConnection} from '../cdp/CdpConnection.js'; import {WebSocketTransport} from '../utils/WebsocketTransport.js'; +import {PipeTransport} from '../utils/PipeTransport.js'; import {MapperServerCdpConnection} from './MapperCdpConnection.js'; import {getMapperTabSource} from './reader.js'; @@ -97,6 +98,7 @@ export class BrowserInstance { executablePath, args: chromeArguments, env: process.env, + pipe: true, }; debugInternal(`Launching browser`, { @@ -106,16 +108,20 @@ export class BrowserInstance { const browserProcess = launch(launchArguments); - const cdpEndpoint = await browserProcess.waitForLineOutput( - CDP_WEBSOCKET_ENDPOINT_REGEX, - ); - - // There is a conflict between prettier and eslint here. - // prettier-ignore - const cdpConnection = await this.#establishCdpConnection( - cdpEndpoint - ); - + let cdpConnection; + if(chromeArguments.includes('--remote-debugging-pipe')) { + cdpConnection = await this.#establishPipeConnection(browserProcess); + } else { + const cdpEndpoint = await browserProcess.waitForLineOutput( + CDP_WEBSOCKET_ENDPOINT_REGEX, + ); + + // There is a conflict between prettier and eslint here. + // prettier-ignore + cdpConnection = await this.#establishCdpConnection( + cdpEndpoint + ); + } // 2. Get `BiDi-CDP` mapper JS binaries. const mapperTabSource = await getMapperTabSource(); @@ -166,4 +172,15 @@ export class BrowserInstance { }); }); } + + static #establishPipeConnection(browserProcess: Process):Promise { + debugInternal('Establishing pipe connection to broser process with cdpUrl: ', browserProcess.nodeProcess.pid); + const {3: pipeWrite, 4: pipeRead} = browserProcess.nodeProcess.stdio; + const transport = new PipeTransport( + pipeWrite as NodeJS.WritableStream, + pipeRead as NodeJS.ReadableStream, + ); + const connection = new MapperCdpConnection(transport); + return Promise.resolve(connection); + } } diff --git a/src/bidiTab/BidiParser.ts b/src/bidiTab/BidiParser.ts index da303e11de..e33a1d0116 100644 --- a/src/bidiTab/BidiParser.ts +++ b/src/bidiTab/BidiParser.ts @@ -26,6 +26,7 @@ import type { Script, Session, Storage, + WebExtension, } from '../protocol/protocol.js'; import * as Parser from '../protocol-parser/protocol-parser.js'; @@ -231,4 +232,14 @@ export class BidiParser implements BidiCommandParameterParser { return Parser.Storage.parseSetCookieParams(params); } // keep-sorted end + + // WebExtenstion module + // keep-sorted start block=yes + parseInstallParams(params: unknown): WebExtension.InstallParameters { + return Parser.WebModule.parseInstallParams(params); + } + parseUninstallParams(params: unknown): WebExtension.UninstallParameters { + return Parser.WebModule.parseUninstallParams(params); + } + // keep-sorted end } diff --git a/src/protocol-parser/protocol-parser.ts b/src/protocol-parser/protocol-parser.ts index 90ae967900..98b2470b50 100644 --- a/src/protocol-parser/protocol-parser.ts +++ b/src/protocol-parser/protocol-parser.ts @@ -433,3 +433,23 @@ export namespace Bluetooth { ) as Protocol.Bluetooth.SimulatePreconnectedPeripheralParameters; } } + +/** @see https://w3c.github.io/webdriver-bidi/#module-webExtension */ +export namespace WebModule { + export function parseInstallParams( + params: unknown, + ): Protocol.WebExtension.InstallParameters { + return parseObject( + params, + WebDriverBidi.WebExtension.InstallParametersSchema, + ); + } + export function parseUninstallParams( + params: unknown, + ): Protocol.WebExtension.UninstallParameters { + return parseObject( + params, + WebDriverBidi.WebExtension.UninstallParametersSchema, + ); + } +} diff --git a/src/utils/PipeTransport.ts b/src/utils/PipeTransport.ts new file mode 100644 index 0000000000..807cff8a50 --- /dev/null +++ b/src/utils/PipeTransport.ts @@ -0,0 +1,81 @@ + +import type WebSocket from 'ws'; + + +import {assert} from './assert.js'; +import debug from 'debug'; +import type {Transport} from './transport.js'; + +const debugInternal = debug('bidi:mapper:internal'); + +export class PipeTransport implements Transport { + #pipeWrite: NodeJS.WritableStream; + #onMessage: ((message: string) => void) | null = null; + + //#subscriptions = new DisposableStack(); + + #isClosed = false; + #pendingMessage = ''; + + constructor( + pipeWrite: NodeJS.WritableStream, + pipeRead: NodeJS.ReadableStream, + ) { + this.#pipeWrite = pipeWrite; + + pipeRead.on('data', (chunk) => { + return this.#dispatch(chunk); + }); + pipeRead.on('close', () => { + this.close(); + }); + pipeRead.on('error', (error) => { + debugInternal('Pipe read error: ', error); + this.close(); + }); + pipeWrite.on('error', (error) => { + debugInternal('Pipe read error: ', error); + this.close(); + }); + } + + setOnMessage(onMessage: (message: string) => void) { + this.#onMessage = onMessage; + } + sendMessage(message: string) { + assert(!this.#isClosed, '`PipeTransport` is closed.'); + + this.#pipeWrite.write(message); + this.#pipeWrite.write('\0'); + } + + #dispatch(buffer: Buffer): void { + assert(!this.#isClosed, '`PipeTransport` is closed.'); + + let end = buffer.indexOf('\0'); + if (end === -1) { + this.#pendingMessage += buffer.toString(); + return; + } + const message = this.#pendingMessage + buffer.toString(undefined, 0, end); + if (this.#onMessage) { + this.#onMessage.call(null, message); + } + + let start = end + 1; + end = buffer.indexOf('\0', start); + while (end !== -1) { + if (this.#onMessage) { + this.#onMessage.call(null, buffer.toString(undefined, start, end)); + } + start = end + 1; + end = buffer.indexOf('\0', start); + } + this.#pendingMessage = buffer.toString(undefined, start); + } + + close(): void { + debugInternal('Closing pipe'); + this.#isClosed = true; + } + } \ No newline at end of file diff --git a/tests/webExtension/test_web_extension.py b/tests/webExtension/test_web_extension.py new file mode 100644 index 0000000000..7d15483caa --- /dev/null +++ b/tests/webExtension/test_web_extension.py @@ -0,0 +1,62 @@ +# Copyright 2025 Google LLC. +# 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 pytest +from test_helpers import execute_command + + +@pytest.mark.asyncio +async def test_method_not_available(websocket): + with pytest.raises(Exception, + match=str({ + 'error': 'unknown error', + 'message': 'Method not available.', + })): + await execute_command( + websocket, { + "method": "webExtension.install", + "params": { + "extensionData": { + "type": "path", + "path": "invalid-path", + }, + } + }) + + +@pytest.mark.asyncio +@pytest.mark.parametrize('capabilities', [{ + 'goog:chromeOptions': { + 'args': ['--enable-unsafe-extension-debugging', '--remote-debugging-pipe'] + }, + }], + indirect=True) +async def test_invalid_path(websocket): + with pytest.raises( + Exception, + match=str({ + 'error': 'unknown error', # should not be that + 'message': 'Method not available.', + })): + await execute_command( + websocket, { + "method": "webExtension.install", + "params": { + "extensionData": { + "type": "path", + "path": "invalid-path", + }, + } + }) diff --git a/tools/bidi-server.mjs b/tools/bidi-server.mjs index a07886cf80..3e4311c51b 100644 --- a/tools/bidi-server.mjs +++ b/tools/bidi-server.mjs @@ -110,6 +110,7 @@ export function createBiDiServerProcess() { `--bidi-mapper-path=${resolve(join('lib', 'iife', 'mapperTab.js'))}`, `--log-path=${createLogFile('chromedriver')}`, `--readable-timestamp`, + `--remote-debugging-pipe`, ...(VERBOSE ? ['--verbose'] : []), ], options: {