From 24d393ce11d52d627f79e91dd0b93059f763849b Mon Sep 17 00:00:00 2001 From: Liviu Rau Date: Mon, 20 Jan 2025 20:27:49 +0100 Subject: [PATCH 1/5] Add webExtension module --- src/bidiMapper/BidiNoOpParser.ts | 11 +++ src/bidiMapper/BidiParser.ts | 7 ++ src/bidiMapper/CommandProcessor.ts | 7 +- .../webExtension/WebExtensionProcessor.ts | 68 +++++++++++++++++++ src/bidiTab/BidiParser.ts | 11 +++ src/protocol-parser/protocol-parser.ts | 20 ++++++ 6 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 src/bidiMapper/modules/webExtension/WebExtensionProcessor.ts 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..dfcbcd2a8e --- /dev/null +++ b/src/bidiMapper/modules/webExtension/WebExtensionProcessor.ts @@ -0,0 +1,68 @@ +/** + * 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. + */ + +/* + * Copyright 2023 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, +} from '../../../protocol/protocol.js'; + +/** + * Responsible for handling the `storage` module. + */ +export class WebExtensionProcessor { + readonly #browserCdpClient: CdpClient; + + constructor(browserCdpClient: CdpClient) { + this.#browserCdpClient = browserCdpClient; + } + + async install( + params: WebExtension.InstallParameters, + ): Promise { + try { + const response = await this.#browserCdpClient.sendCommand( + 'Extensions.loadUnpacked', + { + path: (params.extensionData as WebExtension.ExtensionArchivePath) + .path, + }, + ); + return { + extension: response.id, + }; + } catch (err: any) { + throw new InvalidWebExtensionException(err.toString()); + } + } +} 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, + ); + } +} From e7c41dfd28f9d3243338e2b474ae5122344d4b48 Mon Sep 17 00:00:00 2001 From: Liviu Rau Date: Mon, 27 Jan 2025 16:19:35 +0100 Subject: [PATCH 2/5] Add e2e tests --- .../webExtension/WebExtensionProcessor.ts | 32 ++++------ .../simple_background_page/background.js | 5 ++ .../simple_background_page/manifest.json | 9 +++ tests/webExtension/test_web_extension.py | 62 +++++++++++++++++++ tools/bidi-server.mjs | 1 + 5 files changed, 89 insertions(+), 20 deletions(-) create mode 100644 tests/webExtension/simple_background_page/background.js create mode 100644 tests/webExtension/simple_background_page/manifest.json create mode 100644 tests/webExtension/test_web_extension.py diff --git a/src/bidiMapper/modules/webExtension/WebExtensionProcessor.ts b/src/bidiMapper/modules/webExtension/WebExtensionProcessor.ts index dfcbcd2a8e..a23376ddfe 100644 --- a/src/bidiMapper/modules/webExtension/WebExtensionProcessor.ts +++ b/src/bidiMapper/modules/webExtension/WebExtensionProcessor.ts @@ -15,30 +15,15 @@ * limitations under the License. */ -/* - * Copyright 2023 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 `storage` module. + * Responsible for handling the `webModule` module. */ export class WebExtensionProcessor { readonly #browserCdpClient: CdpClient; @@ -50,19 +35,26 @@ export class WebExtensionProcessor { 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.ExtensionArchivePath) - .path, + path: (params.extensionData as WebExtension.ExtensionPath).path, }, ); return { extension: response.id, }; } catch (err: any) { - throw new InvalidWebExtensionException(err.toString()); + if ((err as Error).message.startsWith('invalid web extension')) { + throw new InvalidWebExtensionException((err as Error).message); + } + throw err; } } } diff --git a/tests/webExtension/simple_background_page/background.js b/tests/webExtension/simple_background_page/background.js new file mode 100644 index 0000000000..88a8608a2d --- /dev/null +++ b/tests/webExtension/simple_background_page/background.js @@ -0,0 +1,5 @@ +// Copyright 2021 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +console.log('Hello world'); diff --git a/tests/webExtension/simple_background_page/manifest.json b/tests/webExtension/simple_background_page/manifest.json new file mode 100644 index 0000000000..86e0caa118 --- /dev/null +++ b/tests/webExtension/simple_background_page/manifest.json @@ -0,0 +1,9 @@ +{ + "description": "Test Extension - Simple Background Page", + "name": "Test Extension - Simple Background Page", + "version": "0.1", + "manifest_version": 2, + "background": { + "scripts": [ "background.js" ] + } +} diff --git a/tests/webExtension/test_web_extension.py b/tests/webExtension/test_web_extension.py new file mode 100644 index 0000000000..2179347b72 --- /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'] + }, +}], + 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: { From 8352cad84bc90718c70a1b95a690dad6821e46b5 Mon Sep 17 00:00:00 2001 From: Liviu Rau Date: Mon, 27 Jan 2025 16:46:44 +0100 Subject: [PATCH 3/5] Small fix --- .../modules/webExtension/WebExtensionProcessor.ts | 2 +- tests/webExtension/simple_background_page/background.js | 5 ----- tests/webExtension/simple_background_page/manifest.json | 9 --------- 3 files changed, 1 insertion(+), 15 deletions(-) delete mode 100644 tests/webExtension/simple_background_page/background.js delete mode 100644 tests/webExtension/simple_background_page/manifest.json diff --git a/src/bidiMapper/modules/webExtension/WebExtensionProcessor.ts b/src/bidiMapper/modules/webExtension/WebExtensionProcessor.ts index a23376ddfe..fe9052d33d 100644 --- a/src/bidiMapper/modules/webExtension/WebExtensionProcessor.ts +++ b/src/bidiMapper/modules/webExtension/WebExtensionProcessor.ts @@ -50,7 +50,7 @@ export class WebExtensionProcessor { return { extension: response.id, }; - } catch (err: any) { + } catch (err) { if ((err as Error).message.startsWith('invalid web extension')) { throw new InvalidWebExtensionException((err as Error).message); } diff --git a/tests/webExtension/simple_background_page/background.js b/tests/webExtension/simple_background_page/background.js deleted file mode 100644 index 88a8608a2d..0000000000 --- a/tests/webExtension/simple_background_page/background.js +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright 2021 The Chromium Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -console.log('Hello world'); diff --git a/tests/webExtension/simple_background_page/manifest.json b/tests/webExtension/simple_background_page/manifest.json deleted file mode 100644 index 86e0caa118..0000000000 --- a/tests/webExtension/simple_background_page/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "description": "Test Extension - Simple Background Page", - "name": "Test Extension - Simple Background Page", - "version": "0.1", - "manifest_version": 2, - "background": { - "scripts": [ "background.js" ] - } -} From fc3167a4b7a86936daba83c7a0e3956fa7a368c7 Mon Sep 17 00:00:00 2001 From: Liviu Rau Date: Tue, 28 Jan 2025 11:31:51 +0100 Subject: [PATCH 4/5] Add PipeTransport implementation --- src/bidiServer/BrowserInstance.ts | 36 ++++++++--- src/utils/PipeTransport.ts | 81 ++++++++++++++++++++++++ tests/webExtension/test_web_extension.py | 2 +- 3 files changed, 108 insertions(+), 11 deletions(-) create mode 100644 src/utils/PipeTransport.ts diff --git a/src/bidiServer/BrowserInstance.ts b/src/bidiServer/BrowserInstance.ts index d67d818883..bb8ef0bf2a 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'; @@ -106,16 +107,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('--remote-debugging-pipe' in chromeArguments) { + 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 +171,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/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 index 2179347b72..801a1e7d72 100644 --- a/tests/webExtension/test_web_extension.py +++ b/tests/webExtension/test_web_extension.py @@ -39,7 +39,7 @@ async def test_method_not_available(websocket): @pytest.mark.asyncio @pytest.mark.parametrize('capabilities', [{ 'goog:chromeOptions': { - 'args': ['--enable-unsafe-extension-debugging'] + 'args': ['--enable-unsafe-extension-debugging', '--remote-debugging-pipe'] }, }], indirect=True) From 3b31ef6830e40fed4fdf1135aa1c44898f8866d6 Mon Sep 17 00:00:00 2001 From: Liviu Rau Date: Tue, 28 Jan 2025 12:38:30 +0100 Subject: [PATCH 5/5] --- --- src/bidiServer/BrowserInstance.ts | 3 ++- tests/webExtension/test_web_extension.py | 10 +++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/bidiServer/BrowserInstance.ts b/src/bidiServer/BrowserInstance.ts index bb8ef0bf2a..4324c554a5 100644 --- a/src/bidiServer/BrowserInstance.ts +++ b/src/bidiServer/BrowserInstance.ts @@ -98,6 +98,7 @@ export class BrowserInstance { executablePath, args: chromeArguments, env: process.env, + pipe: true, }; debugInternal(`Launching browser`, { @@ -108,7 +109,7 @@ export class BrowserInstance { const browserProcess = launch(launchArguments); let cdpConnection; - if('--remote-debugging-pipe' in chromeArguments) { + if(chromeArguments.includes('--remote-debugging-pipe')) { cdpConnection = await this.#establishPipeConnection(browserProcess); } else { const cdpEndpoint = await browserProcess.waitForLineOutput( diff --git a/tests/webExtension/test_web_extension.py b/tests/webExtension/test_web_extension.py index 801a1e7d72..7d15483caa 100644 --- a/tests/webExtension/test_web_extension.py +++ b/tests/webExtension/test_web_extension.py @@ -38,11 +38,11 @@ async def test_method_not_available(websocket): @pytest.mark.asyncio @pytest.mark.parametrize('capabilities', [{ - 'goog:chromeOptions': { - 'args': ['--enable-unsafe-extension-debugging', '--remote-debugging-pipe'] - }, -}], - indirect=True) + 'goog:chromeOptions': { + 'args': ['--enable-unsafe-extension-debugging', '--remote-debugging-pipe'] + }, + }], + indirect=True) async def test_invalid_path(websocket): with pytest.raises( Exception,