diff --git a/docs/testing.md b/docs/testing.md index 2247758b..a04b8b92 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -12,7 +12,7 @@ release and demo tooling only. Pick the kind of test from where the logic under test lives. -1. **MCP harness** (`tests/integration/mcp/`, `startMcpServer` from +1. **MCP harness** (`tests/integration/mcp/`, `mcpTest` from `tests/helpers/mcp.ts`). Starts the server in a child process and calls tools through an MCP client. For logic in tool handlers and in the server lifecycle (start, reconnect, restart, close). One file per tool module, plus diff --git a/src/firefox/pages.ts b/src/firefox/pages.ts index e4549e5e..3dcb6cc6 100644 --- a/src/firefox/pages.ts +++ b/src/firefox/pages.ts @@ -164,12 +164,17 @@ export class PageManagement { * @param promptText - Optional text to enter in prompt dialog */ async acceptDialog(promptText?: string): Promise { + const contextId = this.getCurrentContextId(); + if (!contextId) { + throw new Error(`Cannot accept dialog: no browsing context ID`); + } + try { - const alert = await this.driver.switchTo().alert(); - if (promptText !== undefined) { - await alert.sendKeys(promptText); - } - await alert.accept(); + await this.sendBiDiCommand('browsingContext.handleUserPrompt', { + context: contextId, + accept: true, + userText: promptText, + }); } catch (error) { throw new Error( `Failed to accept dialog: ${error instanceof Error ? error.message : String(error)}` @@ -181,9 +186,16 @@ export class PageManagement { * Dismiss dialog (alert/confirm/prompt) */ async dismissDialog(): Promise { + const contextId = this.getCurrentContextId(); + if (!contextId) { + throw new Error(`Cannot dismiss dialog: no browsing context ID`); + } + try { - const alert = await this.driver.switchTo().alert(); - await alert.dismiss(); + await this.sendBiDiCommand('browsingContext.handleUserPrompt', { + context: contextId, + accept: false, + }); } catch (error) { throw new Error( `Failed to dismiss dialog: ${error instanceof Error ? error.message : String(error)}` diff --git a/src/tools/utilities.ts b/src/tools/utilities.ts index dad169e0..0bca7467 100644 --- a/src/tools/utilities.ts +++ b/src/tools/utilities.ts @@ -87,19 +87,8 @@ export const handleAcceptDialog = defineToolHandler( const { getFirefox } = await import('../index.js'); const firefox = await getFirefox(); - try { - await firefox.acceptDialog(promptText); - return successResponse(promptText ? `Accepted: "${promptText}"` : 'Accepted'); - } catch (error) { - const errorMsg = (error as Error).message; - - // Concise error for no active dialog - if (errorMsg.includes('no such alert') || errorMsg.includes('No dialog')) { - throw new Error('No active dialog'); - } - - throw error; - } + await firefox.acceptDialog(promptText); + return successResponse(promptText ? `Accepted: "${promptText}"` : 'Accepted'); } ); @@ -108,19 +97,8 @@ export const handleDismissDialog = defineToolHandler( const { getFirefox } = await import('../index.js'); const firefox = await getFirefox(); - try { - await firefox.dismissDialog(); - return successResponse('Dismissed'); - } catch (error) { - const errorMsg = (error as Error).message; - - // Concise error for no active dialog - if (errorMsg.includes('no such alert') || errorMsg.includes('No dialog')) { - throw new Error('No active dialog'); - } - - throw error; - } + await firefox.dismissDialog(); + return successResponse('Dismissed'); } ); diff --git a/tests/helpers/mcp.ts b/tests/helpers/mcp.ts index 21fcd8c6..1793c7cc 100644 --- a/tests/helpers/mcp.ts +++ b/tests/helpers/mcp.ts @@ -10,6 +10,7 @@ import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { test } from 'vitest'; import { removeDir } from './fs.js'; import type { ToolArgsOf } from '@/types/tool-args.js'; import type { MODULES } from '@/tools/index.js'; @@ -88,3 +89,11 @@ export async function startMcpServer(serverArgs: string[] = []): Promise { + const server = await startMcpServer(mcpServerArgs); + onCleanup(() => server.close()); + return server; + }); diff --git a/tests/integration/mcp/script.test.ts b/tests/integration/mcp/script.test.ts index e725af47..fe128774 100644 --- a/tests/integration/mcp/script.test.ts +++ b/tests/integration/mcp/script.test.ts @@ -2,37 +2,22 @@ * Integration tests for evaluate_script, called through the MCP server */ -import { describe, it, expect, assert, beforeAll, afterAll, beforeEach } from 'vitest'; +import { expect, assert } from 'vitest'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fixtureUrl } from '../../helpers/firefox.js'; -import { startMcpServer, type McpTestServer } from '../../helpers/mcp.js'; +import { mcpTest, type McpTestServer } from '../../helpers/mcp.js'; -describe('evaluate_script (MCP)', () => { - let server: McpTestServer | undefined; +mcpTest.override('mcpServerArgs', ['--tools', 'pages', 'snapshot', 'script']); - function mcp(): McpTestServer { - if (!server) { - throw new Error('MCP server did not start'); - } - return server; - } - - beforeAll(async () => { - server = await startMcpServer(['--tools', 'pages', 'snapshot', 'script']); - }); - - afterAll(async () => { - await server?.close(); - }); - - beforeEach(async () => { - const result = await mcp().callTool('navigate_page', { url: fixtureUrl('simple.html') }); +mcpTest.describe('evaluate_script (MCP)', () => { + mcpTest.beforeEach(async ({ mcp }) => { + const result = await mcp.callTool('navigate_page', { url: fixtureUrl('simple.html') }); expect(result.isError).toBe(false); }); - it('returns the value of the function', async () => { - const result = await mcp().callTool('evaluate_script', { + mcpTest('returns the value of the function', async ({ mcp }) => { + const result = await mcp.callTool('evaluate_script', { function: '() => ({ title: document.title, count: 1 + 1 })', }); @@ -42,8 +27,8 @@ describe('evaluate_script (MCP)', () => { expect(result.text).toContain('"count": 2'); }); - it('awaits a returned promise', async () => { - const result = await mcp().callTool('evaluate_script', { + mcpTest('awaits a returned promise', async ({ mcp }) => { + const result = await mcp.callTool('evaluate_script', { function: '() => new Promise((r) => setTimeout(() => r("done"), 50))', }); @@ -51,12 +36,12 @@ describe('evaluate_script (MCP)', () => { expect(result.text).toContain('"done"'); }); - it('passes snapshot UIDs as element arguments', async () => { - const snapshot = await mcp().callTool('take_snapshot'); + mcpTest('passes snapshot UIDs as element arguments', async ({ mcp }) => { + const snapshot = await mcp.callTool('take_snapshot'); const uid = /uid=(\S+) button "Click Me"/.exec(snapshot.text)?.[1]; assert(uid, 'snapshot contains the button uid'); - const result = await mcp().callTool('evaluate_script', { + const result = await mcp.callTool('evaluate_script', { function: '(el) => el.id', args: [{ uid }], }); @@ -65,9 +50,9 @@ describe('evaluate_script (MCP)', () => { expect(result.text).toContain('"clickBtn"'); }); - it('reports an unknown UID', async () => { - await mcp().callTool('take_snapshot'); - const result = await mcp().callTool('evaluate_script', { + mcpTest('reports an unknown UID', async ({ mcp }) => { + await mcp.callTool('take_snapshot'); + const result = await mcp.callTool('evaluate_script', { function: '(el) => el.id', args: [{ uid: 'does-not-exist' }], }); @@ -76,8 +61,8 @@ describe('evaluate_script (MCP)', () => { expect(result.text).toContain('does-not-exist'); }); - it('reports an exception thrown by the function', async () => { - const result = await mcp().callTool('evaluate_script', { + mcpTest('reports an exception thrown by the function', async ({ mcp }) => { + const result = await mcp.callTool('evaluate_script', { function: '() => { throw new Error("failure in page"); }', }); @@ -86,14 +71,14 @@ describe('evaluate_script (MCP)', () => { expect(result.text).toContain('failure in page'); }); - it('rejects a plain expression', async () => { - const result = await mcp().callTool('evaluate_script', { function: 'document.title' }); + mcpTest('rejects a plain expression', async ({ mcp }) => { + const result = await mcp.callTool('evaluate_script', { function: 'document.title' }); expect(result.isError).toBe(true); }); - it('times out a function that never settles', async () => { - const result = await mcp().callTool('evaluate_script', { + mcpTest('times out a function that never settles', async ({ mcp }) => { + const result = await mcp.callTool('evaluate_script', { function: '() => new Promise(() => {})', timeout: 200, }); @@ -102,14 +87,14 @@ describe('evaluate_script (MCP)', () => { expect(result.text).toContain('timed out (exceeded 200ms)'); }); - describe('sandbox', () => { - it('does not see page globals', async () => { - await mcp().callTool('evaluate_script', { function: '() => { window.pageValue = 42; }' }); + mcpTest.describe('sandbox', () => { + mcpTest('does not see page globals', async ({ mcp }) => { + await mcp.callTool('evaluate_script', { function: '() => { window.pageValue = 42; }' }); - const page = await mcp().callTool('evaluate_script', { + const page = await mcp.callTool('evaluate_script', { function: '() => typeof window.pageValue', }); - const sandboxed = await mcp().callTool('evaluate_script', { + const sandboxed = await mcp.callTool('evaluate_script', { function: '() => typeof window.pageValue', sandbox: 'isolated', }); @@ -118,8 +103,8 @@ describe('evaluate_script (MCP)', () => { expect(sandboxed.text).toContain('"undefined"'); }); - it('shares the page DOM', async () => { - const result = await mcp().callTool('evaluate_script', { + mcpTest('shares the page DOM', async ({ mcp }) => { + const result = await mcp.callTool('evaluate_script', { function: '() => document.getElementById("title").textContent', sandbox: 'isolated', }); @@ -128,17 +113,17 @@ describe('evaluate_script (MCP)', () => { expect(result.text).toContain('"Simple Test Page"'); }); - it('keeps its state across calls with the same name', async () => { - await mcp().callTool('evaluate_script', { + mcpTest('keeps its state across calls with the same name', async ({ mcp }) => { + await mcp.callTool('evaluate_script', { function: '() => { window.sandboxValue = "kept"; }', sandbox: 'first', }); - const same = await mcp().callTool('evaluate_script', { + const same = await mcp.callTool('evaluate_script', { function: '() => window.sandboxValue ?? null', sandbox: 'first', }); - const other = await mcp().callTool('evaluate_script', { + const other = await mcp.callTool('evaluate_script', { function: '() => window.sandboxValue ?? null', sandbox: 'second', }); @@ -148,14 +133,14 @@ describe('evaluate_script (MCP)', () => { }); }); - describe('saveTo', () => { - function outputDir(): string { - return join(mcp().home, '.firefox-mcp', 'output'); + mcpTest.describe('saveTo', () => { + function outputDir(mcp: McpTestServer): string { + return join(mcp.home, '.firefox-mcp', 'output'); } - it('writes the result to the given file without a preview', async () => { - const filePath = join(outputDir(), 'result.json'); - const result = await mcp().callTool('evaluate_script', { + mcpTest('writes the result to the given file without a preview', async ({ mcp }) => { + const filePath = join(outputDir(mcp), 'result.json'); + const result = await mcp.callTool('evaluate_script', { function: '() => "x".repeat(5000)', saveTo: filePath, }); @@ -166,10 +151,10 @@ describe('evaluate_script (MCP)', () => { expect(JSON.parse(readFileSync(filePath, 'utf8'))).toBe('x'.repeat(5000)); }); - it('returns a truncated preview when asked', async () => { - const result = await mcp().callTool('evaluate_script', { + mcpTest('returns a truncated preview when asked', async ({ mcp }) => { + const result = await mcp.callTool('evaluate_script', { function: '() => "x".repeat(5000)', - saveTo: join(outputDir(), 'preview.json'), + saveTo: join(outputDir(mcp), 'preview.json'), preview: 100, }); @@ -179,10 +164,10 @@ describe('evaluate_script (MCP)', () => { expect(result.text).not.toContain('x'.repeat(5000)); }); - it('ignores a non-positive preview', async () => { - const result = await mcp().callTool('evaluate_script', { + mcpTest('ignores a non-positive preview', async ({ mcp }) => { + const result = await mcp.callTool('evaluate_script', { function: '() => 1', - saveTo: join(outputDir(), 'no-preview.json'), + saveTo: join(outputDir(mcp), 'no-preview.json'), preview: -1, }); @@ -190,9 +175,9 @@ describe('evaluate_script (MCP)', () => { expect(result.text).not.toContain('Preview:'); }); - it('writes "undefined" when the function returns nothing', async () => { - const filePath = join(outputDir(), 'undefined.json'); - const result = await mcp().callTool('evaluate_script', { + mcpTest('writes "undefined" when the function returns nothing', async ({ mcp }) => { + const filePath = join(outputDir(mcp), 'undefined.json'); + const result = await mcp.callTool('evaluate_script', { function: '() => undefined', saveTo: filePath, }); @@ -201,15 +186,15 @@ describe('evaluate_script (MCP)', () => { expect(readFileSync(filePath, 'utf8')).toBe('undefined'); }); - it('generates a file in the output directory when saveTo is true', async () => { - const result = await mcp().callTool('evaluate_script', { + mcpTest('generates a file in the output directory when saveTo is true', async ({ mcp }) => { + const result = await mcp.callTool('evaluate_script', { function: '() => 1', saveTo: true, }); expect(result.isError).toBe(false); const savedPath = /Result saved to: (.+) \(/.exec(result.text)?.[1]; - expect(savedPath).toContain(join(outputDir(), 'evaluate-script-')); + expect(savedPath).toContain(join(outputDir(mcp), 'evaluate-script-')); expect(existsSync(savedPath as string)).toBe(true); }); }); diff --git a/tests/integration/mcp/webextension.test.ts b/tests/integration/mcp/webextension.test.ts index ca76f302..958086db 100644 --- a/tests/integration/mcp/webextension.test.ts +++ b/tests/integration/mcp/webextension.test.ts @@ -2,13 +2,15 @@ * Integration tests for list_extensions, called through the MCP server */ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { expect } from 'vitest'; import { existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { zipSync } from 'fflate'; import { compareVersions } from '@/utils/version.js'; import { fixturesPath } from '../../helpers/firefox.js'; -import { startMcpServer, type McpTestServer } from '../../helpers/mcp.js'; +import { mcpTest } from '../../helpers/mcp.js'; + +mcpTest.override('mcpServerArgs', ['--tools', 'webextension', 'management']); const extensionDir = resolve(fixturesPath, 'test-extension'); // Own filename: moz-extension.test.ts packs the same fixture as test-extension.xpi @@ -22,34 +24,25 @@ function packExtension(): void { writeFileSync(xpiPath, zipSync(entries)); } -describe('list_extensions (MCP)', () => { - let server: McpTestServer | undefined; +mcpTest.describe('list_extensions (MCP)', () => { let extensionId = ''; let listExtensionsSupported = false; - function mcp(): McpTestServer { - if (!server) { - throw new Error('MCP server did not start'); - } - return server; - } - - beforeAll(async () => { + mcpTest.beforeAll(async ({ mcp }) => { packExtension(); - server = await startMcpServer(['--tools', 'webextension', 'management']); // webExtension.moz:listExtensions needs Firefox 158 - const info = await mcp().callTool('get_firefox_info'); + const info = await mcp.callTool('get_firefox_info'); const version = /Firefox version: (\d+)/.exec(info.text)?.[1]; listExtensionsSupported = version !== undefined && compareVersions(version, '158.0') >= 0; if (!listExtensionsSupported) { return; } - const empty = await mcp().callTool('list_extensions'); + const empty = await mcp.callTool('list_extensions'); expect(empty.text).toBe('No extensions installed'); - const installed = await mcp().callTool('install_extension', { + const installed = await mcp.callTool('install_extension', { type: 'archivePath', path: xpiPath, }); @@ -58,28 +51,27 @@ describe('list_extensions (MCP)', () => { expect(extensionId).toBeTruthy(); }, 60000); - afterAll(async () => { + mcpTest.afterAll(async ({ mcp }) => { try { - if (server && extensionId) { - await server.callTool('uninstall_extension', { id: extensionId }); + if (extensionId) { + await mcp.callTool('uninstall_extension', { id: extensionId }); } } finally { - await server?.close(); if (existsSync(xpiPath)) { unlinkSync(xpiPath); } } }); - beforeEach((ctx) => { + mcpTest.beforeEach(({ skip }) => { if (!listExtensionsSupported) { console.warn('Skipping list_extensions tests: requires Firefox 158 or later'); - ctx.skip(); + skip(); } }); - it('formats the extension list', async () => { - const result = await mcp().callTool('list_extensions', { ids: [extensionId] }); + mcpTest('formats the extension list', async ({ mcp }) => { + const result = await mcp.callTool('list_extensions', { ids: [extensionId] }); expect(result.isError).toBe(false); expect(result.text).toContain('1 extension(s)'); @@ -92,16 +84,16 @@ describe('list_extensions (MCP)', () => { expect(result.text).toContain('Background scripts: (none)'); }); - it('reports policy fields omitted without system access', async () => { - const result = await mcp().callTool('list_extensions', { ids: [extensionId] }); + mcpTest('reports policy fields omitted without system access', async ({ mcp }) => { + const result = await mcp.callTool('list_extensions', { ids: [extensionId] }); expect(result.isError).toBe(false); expect(result.text).toContain('UUID: `uuid` omitted due to restricted system access.'); expect(result.text).toContain('Base URL: `baseURL` omitted due to restricted system access.'); }); - it('describes the filters when nothing matches', async () => { - const result = await mcp().callTool('list_extensions', { + mcpTest('describes the filters when nothing matches', async ({ mcp }) => { + const result = await mcp.callTool('list_extensions', { ids: [extensionId], name: 'no-such-extension', isActive: true,