diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 8aa274f0d..cab31d9ea 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -41,6 +41,12 @@ jobs: shell: bash run: npm ci + - name: Install Chrome Canary + shell: bash + run: | + CANARY_PATH=$(npx @puppeteer/browsers install chrome@canary --format "{{path}}") + echo "CANARY_EXECUTABLE_PATH=$CANARY_PATH" >> $GITHUB_ENV + - name: Build run: npm run bundle env: diff --git a/src/McpContext.ts b/src/McpContext.ts index 8cb44f64b..1cdc4b35f 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -919,6 +919,24 @@ export class McpContext implements Context { this.#extensionRegistry.remove(id); } + async triggerExtensionAction(id: string): Promise { + const page = this.getSelectedPptrPage(); + // @ts-expect-error internal puppeteer api is needed since we don't have a way to get + // a tab id at the moment + const theTarget = page._tabId; + const session = await this.browser.target().createCDPSession(); + + try { + // @ts-expect-error triggerAction is not yet available + await session.send('Extensions.triggerAction', { + id, + targetId: theTarget, + }); + } finally { + await session.detach(); + } + } + listExtensions(): InstalledExtension[] { return this.#extensionRegistry.list(); } diff --git a/src/McpResponse.ts b/src/McpResponse.ts index a4514767f..e87ebac04 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -595,7 +595,7 @@ Call ${handleDialog.name} to handle it before continuing.`); } if (this.#includeExtensionServiceWorkers) { - if (!context.getExtensionServiceWorkers().length) { + if (context.getExtensionServiceWorkers().length) { response.push(`## Extension Service Workers`); } diff --git a/src/tools/ToolDefinition.ts b/src/tools/ToolDefinition.ts index 5230a4db3..345aa763d 100644 --- a/src/tools/ToolDefinition.ts +++ b/src/tools/ToolDefinition.ts @@ -190,6 +190,7 @@ export type Context = Readonly<{ ): void; installExtension(path: string): Promise; uninstallExtension(id: string): Promise; + triggerExtensionAction(id: string): Promise; listExtensions(): InstalledExtension[]; getExtension(id: string): InstalledExtension | undefined; getSelectedMcpPage(): McpPage; diff --git a/src/tools/extensions.ts b/src/tools/extensions.ts index 0ab2d43ff..76cec7fb3 100644 --- a/src/tools/extensions.ts +++ b/src/tools/extensions.ts @@ -85,3 +85,21 @@ export const reloadExtension = defineTool({ response.appendResponseLine('Extension reloaded.'); }, }); + +export const triggerExtensionAction = defineTool({ + name: 'trigger_extension_action', + description: 'Triggers an action in a Chrome extension.', + annotations: { + category: ToolCategory.EXTENSIONS, + readOnlyHint: false, + conditions: [EXTENSIONS_CONDITION], + }, + schema: { + id: zod.string().describe('ID of the extension.'), + }, + handler: async (request, response, context) => { + const {id} = request.params; + await context.triggerExtensionAction(id); + response.appendResponseLine(`Extension action triggered. Id: ${id}`); + }, +}); diff --git a/tests/tools/extensions.test.ts b/tests/tools/extensions.test.ts index 58fa0a40b..61f85b903 100644 --- a/tests/tools/extensions.test.ts +++ b/tests/tools/extensions.test.ts @@ -10,29 +10,25 @@ import {afterEach, describe, it} from 'node:test'; import sinon from 'sinon'; -import type {McpResponse} from '../../src/McpResponse.js'; +import type {ParsedArguments} from '../../src/cli.js'; import { installExtension, uninstallExtension, listExtensions, reloadExtension, + triggerExtensionAction, } from '../../src/tools/extensions.js'; -import {withMcpContext} from '../utils.js'; +import {extractExtensionId, withMcpContext} from '../utils.js'; +const EXTENSION_WITH_SW_PATH = path.join( + import.meta.dirname, + '../../../tests/tools/fixtures/extension-sw', +); const EXTENSION_PATH = path.join( import.meta.dirname, '../../../tests/tools/fixtures/extension', ); -export function extractId(response: McpResponse) { - const responseLine = response.responseLines[0]; - assert.ok(responseLine, 'Response should not be empty'); - const match = responseLine.match(/Extension installed\. Id: (.+)/); - const extensionId = match ? match[1] : null; - assert.ok(extensionId, 'Response should contain a valid key'); - return extensionId; -} - describe('extension', () => { afterEach(() => { sinon.restore(); @@ -47,7 +43,7 @@ describe('extension', () => { context, ); - const extensionId = extractId(response); + const extensionId = extractExtensionId(response); const page = context.getSelectedPptrPage(); await page.goto('chrome://extensions'); @@ -102,7 +98,7 @@ describe('extension', () => { context, ); - const extensionId = extractId(response); + const extensionId = extractExtensionId(response); const installSpy = sinon.spy(context, 'installExtension'); response.resetResponseLineForTesting(); @@ -128,4 +124,36 @@ describe('extension', () => { assert.ok(reinstalled, 'Extension should be present after reload'); }); }); + it('triggers an extension action', async () => { + await withMcpContext( + async (response, context) => { + const extensionId = await context.installExtension( + EXTENSION_WITH_SW_PATH, + ); + + const targetsBefore = context.browser.targets(); + const pageTargetBefore = targetsBefore.find( + t => t.type() === 'page' && t.url().includes(extensionId), + ); + assert.ok(!pageTargetBefore, 'Page should not exist before action'); + + await triggerExtensionAction.handler( + {params: {id: extensionId}}, + response, + context, + ); + + const pageTargetAfter = await context.browser.waitForTarget( + t => t.type() === 'page' && t.url().includes(extensionId), + ); + assert.ok(pageTargetAfter, 'Page should exist after action'); + }, + { + executablePath: process.env.CANARY_EXECUTABLE_PATH, + }, + { + categoryExtensions: true, + } as ParsedArguments, + ); + }); }); diff --git a/tests/tools/fixtures/extension-sw/sw.js b/tests/tools/fixtures/extension-sw/sw.js index f44ddb739..b49093e40 100644 --- a/tests/tools/fixtures/extension-sw/sw.js +++ b/tests/tools/fixtures/extension-sw/sw.js @@ -1 +1,3 @@ -console.log('Service worker loaded'); +chrome.action.onClicked.addListener(tab => { + console.log('Action clicked'); +}); diff --git a/tests/tools/script.test.ts b/tests/tools/script.test.ts index 90c1b34b3..fa7977e4b 100644 --- a/tests/tools/script.test.ts +++ b/tests/tools/script.test.ts @@ -12,9 +12,7 @@ import type {ParsedArguments} from '../../src/cli.js'; import {installExtension} from '../../src/tools/extensions.js'; import {evaluateScript} from '../../src/tools/script.js'; import {serverHooks} from '../server.js'; -import {html, withMcpContext} from '../utils.js'; - -import {extractId} from './extensions.test.js'; +import {extractExtensionId, html, withMcpContext} from '../utils.js'; const EXTENSION_PATH = path.join( import.meta.dirname, @@ -209,7 +207,7 @@ describe('script', () => { context, ); - const extensionId = extractId(response); + const extensionId = extractExtensionId(response); const swTarget = await context.browser.waitForTarget( t => t.type() === 'service_worker' && t.url().includes(extensionId), ); diff --git a/tests/utils.ts b/tests/utils.ts index d26dc309b..ac88f9398 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import assert from 'node:assert'; + import type {CallToolResult} from '@modelcontextprotocol/sdk/types.js'; import logger from 'debug'; import type {Browser} from 'puppeteer'; @@ -42,15 +44,29 @@ export function getImageContent(content: CallToolResult['content'][number]): { throw new Error(`Expected image content but got ${content.type}`); } +export function extractExtensionId(response: McpResponse) { + const responseLine = response.responseLines[0]; + assert.ok(responseLine, 'Response should not be empty'); + const match = responseLine.match(/Extension installed\. Id: (.+)/); + const extensionId = match ? match[1] : null; + assert.ok(extensionId, 'Response should contain a valid key'); + return extensionId; +} + const browsers = new Map(); let context: McpContext | undefined; export async function withBrowser( cb: (browser: Browser, page: Page) => Promise, - options: {debug?: boolean; autoOpenDevTools?: boolean} = {}, + options: { + debug?: boolean; + autoOpenDevTools?: boolean; + executablePath?: string; + } = {}, ) { const launchOptions: LaunchOptions = { - executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, + executablePath: + options.executablePath ?? process.env.PUPPETEER_EXECUTABLE_PATH, headless: !options.debug, defaultViewport: null, devtools: options.autoOpenDevTools ?? false, @@ -85,6 +101,7 @@ export async function withMcpContext( debug?: boolean; autoOpenDevTools?: boolean; performanceCrux?: boolean; + executablePath?: string; } = {}, args: ParsedArguments = {} as ParsedArguments, ) {