Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
97 changes: 94 additions & 3 deletions src/firefox/bidi.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,104 @@
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<FirefoxEvent, { method: M }>['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<keyof typeof Network.DataType, Network.DataType>;

export class BiDiFacade extends EventEmitter<FirefoxEventMap> {
private listening = false;
private nextCommandId = 1;

constructor(private readonly driver: WebDriver) {
super();
}

async subscribe(events: string | string[]) {
async subscribe(events: FirefoxEventName | FirefoxEventName[]) {
const bidi = await this.driver.getBidi();
if (!this.listening) {
this.listenForEvents(bidi.socket);
Expand All @@ -19,7 +107,10 @@ export class BiDiFacade extends EventEmitter {
await bidi.subscribe(events);
}

async sendCommand(method: string, params: Record<string, any> = {}): Promise<any> {
async sendCommand<T extends keyof FirefoxCommands>(
method: T,
params: FirefoxCommands[T]['params'] = {}
): Promise<FirefoxCommands[T]['returnType']> {
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;
Expand Down
15 changes: 6 additions & 9 deletions src/firefox/cache.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
/**
* Network cache behaviour (WebDriver BiDi network.setCacheBehavior)
*/

export type BiDiCommandFn = (method: string, params: Record<string, any>) => Promise<any>;
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);
Expand All @@ -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<void> {
const params: Record<string, unknown> = { 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.
Expand Down
6 changes: 3 additions & 3 deletions src/firefox/events/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
12 changes: 3 additions & 9 deletions src/firefox/events/debugging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
}
Expand Down
24 changes: 13 additions & 11 deletions src/firefox/events/network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,7 +78,7 @@ export class NetworkEvents {
return;
}

const requestId = req.request?.request || req.requestId;
const requestId = req.request.request;

if (!requestId) {
return;
Expand All @@ -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',

@hbenl hbenl Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I switched from req.initiator.type to req.request.initiatorType because only that property has the values xmlhttprequest and fetch and req.initiator.type is deprecated.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah good catch, thanks!

requestHeaders: this.parseHeaders(req.request.headers),
timings: {
requestTime: Date.now(),
},
Expand All @@ -108,7 +110,7 @@ export class NetworkEvents {
return;
}

const requestId = resp.request?.request || resp.requestId;
const requestId = resp.request?.request;

if (!requestId) {
return;
Expand All @@ -128,7 +130,7 @@ export class NetworkEvents {
return;
}

const requestId = resp.request?.request || resp.requestId;
const requestId = resp.request?.request;

if (!requestId) {
return;
Expand Down Expand Up @@ -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;
Expand All @@ -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<NetworkBodyResult> {
async fetchBody(requestId: string, dataType: Network.DataType): Promise<NetworkBodyResult> {
if (!this.collectorId) {
return { ok: false, reason: 'unsupported' };
}
Expand Down
Loading