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
3 changes: 2 additions & 1 deletion src/firefox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ export class FirefoxClient {
this.pages = new PageManagement(
driver,
() => this.core.getCurrentContextId(),
(id: string) => this.core.setCurrentContextId(id)
(id: string) => this.core.setCurrentContextId(id),
(method: string, params: Record<string, any>) => this.core.sendBiDiCommand(method, params)
);

// Subscribe to console and network events for ALL contexts (not just current).
Expand Down
43 changes: 38 additions & 5 deletions src/firefox/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,53 @@
*/

import { WebDriver } from 'selenium-webdriver';
import { log } from '../utils/logger.js';
import { log, logDebug } from '../utils/logger.js';

const COMMON_URL_SCHEMES = ['http:', 'https:', 'data:', 'blob:', 'file:'];

/**
* Check if a URL uses a common scheme that supports load events.
* Non-common schemes = moz-extension:, about:, ...
*/
export function isCommonScheme(url: string): boolean {
try {
return COMMON_URL_SCHEMES.includes(new URL(url).protocol);
} catch {
return false;
}
}

export type BiDiCommandFn = (method: string, params: Record<string, any>) => Promise<any>;

export class PageManagement {
constructor(
private driver: WebDriver,
private getCurrentContextId: () => string | null,
private setCurrentContextId: (id: string) => void
private setCurrentContextId: (id: string) => void,
private sendBiDiCommand: BiDiCommandFn
) {}

/**
* Navigate to URL
* Navigate to URL using BiDi
*/
async navigate(url: string): Promise<void> {
await this.driver.get(url);
const contextId = this.getCurrentContextId();
if (!contextId) {
throw new Error(`Cannot navigate: no browsing context ID`);
}

// Default wait time is "interactive" (DOMContentLoaded).

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.

To be completely transparent here, maybe complete would be a more faithful translation of the current implementation. However BiDi is much more strict than Classic when it comes to navigation, so I prefer to stick with interactive for now.

// All uncommon schemes use wait time "none"
const wait = isCommonScheme(url) ? 'interactive' : 'none';

// Navigate using direct BiDi
await this.sendBiDiCommand('browsingContext.navigate', {
context: contextId,
url,
wait,
});

logDebug(`BiDi navigate (wait:${wait}) to: ${url}`);
log(`Navigated to: ${url}`);
}

Expand Down Expand Up @@ -159,7 +192,7 @@ export class PageManagement {
const newIdx = handles.length - 1;
this.setCurrentContextId(handles[newIdx]!);
this.cachedSelectedIdx = newIdx;
await this.driver.get(url);
await this.navigate(url);
return newIdx;
}

Expand Down
174 changes: 174 additions & 0 deletions tests/firefox/pages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* Unit tests for PageManagement and isCommonScheme
*
* Navigation uses BiDi browsingContext.navigate for all URLs.
* Common schemes (http/https/data/blob/file) use wait:"interactive".
* Uncommon schemes (moz-extension:, about:, etc.) use wait:"none"
*/

import { describe, it, expect, vi } from 'vitest';
import { isCommonScheme, PageManagement } from '@/firefox/pages.js';

const HTTPS_URL = 'https://example.com/test.html';
const HTTP_URL = 'http://example.com/test.html';
const FILE_URL = 'file:///some/path/test.html';
const MOZ_EXT_URL = 'moz-extension://a1b2c3d4-e5f6-7890-abcd-ef1234567890/popup.html';
const BLOB_URL = 'blob:https://example.org/40a5fb5a-d56d-4a33-b4e2-0acf6a8e5f64';
const DATA_URL = 'data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==';

describe('isCommonScheme', () => {
it('returns true for common URL schemes', () => {
expect(isCommonScheme(HTTPS_URL)).toBe(true);
expect(isCommonScheme(HTTP_URL)).toBe(true);
expect(isCommonScheme(FILE_URL)).toBe(true);
expect(isCommonScheme(DATA_URL)).toBe(true);
expect(isCommonScheme(BLOB_URL)).toBe(true);
});

it('returns false for moz-extension:// URLs', () => {
expect(isCommonScheme(MOZ_EXT_URL)).toBe(false);
});

it('returns false for about: URLs', () => {
expect(isCommonScheme('about:blank')).toBe(false);
});

it('returns false for malformed URLs', () => {
expect(isCommonScheme('not-a-url')).toBe(false);
});
});

// -- PageManagement -----------------------------------------------------------

describe('PageManagement', () => {
function createMocks() {
// Any driver method call should fail — navigate must use BiDi, not driver
const driver = new Proxy(
{},
{
get: () => {
throw new Error('Unexpected driver call — navigation must use BiDi');
},
}
);
const getCurrentContextId = vi.fn().mockReturnValue('ctx-1');
const setCurrentContextId = vi.fn();
const sendBiDiCommand = vi.fn().mockResolvedValue({});

const pages = new PageManagement(
driver,
getCurrentContextId,
setCurrentContextId,
sendBiDiCommand
);
return { pages, sendBiDiCommand };
}

describe('navigate', () => {
it('uses BiDi with wait:interactive for common URL schemes', async () => {
const { pages, sendBiDiCommand } = createMocks();

await pages.navigate(HTTPS_URL);
expect(sendBiDiCommand).toHaveBeenCalledWith('browsingContext.navigate', {
context: 'ctx-1',
url: HTTPS_URL,
wait: 'interactive',
});

await pages.navigate(HTTP_URL);
expect(sendBiDiCommand).toHaveBeenCalledWith('browsingContext.navigate', {
context: 'ctx-1',
url: HTTP_URL,
wait: 'interactive',
});

await pages.navigate(DATA_URL);
expect(sendBiDiCommand).toHaveBeenCalledWith('browsingContext.navigate', {
context: 'ctx-1',
url: DATA_URL,
wait: 'interactive',
});

await pages.navigate(FILE_URL);
expect(sendBiDiCommand).toHaveBeenCalledWith('browsingContext.navigate', {
context: 'ctx-1',
url: FILE_URL,
wait: 'interactive',
});

await pages.navigate(BLOB_URL);
expect(sendBiDiCommand).toHaveBeenCalledWith('browsingContext.navigate', {
context: 'ctx-1',
url: BLOB_URL,
wait: 'interactive',
});
});

it('uses BiDi with wait:none for uncommon URL schemes', async () => {
const { pages, sendBiDiCommand } = createMocks();

await pages.navigate(MOZ_EXT_URL);
expect(sendBiDiCommand).toHaveBeenCalledWith('browsingContext.navigate', {
context: 'ctx-1',
url: MOZ_EXT_URL,
wait: 'none',
});

await pages.navigate('about:blank');
expect(sendBiDiCommand).toHaveBeenCalledWith('browsingContext.navigate', {
context: 'ctx-1',
url: 'about:blank',
wait: 'none',
});

await pages.navigate('not-a-url');
expect(sendBiDiCommand).toHaveBeenCalledWith('browsingContext.navigate', {
context: 'ctx-1',
url: 'not-a-url',
wait: 'none',
});
});
});

describe('createNewPage', () => {
it('uses BiDi navigate', async () => {
// createNewPage needs real driver methods for switchTo/handles
const switchToMock = vi
.fn()
.mockReturnValue({ newWindow: vi.fn().mockResolvedValue(undefined) });
const getAllWindowHandlesMock = vi.fn().mockResolvedValue(['handle-1', 'handle-2']);

const driver = {
switchTo: switchToMock,
getAllWindowHandles: getAllWindowHandlesMock,
} as any;

const getCurrentContextId = vi.fn().mockReturnValue('handle-2');
const setCurrentContextId = vi.fn();
const sendBiDiCommand = vi.fn().mockResolvedValue({});

const pages = new PageManagement(
driver,
getCurrentContextId,
setCurrentContextId,
sendBiDiCommand
);

// wait: interactive
await pages.createNewPage(HTTPS_URL);
expect(sendBiDiCommand).toHaveBeenCalledWith('browsingContext.navigate', {
context: 'handle-2',
url: HTTPS_URL,
wait: 'interactive',
});

// wait: none
await pages.createNewPage(MOZ_EXT_URL);
expect(sendBiDiCommand).toHaveBeenCalledWith('browsingContext.navigate', {
context: 'handle-2',
url: MOZ_EXT_URL,
wait: 'none',
});
});
});
});
9 changes: 9 additions & 0 deletions tests/fixtures/test-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"manifest_version": 2,
"name": "MCP Test Extension",
"version": "1.0",
"description": "Minimal extension for testing moz-extension:// navigation",
"browser_action": {
"default_popup": "popup.html"
}
}
11 changes: 11 additions & 0 deletions tests/fixtures/test-extension/popup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>MCP Test Extension</title>
</head>
<body>
<h1>MCP Test Extension</h1>
<p id="status">extension-loaded</p>
</body>
</html>
Loading