From 997bb50e6f0970326156e2208434f4e226e0f6bb Mon Sep 17 00:00:00 2001 From: Holger Benl Date: Fri, 4 Sep 2026 13:29:15 +0200 Subject: [PATCH] chore: add webdriver-bidi-protocol types --- package-lock.json | 10 +++- package.json | 3 +- src/firefox/bidi.ts | 97 ++++++++++++++++++++++++++++++++- src/firefox/cache.ts | 15 ++--- src/firefox/events/console.ts | 6 +- src/firefox/events/debugging.ts | 12 +--- src/firefox/events/network.ts | 24 ++++---- src/firefox/index.ts | 30 +++++----- src/firefox/pages.ts | 21 ++++--- src/firefox/types.ts | 38 ------------- src/tools/debugging.ts | 4 +- src/tools/firefox-prefs.ts | 4 +- src/tools/network.ts | 16 +++--- src/tools/pages.ts | 7 ++- src/tools/privileged-context.ts | 11 +--- src/tools/screencast.ts | 5 +- src/tools/script.ts | 26 ++++----- src/tools/webextension.ts | 31 +++++------ tests/firefox/bidi.test.ts | 4 +- tests/firefox/pages.test.ts | 11 ++-- tsconfig.json | 3 +- 21 files changed, 215 insertions(+), 163 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9873328e..2bb6b93a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,7 +39,8 @@ "tsup": "8.5.0", "tsx": "4.21.0", "typescript": "5.3.3", - "vitest": "4.1.8" + "vitest": "4.1.8", + "webdriver-bidi-protocol": "^0.4.3" }, "engines": { "node": ">=20.19.0" @@ -8188,6 +8189,13 @@ "node": ">=18" } }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz", + "integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", diff --git a/package.json b/package.json index cdd94be1..5e824027 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,8 @@ "tsup": "8.5.0", "tsx": "4.21.0", "typescript": "5.3.3", - "vitest": "4.1.8" + "vitest": "4.1.8", + "webdriver-bidi-protocol": "^0.4.3" }, "files": [ "dist", diff --git a/src/firefox/bidi.ts b/src/firefox/bidi.ts index eb0d2e08..2431587a 100644 --- a/src/firefox/bidi.ts +++ b/src/firefox/bidi.ts @@ -1,8 +1,96 @@ import EventEmitter from 'node:events'; import { WebDriver } from 'selenium-webdriver'; +import type { + BrowsingContext, + Commands, + Event, + EmptyParams, + EmptyResult, + Network, +} from 'webdriver-bidi-protocol'; import { logDebug } from '../utils/logger.js'; -export class BiDiFacade extends EventEmitter { +// Firefox-specific events +type DebuggingPausedEvent = { + method: 'moz:debugging.paused'; + params: { context: BrowsingContext.BrowsingContext; url: string; line: number; column: number }; +}; +type DebuggingResumedEvent = { + method: 'moz:debugging.resumed'; + params: { context: BrowsingContext.BrowsingContext }; +}; +type FirefoxEvent = Event | DebuggingPausedEvent | DebuggingResumedEvent; +type FirefoxEventMap = { + [M in FirefoxEvent['method']]: [Extract['params']]; +}; +type FirefoxEventName = keyof FirefoxEventMap; + +export type FirefoxCommands = Commands & { + // Firefox-specific extensions to standard commands + 'browsingContext.getTree': { + params: { 'moz:scope'?: string }; + }; + 'webExtension.install': { + params: { 'moz:permanent'?: boolean }; + }; + // Firefox-specific commands + 'moz:debugging.setDebuggerEnabled': { + params: { enabled: boolean }; + returnType: EmptyResult; + }; + 'moz:debugging.setBreakpoint': { + params: { location: { url: string; line: number } }; + returnType: { breakpoint: string }; + }; + 'moz:debugging.removeBreakpoint': { + params: { breakpoint: string }; + returnType: EmptyResult; + }; + 'moz:debugging.resume': { + params: { context: BrowsingContext.BrowsingContext }; + returnType: EmptyResult; + }; + 'moz:debugging.listScripts': { + params: { context: BrowsingContext.BrowsingContext }; + returnType: { scripts: string[] }; + }; + 'moz:debugging.getScriptSource': { + params: { context: BrowsingContext.BrowsingContext; scriptUrl: string }; + returnType: { source: string }; + }; + 'moz:profiler.start': { + params: EmptyParams; + returnType: { active: boolean }; + }; + 'moz:profiler.stop': { + params: { discard?: boolean }; + returnType: { path?: string }; + }; + 'moz:profiler.isActive': { + params: EmptyParams; + returnType: { active: boolean }; + }; +}; + +// webdriver-bidi-protocol uses ambient const enums AND we're using vitest +// which implies typescript's isolatedModules is true, meaning that +// these enums are not available at runtime, so we create these helpers +// for easier access to the correctly typed values +export const ReadinessState = { + None: 'none' as BrowsingContext.ReadinessState, + Interactive: 'interactive' as BrowsingContext.ReadinessState, + Complete: 'complete' as BrowsingContext.ReadinessState, +} as const satisfies Record< + keyof typeof BrowsingContext.ReadinessState, + BrowsingContext.ReadinessState +>; + +export const DataType = { + Request: 'request' as Network.DataType, + Response: 'response' as Network.DataType, +} as const satisfies Record; + +export class BiDiFacade extends EventEmitter { private listening = false; private nextCommandId = 1; @@ -10,7 +98,7 @@ export class BiDiFacade extends EventEmitter { super(); } - async subscribe(events: string | string[]) { + async subscribe(events: FirefoxEventName | FirefoxEventName[]) { const bidi = await this.driver.getBidi(); if (!this.listening) { this.listenForEvents(bidi.socket); @@ -19,7 +107,10 @@ export class BiDiFacade extends EventEmitter { await bidi.subscribe(events); } - async sendCommand(method: string, params: Record = {}): Promise { + async sendCommand( + method: T, + params: FirefoxCommands[T]['params'] = {} + ): Promise { const bidi = await this.driver.getBidi(); // bidi.socket is a Node.js `ws` WebSocket (EventEmitter-style), but typed as browser WebSocket const ws = bidi.socket as any; diff --git a/src/firefox/cache.ts b/src/firefox/cache.ts index 9283c4c9..816a668a 100644 --- a/src/firefox/cache.ts +++ b/src/firefox/cache.ts @@ -1,17 +1,14 @@ -/** - * Network cache behaviour (WebDriver BiDi network.setCacheBehavior) - */ - -export type BiDiCommandFn = (method: string, params: Record) => Promise; +import type { Network } from 'webdriver-bidi-protocol'; +import type { BiDiFacade } from './bidi'; /** * WebDriver BiDi network.CacheBehavior. * - "default": normal HTTP cache behaviour * - "bypass": skip the cache, so every request goes to the network */ -export const CACHE_BEHAVIORS = ['default', 'bypass'] as const; +export type CacheBehavior = Network.SetCacheBehaviorParameters['cacheBehavior']; -export type CacheBehavior = (typeof CACHE_BEHAVIORS)[number]; +export const CACHE_BEHAVIORS: CacheBehavior[] = ['default', 'bypass'] as const; export function isCacheBehavior(value: unknown): value is CacheBehavior { return CACHE_BEHAVIORS.includes(value as CacheBehavior); @@ -20,11 +17,11 @@ export function isCacheBehavior(value: unknown): value is CacheBehavior { export class CacheManagement { constructor( private getCurrentContextId: () => string | null, - private sendBiDiCommand: BiDiCommandFn + private sendBiDiCommand: BiDiFacade['sendCommand'] ) {} async setCacheBehavior(behavior: CacheBehavior, options?: { global?: boolean }): Promise { - const params: Record = { cacheBehavior: behavior }; + const params: Network.SetCacheBehaviorParameters = { cacheBehavior: behavior }; // Omitting `contexts` applies the behaviour globally; passing the current // context scopes it to the selected tab, which is the default. diff --git a/src/firefox/events/console.ts b/src/firefox/events/console.ts index 846961f1..81feb950 100644 --- a/src/firefox/events/console.ts +++ b/src/firefox/events/console.ts @@ -45,11 +45,11 @@ export class ConsoleEvents { this.bidi.on('log.entryAdded', (entry) => { const message: ConsoleMessage = { - level: (entry.level as ConsoleMessage['level']) || 'info', - text: entry.text || (entry.args ? JSON.stringify(entry.args) : ''), + level: entry.level || 'info', + text: entry.text || ('args' in entry && entry.args ? JSON.stringify(entry.args) : ''), timestamp: entry.timestamp || Date.now(), source: entry.source?.realm, - args: entry.args, + args: 'args' in entry ? entry.args : [], }; this.consoleMessages.push(message); logDebug(`Console [${message.level}]: ${message.text}`); diff --git a/src/firefox/events/debugging.ts b/src/firefox/events/debugging.ts index a5e7268e..2d99ad85 100644 --- a/src/firefox/events/debugging.ts +++ b/src/firefox/events/debugging.ts @@ -96,21 +96,15 @@ export class DebuggingEvents { awaitPromise: false, }); - const evalResult = result as { - type: string; - result?: unknown; - exceptionDetails?: { text: string }; - }; - - if (evalResult.type === 'exception') { + if (result.type === 'exception') { entry.results.push({ value: null, - error: evalResult.exceptionDetails?.text ?? 'Unknown error', + error: result.exceptionDetails?.text ?? 'Unknown error', timestamp: Date.now(), }); } else { entry.results.push({ - value: evalResult.result, + value: result.result, timestamp: Date.now(), }); } diff --git a/src/firefox/events/network.ts b/src/firefox/events/network.ts index f47d3374..9f38073d 100644 --- a/src/firefox/events/network.ts +++ b/src/firefox/events/network.ts @@ -2,7 +2,8 @@ * Network event handling with lifecycle hooks */ -import type { BiDiFacade } from '../bidi.js'; +import type { Network } from 'webdriver-bidi-protocol'; +import { DataType, type BiDiFacade } from '../bidi.js'; import { logDebug } from '../../utils/logger.js'; // Memory protection constants @@ -77,7 +78,7 @@ export class NetworkEvents { return; } - const requestId = req.request?.request || req.requestId; + const requestId = req.request.request; if (!requestId) { return; @@ -87,12 +88,13 @@ export class NetworkEvents { const record = { id: requestId, - url: req.request?.url || '', - method: req.request?.method || 'GET', + url: req.request.url, + method: req.request.method, timestamp: Date.now(), - resourceType: this.guessResourceType(req.request?.url || ''), - isXHR: req.initiator?.type === 'xmlhttprequest' || req.initiator?.type === 'fetch', - requestHeaders: this.parseHeaders(req.request?.headers || []), + resourceType: this.guessResourceType(req.request.url), + isXHR: + req.request.initiatorType === 'xmlhttprequest' || req.request.initiatorType === 'fetch', + requestHeaders: this.parseHeaders(req.request.headers), timings: { requestTime: Date.now(), }, @@ -108,7 +110,7 @@ export class NetworkEvents { return; } - const requestId = resp.request?.request || resp.requestId; + const requestId = resp.request?.request; if (!requestId) { return; @@ -128,7 +130,7 @@ export class NetworkEvents { return; } - const requestId = resp.request?.request || resp.requestId; + const requestId = resp.request?.request; if (!requestId) { return; @@ -171,7 +173,7 @@ export class NetworkEvents { try { const result = await this.bidi.sendCommand('network.addDataCollector', { - dataTypes: ['request', 'response'], + dataTypes: [DataType.Request, DataType.Response], maxEncodedDataSize: MAX_ENCODED_DATA_SIZE, }); this.collectorId = result?.collector ?? null; @@ -192,7 +194,7 @@ export class NetworkEvents { * Returns a structured result so callers can render an appropriate marker * when the body was never collected, evicted, or the browser lacks support. */ - async fetchBody(requestId: string, dataType: 'request' | 'response'): Promise { + async fetchBody(requestId: string, dataType: Network.DataType): Promise { if (!this.collectorId) { return { ok: false, reason: 'unsupported' }; } diff --git a/src/firefox/index.ts b/src/firefox/index.ts index 96244139..8b5a5e5e 100644 --- a/src/firefox/index.ts +++ b/src/firefox/index.ts @@ -4,6 +4,7 @@ import type { FirefoxLaunchOptions, ConsoleMessage, LogpointResult } from './types.js'; import { WebElement } from 'selenium-webdriver'; +import type { Browser, BrowsingContext, Network } from 'webdriver-bidi-protocol'; import { FirefoxCore } from './core.js'; import { BiDiFacade } from './bidi.js'; import { logDebug } from '../utils/logger.js'; @@ -11,7 +12,7 @@ import { remoteValueToNative } from '../utils/remote-value.js'; import { ConsoleEvents, NetworkEvents, DebuggingEvents, DownloadEvents } from './events/index.js'; import type { NetworkBodyResult } from './events/network.js'; import { DomInteractions } from './dom.js'; -import { PageManagement, type ReadinessState } from './pages.js'; +import { PageManagement } from './pages.js'; import { CacheManagement, type CacheBehavior } from './cache.js'; import { SnapshotManager, type Snapshot, type SnapshotOptions } from './snapshot/index.js'; @@ -101,12 +102,12 @@ export class FirefoxClient { driver, () => this.core.getCurrentContextId(), (id: string) => this.core.setCurrentContextId(id), - (method: string, params: Record) => this.getBidi().sendCommand(method, params) + (method, params) => this.getBidi().sendCommand(method, params) ); this.cache = new CacheManagement( () => this.core.getCurrentContextId(), - (method: string, params: Record) => this.getBidi().sendCommand(method, params) + (method, params) => this.getBidi().sendCommand(method, params) ); } @@ -121,10 +122,14 @@ export class FirefoxClient { * native value; throws on a script exception. */ async evaluate(expression: string): Promise { + const context = this.core.getCurrentContextId(); + if (!context) { + throw new Error('No active browsing context'); + } const result = await this.getBidi().sendCommand('script.evaluate', { expression, awaitPromise: true, - target: { context: this.core.getCurrentContextId() }, + target: { context }, }); if (result.type === 'success') { return remoteValueToNative(result.result); @@ -256,7 +261,7 @@ export class FirefoxClient { // Pages / Navigation // ============================================================================ - async navigate(url: string, wait?: ReadinessState): Promise { + async navigate(url: string, wait?: BrowsingContext.ReadinessState): Promise { if (!this.pages) { throw new Error('Not connected'); } @@ -326,7 +331,7 @@ export class FirefoxClient { return await this.pages.selectTab(index); } - async createNewPage(url: string, wait?: ReadinessState): Promise { + async createNewPage(url: string, wait?: BrowsingContext.ReadinessState): Promise { if (!this.pages) { throw new Error('Not connected'); } @@ -393,7 +398,7 @@ export class FirefoxClient { */ async getNetworkRequestBody( requestId: string, - dataType: 'request' | 'response' + dataType: Network.DataType ): Promise { if (!this.networkEvents) { throw new Error( @@ -430,11 +435,11 @@ export class FirefoxClient { * @param behavior 'allowed' saves downloads silently, 'denied' cancels them, 'default' resets */ async setDownloadBehavior(behavior: 'allowed' | 'denied' | 'default'): Promise { - const downloadBehavior = + const downloadBehavior: Browser.DownloadBehavior | null = behavior === 'default' ? null : behavior === 'allowed' - ? { type: 'allowed' } + ? ({ type: 'allowed' } as unknown as Browser.DownloadBehavior) : { type: 'denied' }; await this.getBidi().sendCommand('browser.setDownloadBehavior', { downloadBehavior }); } @@ -497,9 +502,8 @@ export class FirefoxClient { * Send raw BiDi command (for advanced operations) * @internal */ - async sendBiDiCommand(method: string, params: Record = {}): Promise { - return await this.getBidi().sendCommand(method, params); - } + sendBiDiCommand: BiDiFacade['sendCommand'] = (method, params) => + this.getBidi().sendCommand(method, params); /** * Get WebDriver instance (for advanced operations) @@ -552,7 +556,7 @@ export class FirefoxClient { const result = await this.getBidi().sendCommand('moz:debugging.setBreakpoint', { location: { url, line }, }); - const logpointId = (result as { breakpoint: string }).breakpoint; + const logpointId = result.breakpoint; this.debuggingEvents.addLogpoint(logpointId, url, line, expression); return logpointId; } diff --git a/src/firefox/pages.ts b/src/firefox/pages.ts index 0729b9fd..469baaaa 100644 --- a/src/firefox/pages.ts +++ b/src/firefox/pages.ts @@ -3,6 +3,8 @@ */ import { WebDriver } from 'selenium-webdriver'; +import type { BrowsingContext } from 'webdriver-bidi-protocol'; +import { ReadinessState, type BiDiFacade } from './bidi.js'; import { log, logDebug } from '../utils/logger.js'; const COMMON_URL_SCHEMES = ['http:', 'https:', 'data:', 'blob:', 'file:']; @@ -19,20 +21,16 @@ export function isCommonScheme(url: string): boolean { } } -export type BiDiCommandFn = (method: string, params: Record) => Promise; - /** * WebDriver BiDi browsingContext.ReadinessState. * - "none": return as soon as navigation starts * - "interactive": wait for DOMContentLoaded * - "complete": wait for the load event, including subresources */ -export const READINESS_STATES = ['none', 'interactive', 'complete'] as const; - -export type ReadinessState = (typeof READINESS_STATES)[number]; +export const READINESS_STATES = [...Object.values(ReadinessState)]; -export function isReadinessState(value: unknown): value is ReadinessState { - return READINESS_STATES.includes(value as ReadinessState); +export function isReadinessState(value: unknown): value is BrowsingContext.ReadinessState { + return READINESS_STATES.includes(value as BrowsingContext.ReadinessState); } export class PageManagement { @@ -40,7 +38,7 @@ export class PageManagement { private driver: WebDriver, private getCurrentContextId: () => string | null, private setCurrentContextId: (id: string) => void, - private sendBiDiCommand: BiDiCommandFn + private sendBiDiCommand: BiDiFacade['sendCommand'] ) {} /** @@ -50,7 +48,7 @@ export class PageManagement { * @param waitOverride - Explicit readiness state to wait for. When omitted, * common schemes wait for "interactive" and uncommon schemes do not wait. */ - async navigate(url: string, waitOverride?: ReadinessState): Promise { + async navigate(url: string, waitOverride?: BrowsingContext.ReadinessState): Promise { const contextId = this.getCurrentContextId(); if (!contextId) { throw new Error(`Cannot navigate: no browsing context ID`); @@ -60,7 +58,8 @@ export class PageManagement { // All uncommon schemes use wait time "none". // An explicit override is honoured for every scheme: silently downgrading it // would discard what the caller asked for with no way to tell. - const wait: ReadinessState = waitOverride ?? (isCommonScheme(url) ? 'interactive' : 'none'); + const wait = + waitOverride ?? (isCommonScheme(url) ? ReadinessState.Interactive : ReadinessState.None); // Navigate using direct BiDi await this.sendBiDiCommand('browsingContext.navigate', { @@ -206,7 +205,7 @@ export class PageManagement { /** * Create new page (tab) */ - async createNewPage(url: string, waitOverride?: ReadinessState): Promise { + async createNewPage(url: string, waitOverride?: BrowsingContext.ReadinessState): Promise { await this.driver.switchTo().newWindow('tab'); const handles = await this.driver.getAllWindowHandles(); const newIdx = handles.length - 1; diff --git a/src/firefox/types.ts b/src/firefox/types.ts index a447631b..18c443c8 100644 --- a/src/firefox/types.ts +++ b/src/firefox/types.ts @@ -102,41 +102,3 @@ export interface FirefoxLaunchOptions { /** Capture network request/response bodies via BiDi data collectors (default: true) */ captureNetworkBodies?: boolean | undefined; } - -/** - * BiDi command structure - */ -export interface BiDiCommand { - id: number; - method: string; - params: Record; -} - -/** - * BiDi response structure - */ -export interface BiDiResponse { - id?: number; - result?: Record; - error?: { - error: string; - message: string; - stacktrace?: string; - }; - method?: string; - params?: Record; -} - -/** - * BiDi error - */ -export class BiDiError extends Error { - constructor( - message: string, - public code: string, - public details?: unknown - ) { - super(message); - this.name = 'BiDiError'; - } -} diff --git a/src/tools/debugging.ts b/src/tools/debugging.ts index bea70e74..ec66cc03 100644 --- a/src/tools/debugging.ts +++ b/src/tools/debugging.ts @@ -136,7 +136,7 @@ export const handleListScripts = defineToolHandler( const result = await firefox.sendBiDiCommand('moz:debugging.listScripts', { context: contextId, }); - const scripts = (result as { scripts: string[] }).scripts; + const scripts = result.scripts; if (scripts.length === 0) { return successResponse('No scripts found'); } @@ -155,7 +155,7 @@ export const handleGetScriptSource = defineToolHandler( context: contextId, scriptUrl, }); - return successResponse((result as { source: string }).source); + return successResponse(result.source); } ); diff --git a/src/tools/firefox-prefs.ts b/src/tools/firefox-prefs.ts index b12d5301..3dea113f 100644 --- a/src/tools/firefox-prefs.ts +++ b/src/tools/firefox-prefs.ts @@ -66,7 +66,7 @@ export const handleSetFirefoxPrefs = defineToolHandler( } const driver = firefox.getDriver(); - const chromeContextId = contexts[0].context; + const chromeContextId = contexts[0]!.context; // Remember current context const originalContextId = firefox.getCurrentContextId(); @@ -172,7 +172,7 @@ export const handleGetFirefoxPrefs = defineToolHandler( } const driver = firefox.getDriver(); - const chromeContextId = contexts[0].context; + const chromeContextId = contexts[0]!.context; // Remember current context const originalContextId = firefox.getCurrentContextId(); diff --git a/src/tools/network.ts b/src/tools/network.ts index 91f0e501..86af2718 100644 --- a/src/tools/network.ts +++ b/src/tools/network.ts @@ -3,6 +3,9 @@ * Provides network request inspection capabilities */ +import type { Network } from 'webdriver-bidi-protocol'; +import type { FirefoxClient } from '../firefox/index.js'; +import { DataType } from '../firefox/bidi.js'; import { successResponse, errorResponse, @@ -162,14 +165,9 @@ export const setNetworkCacheTool = { * the whole tool call. Absent support degrades to an 'unsupported' marker. */ async function safeFetchBody( - firefox: { - getNetworkRequestBody?: ( - id: string, - dataType: 'request' | 'response' - ) => Promise; - }, + firefox: FirefoxClient, id: string, - dataType: 'request' | 'response' + dataType: Network.DataType ): Promise { if (typeof firefox.getNetworkRequestBody !== 'function') { return { ok: false, reason: 'unsupported' }; @@ -538,8 +536,8 @@ export const handleGetNetworkRequest = defineToolHandler( }; const [responseBodyResult, requestBodyResult] = await Promise.all([ - safeFetchBody(firefox, request.id, 'response'), - safeFetchBody(firefox, request.id, 'request'), + safeFetchBody(firefox, request.id, DataType.Response), + safeFetchBody(firefox, request.id, DataType.Request), ]); if (saveTo) { diff --git a/src/tools/pages.ts b/src/tools/pages.ts index 8b53020e..282c675e 100644 --- a/src/tools/pages.ts +++ b/src/tools/pages.ts @@ -2,9 +2,10 @@ * Page navigation and management tools for MCP */ +import type { BrowsingContext } from 'webdriver-bidi-protocol'; import { successResponse, previewExcerpt, truncationFooter } from '../utils/response-helpers.js'; import { saveOutput } from '../utils/save-output.js'; -import { READINESS_STATES, isReadinessState, type ReadinessState } from '../firefox/pages.js'; +import { READINESS_STATES, isReadinessState } from '../firefox/pages.js'; import { defineModule, defineToolHandler, @@ -33,7 +34,7 @@ const waitSchema = { * An unknown value is rejected rather than ignored: silently falling back to the * default would leave the caller believing it waited for something it did not. */ -function parseWait(value: unknown): ReadinessState | undefined { +function parseWait(value: unknown): BrowsingContext.ReadinessState | undefined { if (value === undefined || value === null) { return undefined; } @@ -46,7 +47,7 @@ function parseWait(value: unknown): ReadinessState | undefined { } /** Echo the readiness state back only when the caller asked for one. */ -function waitSuffix(wait: ReadinessState | undefined): string { +function waitSuffix(wait: BrowsingContext.ReadinessState | undefined): string { return wait ? ` (waited for: ${wait})` : ''; } diff --git a/src/tools/privileged-context.ts b/src/tools/privileged-context.ts index c20a7191..de46c678 100644 --- a/src/tools/privileged-context.ts +++ b/src/tools/privileged-context.ts @@ -177,11 +177,6 @@ export const handleSelectPrivilegedContext = defineToolHandler( } ); -const EvaluateResultType = { - Exception: 'exception', - Success: 'success', -}; - export const handleEvaluatePrivilegedScript = defineToolHandler( async (args: unknown): Promise => { const { @@ -214,7 +209,7 @@ export const handleEvaluatePrivilegedScript = defineToolHandler( target: { context }, }); - if (result.type === EvaluateResultType.Success) { + if (result.type === 'success') { // JSON.stringify returns undefined for an undefined script result const json = JSON.stringify(remoteValueToNative(result.result), null, 2) ?? 'undefined'; @@ -235,7 +230,7 @@ export const handleEvaluatePrivilegedScript = defineToolHandler( return successResponse( 'Script ran in chrome context and returned:\n```json\n' + json + '\n```' ); - } else if (result.type === EvaluateResultType.Exception) { + } else { const exceptionDetails = result.exceptionDetails; return errorResponse( new Error( @@ -245,8 +240,6 @@ export const handleEvaluatePrivilegedScript = defineToolHandler( '\n```' ) ); - } else { - return errorResponse(`Unexpected script.callFunction result type: ${result.type}`); } } ); diff --git a/src/tools/screencast.ts b/src/tools/screencast.ts index 8e214876..80e90d3d 100644 --- a/src/tools/screencast.ts +++ b/src/tools/screencast.ts @@ -1,3 +1,4 @@ +import type { BrowsingContext } from 'webdriver-bidi-protocol'; import { successResponse, errorResponse } from '../utils/response-helpers.js'; import { compareVersions } from '../utils/version.js'; import type { FirefoxDevTools } from '../firefox/index.js'; @@ -81,7 +82,7 @@ export const handleScreencastStart = defineToolHandler( throw new Error('No active browsing context to record'); } - const params: Record = { context: contextId }; + const params: BrowsingContext.StartScreencastParameters = { context: contextId }; const video: Record = {}; if (frameRate !== undefined) { @@ -156,7 +157,7 @@ export const handleScreencastStop = defineToolHandler( } const result = await firefox.sendBiDiCommand('browsingContext.stopScreencast', { - screencast: screencastId, + screencast: screencastId!, }); activeRecordings.delete(screencastId as string); diff --git a/src/tools/script.ts b/src/tools/script.ts index 34cc490f..690c2564 100644 --- a/src/tools/script.ts +++ b/src/tools/script.ts @@ -2,6 +2,7 @@ * JavaScript evaluation tool */ +import type { Script } from 'webdriver-bidi-protocol'; import { successResponse, errorResponse, previewExcerpt } from '../utils/response-helpers.js'; import { remoteValueToNative } from '../utils/remote-value.js'; import { validateFunction } from '../utils/js-validation.js'; @@ -65,12 +66,6 @@ export const evaluateScriptTool = { const DEFAULT_TIMEOUT = 5000; // 5 seconds const TIMEOUT = Symbol('Timeout'); -// Types from the WebDriver BiDi specification. -const EvaluateResultType = { - Exception: 'exception', - Success: 'success', -}; - export const handleEvaluateScript = defineToolHandler( async (args: unknown): Promise => { const { @@ -98,7 +93,7 @@ export const handleEvaluateScript = defineToolHandler( const scriptTimeout = timeout ?? DEFAULT_TIMEOUT; // Prepare arguments: resolve UIDs to references shared ids if provided - const resolvedArgs: unknown[] = []; + const resolvedArgs: Script.LocalValue[] = []; if (fnArgs && fnArgs.length > 0) { for (const arg of fnArgs) { try { @@ -126,17 +121,24 @@ export const handleEvaluateScript = defineToolHandler( } // Execute with resolved args (empty array if no args) + const context = firefox.getCurrentContextId(); + if (!context) { + throw new Error('No active browsing context'); + } const callFunctionPromise = firefox.sendBiDiCommand('script.callFunction', { functionDeclaration: fnString, awaitPromise: true, arguments: resolvedArgs, - target: { context: firefox.getCurrentContextId(), ...(sandbox !== undefined && { sandbox }) }, + target: { + context, + ...(sandbox !== undefined && { sandbox }), + }, }); // Race against timeout as WebDriver BiDi callFunction has no built-in // timeout feature. const result = await Promise.race([ - new Promise((r) => setTimeout(() => r(TIMEOUT), scriptTimeout)), + new Promise((r) => setTimeout(() => r(TIMEOUT), scriptTimeout)), callFunctionPromise, ]); @@ -148,7 +150,7 @@ export const handleEvaluateScript = defineToolHandler( 'Try simplifying the script or increasing the timeout parameter.' ) ); - } else if (result.type === EvaluateResultType.Success) { + } else if (result.type === 'success') { // JSON.stringify returns undefined for an undefined script result const json = JSON.stringify(remoteValueToNative(result.result), null, 2) ?? 'undefined'; @@ -167,7 +169,7 @@ export const handleEvaluateScript = defineToolHandler( } return successResponse('Script ran on page and returned:\n```json\n' + json + '\n```'); - } else if (result.type === EvaluateResultType.Exception) { + } else { const exceptionDetails = result.exceptionDetails; return errorResponse( new Error( @@ -177,8 +179,6 @@ export const handleEvaluateScript = defineToolHandler( '\n```' ) ); - } else { - return errorResponse(`Unexpected script.callFunction result type: ${result.type}`); } } ); diff --git a/src/tools/webextension.ts b/src/tools/webextension.ts index c3cc5538..13db543d 100644 --- a/src/tools/webextension.ts +++ b/src/tools/webextension.ts @@ -9,6 +9,8 @@ * Note: list_extensions requires MOZ_REMOTE_ALLOW_SYSTEM_ACCESS=1 */ +import type { WebExtension } from 'webdriver-bidi-protocol'; +import type { FirefoxCommands } from '../firefox/bidi.js'; import { successResponse } from '../utils/response-helpers.js'; import { defineModule, defineToolHandler, type ToolDefinition } from './module.js'; import type { McpToolResponse } from '../types/common.js'; @@ -65,27 +67,24 @@ export const handleInstallExtension = defineToolHandler( } // Validate required fields based on type - if ((type === 'archivePath' || type === 'path') && !path) { - throw new Error(`path parameter is required for type "${type}"`); - } - if (type === 'base64' && !value) { - throw new Error('value parameter is required for type "base64"'); + let extensionData!: WebExtension.ExtensionData; + if (type === 'archivePath' || type === 'path') { + if (!path) { + throw new Error(`path parameter is required for type "${type}"`); + } + extensionData = { type, path }; + } else { + if (!value) { + throw new Error('value parameter is required for type "base64"'); + } + extensionData = { type, value }; } const { getFirefox } = await import('../index.js'); const firefox = await getFirefox(); - // Build extensionData parameter - const extensionData: Record = { type }; - if (path) { - extensionData.path = path; - } - if (value) { - extensionData.value = value; - } - // Build BiDi command parameters - const params: Record = { extensionData }; + const params: FirefoxCommands['webExtension.install']['params'] = { extensionData }; if (permanent !== undefined) { params['moz:permanent'] = permanent; } @@ -252,7 +251,7 @@ export const handleListExtensions = defineToolHandler( } const driver = firefox.getDriver(); - const chromeContextId = contexts[0].context; + const chromeContextId = contexts[0]!.context; const originalContextId = firefox.getCurrentContextId(); try { diff --git a/tests/firefox/bidi.test.ts b/tests/firefox/bidi.test.ts index 8ebfc1ad..1491f20c 100644 --- a/tests/firefox/bidi.test.ts +++ b/tests/firefox/bidi.test.ts @@ -34,7 +34,7 @@ describe('BidiFacade sendCommand WebSocket readiness', () => { const bidi = new BiDiFacade(driver as any); // Start the command (don't await yet) - const commandPromise = bidi.sendCommand('test.method', { foo: 'bar' }); + const commandPromise = bidi.sendCommand('session.new', { capabilities: {} }); // Give the async code a tick to execute await new Promise((resolve) => setTimeout(resolve, 10)); @@ -56,7 +56,7 @@ describe('BidiFacade sendCommand WebSocket readiness', () => { // ASSERT: send() should now have been called expect(mockSend).toHaveBeenCalledTimes(1); - expect(mockSend).toHaveBeenCalledWith(expect.stringContaining('"method":"test.method"')); + expect(mockSend).toHaveBeenCalledWith(expect.stringContaining('"method":"session.new"')); // Simulate response to complete the promise if (eventListeners['message']) { diff --git a/tests/firefox/pages.test.ts b/tests/firefox/pages.test.ts index 8cc5faa4..50592a45 100644 --- a/tests/firefox/pages.test.ts +++ b/tests/firefox/pages.test.ts @@ -7,6 +7,7 @@ */ import { describe, it, expect, vi } from 'vitest'; +import { ReadinessState } from '@/firefox/bidi.js'; import { isCommonScheme, isReadinessState, @@ -153,7 +154,7 @@ describe('PageManagement', () => { it('honours an explicit wait for common schemes', async () => { const { pages, sendBiDiCommand } = createMocks(); - await pages.navigate(HTTPS_URL, 'complete'); + await pages.navigate(HTTPS_URL, ReadinessState.Complete); expect(sendBiDiCommand).toHaveBeenCalledWith('browsingContext.navigate', { context: 'ctx-1', url: HTTPS_URL, @@ -164,14 +165,14 @@ describe('PageManagement', () => { it('honours an explicit wait for uncommon schemes rather than downgrading it', async () => { const { pages, sendBiDiCommand } = createMocks(); - await pages.navigate(MOZ_EXT_URL, 'complete'); + await pages.navigate(MOZ_EXT_URL, ReadinessState.Complete); expect(sendBiDiCommand).toHaveBeenCalledWith('browsingContext.navigate', { context: 'ctx-1', url: MOZ_EXT_URL, wait: 'complete', }); - await pages.navigate('about:blank', 'interactive'); + await pages.navigate('about:blank', ReadinessState.Interactive); expect(sendBiDiCommand).toHaveBeenCalledWith('browsingContext.navigate', { context: 'ctx-1', url: 'about:blank', @@ -182,7 +183,7 @@ describe('PageManagement', () => { it('allows an explicit wait to opt out of waiting on a common scheme', async () => { const { pages, sendBiDiCommand } = createMocks(); - await pages.navigate(HTTPS_URL, 'none'); + await pages.navigate(HTTPS_URL, ReadinessState.None); expect(sendBiDiCommand).toHaveBeenCalledWith('browsingContext.navigate', { context: 'ctx-1', url: HTTPS_URL, @@ -262,7 +263,7 @@ describe('PageManagement', () => { sendBiDiCommand ); - await pages.createNewPage(HTTPS_URL, 'complete'); + await pages.createNewPage(HTTPS_URL, ReadinessState.Complete); expect(sendBiDiCommand).toHaveBeenCalledWith('browsingContext.navigate', { context: 'handle-2', url: HTTPS_URL, diff --git a/tsconfig.json b/tsconfig.json index e6f21b98..9cdff3c6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,7 +28,8 @@ "allowUnreachableCode": false, "exactOptionalPropertyTypes": true, "noImplicitOverride": true, - "useUnknownInCatchVariables": true + "useUnknownInCatchVariables": true, + "isolatedModules": true }, "include": [ "src/**/*"