diff --git a/src/McpContext.ts b/src/McpContext.ts index ff0aa1bfe..889103fab 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -109,6 +109,7 @@ export class McpContext implements Context { null; #nextPageId = 1; + #extensionPages = new WeakMap(); #extensionServiceWorkerMap = new WeakMap(); #nextExtensionServiceWorkerId = 1; @@ -589,6 +590,36 @@ export class McpContext implements Context { this.#options.experimentalIncludeAllPages, ); + const allTargets = this.browser.targets(); + const extensionTargets = allTargets.filter(target => { + return ( + target.url().startsWith('chrome-extension://') && + target.type() === 'page' + ); + }); + + for (const target of extensionTargets) { + // Right now target.page() returns null for popup and side panel pages. + let page = await target.page(); + if (!page) { + // We need to cache pages instances for targets because target.asPage() + // returns a new page instance every time. + page = this.#extensionPages.get(target) ?? null; + if (!page) { + try { + page = await target.asPage(); + this.#extensionPages.set(target, page); + } catch (e) { + this.logger('Failed to get page for extension target', e); + } + } + } + + if (page && !allPages.includes(page)) { + allPages.push(page); + } + } + // Build a reverse lookup from BrowserContext instance → name. const contextToName = new Map(); for (const [name, ctx] of this.#isolatedContexts) { diff --git a/src/McpResponse.ts b/src/McpResponse.ts index af4b1ee4c..b1b2cef30 100644 --- a/src/McpResponse.ts +++ b/src/McpResponse.ts @@ -16,6 +16,7 @@ import {DevTools} from './third_party/index.js'; import type { ConsoleMessage, ImageContent, + Page, ResourceType, TextContent, } from './third_party/index.js'; @@ -42,6 +43,7 @@ interface TraceInsightData { export class McpResponse implements Response { #includePages = false; #includeExtensionServiceWorkers = false; + #includeExtensionPages = false; #snapshotParams?: SnapshotParams; #attachedNetworkRequestId?: number; #attachedNetworkRequestOptions?: { @@ -94,6 +96,7 @@ export class McpResponse implements Response { if (this.#args.categoryExtensions) { this.#includeExtensionServiceWorkers = value; + this.#includeExtensionPages = value; } } @@ -501,6 +504,7 @@ export class McpResponse implements Response { pages?: object[]; pagination?: object; extensionServiceWorkers?: object[]; + extensionPages?: object[]; } = {}; const response = []; @@ -559,34 +563,54 @@ Call ${handleDialog.name} to handle it before continuing.`); } if (this.#includePages) { - const parts = [`## Pages`]; - for (const page of context.getPages()) { - const isolatedContextName = context.getIsolatedContextName(page); - const contextLabel = isolatedContextName - ? ` isolatedContext=${isolatedContextName}` - : ''; - parts.push( - `${context.getPageId(page)}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}${contextLabel}`, - ); + const allPages = context.getPages(); + + const {regularPages, extensionPages} = allPages.reduce( + (acc: {regularPages: Page[]; extensionPages: Page[]}, page: Page) => { + if (page.url().startsWith('chrome-extension://')) { + acc.extensionPages.push(page); + } else { + acc.regularPages.push(page); + } + return acc; + }, + {regularPages: [], extensionPages: []}, + ); + + if (regularPages.length) { + const parts = [`## Pages`]; + const structuredPages = []; + for (const page of regularPages) { + const isolatedContextName = context.getIsolatedContextName(page); + const contextLabel = isolatedContextName + ? ` isolatedContext=${isolatedContextName}` + : ''; + parts.push( + `${context.getPageId(page)}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}${contextLabel}`, + ); + structuredPages.push(createStructuredPage(page, context)); + } + response.push(...parts); + structuredContent.pages = structuredPages; } - response.push(...parts); - structuredContent.pages = context.getPages().map(page => { - const isolatedContextName = context.getIsolatedContextName(page); - const entry: { - id: number | undefined; - url: string; - selected: boolean; - isolatedContext?: string; - } = { - id: context.getPageId(page), - url: page.url(), - selected: context.isPageSelected(page), - }; - if (isolatedContextName) { - entry.isolatedContext = isolatedContextName; + + if (this.#includeExtensionPages) { + if (extensionPages.length) { + response.push(`## Extension Pages`); + const structuredExtensionPages = []; + for (const page of extensionPages) { + const isolatedContextName = context.getIsolatedContextName(page); + const contextLabel = isolatedContextName + ? ` isolatedContext=${isolatedContextName}` + : ''; + response.push( + `${context.getPageId(page)}: ${page.url()}${context.isPageSelected(page) ? ' [selected]' : ''}${contextLabel}`, + ); + structuredExtensionPages.push(createStructuredPage(page, context)); + } + structuredContent.extensionPages = structuredExtensionPages; } - return entry; - }); + } } if (this.#includeExtensionServiceWorkers) { @@ -803,3 +827,20 @@ Call ${handleDialog.name} to handle it before continuing.`); this.#textResponseLines = []; } } +function createStructuredPage(page: Page, context: McpContext) { + const isolatedContextName = context.getIsolatedContextName(page); + const entry: { + id: number | undefined; + url: string; + selected: boolean; + isolatedContext?: string; + } = { + id: context.getPageId(page), + url: page.url(), + selected: context.isPageSelected(page), + }; + if (isolatedContextName) { + entry.isolatedContext = isolatedContextName; + } + return entry; +} diff --git a/src/browser.ts b/src/browser.ts index 899a19a68..7deea75b4 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -20,12 +20,11 @@ import {puppeteer} from './third_party/index.js'; let browser: Browser | undefined; -function makeTargetFilter() { - const ignoredPrefixes = new Set([ - 'chrome://', - 'chrome-extension://', - 'chrome-untrusted://', - ]); +function makeTargetFilter(enableExtensions = false) { + const ignoredPrefixes = new Set(['chrome://', 'chrome-untrusted://']); + if (!enableExtensions) { + ignoredPrefixes.add('chrome-extension://'); + } return function targetFilter(target: Target): boolean { if (target.url() === 'chrome://newtab/') { @@ -51,14 +50,15 @@ export async function ensureBrowserConnected(options: { devtools: boolean; channel?: Channel; userDataDir?: string; + enableExtensions?: boolean; }) { - const {channel} = options; + const {channel, enableExtensions} = options; if (browser?.connected) { return browser; } const connectOptions: Parameters[0] = { - targetFilter: makeTargetFilter(), + targetFilter: makeTargetFilter(enableExtensions), defaultViewport: null, handleDevToolsAsPage: true, }; @@ -218,7 +218,7 @@ export async function launch(options: McpLaunchOptions): Promise { try { const browser = await puppeteer.launch({ channel: puppeteerChannel, - targetFilter: makeTargetFilter(), + targetFilter: makeTargetFilter(options.enableExtensions), executablePath, defaultViewport: null, userDataDir, diff --git a/src/server.ts b/src/server.ts index 991c2d292..45658a9c7 100644 --- a/src/server.ts +++ b/src/server.ts @@ -79,6 +79,7 @@ export async function createMcpServer( : undefined, userDataDir: serverArgs.userDataDir, devtools, + enableExtensions: serverArgs.categoryExtensions, }) : await ensureBrowserLaunched({ headless: serverArgs.headless, diff --git a/tests/tools/fixtures/extension-side-panel/manifest.json b/tests/tools/fixtures/extension-side-panel/manifest.json new file mode 100644 index 000000000..578045282 --- /dev/null +++ b/tests/tools/fixtures/extension-side-panel/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "Test Extension Side Panel", + "version": "1.0.0", + "manifest_version": 3, + "permissions": ["sidePanel"], + "action": { + "default_title": "Click to open panel" + }, + "background": { + "service_worker": "sw.js" + }, + "side_panel": { + "default_path": "sidepanel.html" + } +} diff --git a/tests/tools/fixtures/extension-side-panel/sidepanel.html b/tests/tools/fixtures/extension-side-panel/sidepanel.html new file mode 100644 index 000000000..00b42ab5c --- /dev/null +++ b/tests/tools/fixtures/extension-side-panel/sidepanel.html @@ -0,0 +1,9 @@ + + + + Side Panel + + +

Side Panel

+ + diff --git a/tests/tools/fixtures/extension-side-panel/sw.js b/tests/tools/fixtures/extension-side-panel/sw.js new file mode 100644 index 000000000..783d917f6 --- /dev/null +++ b/tests/tools/fixtures/extension-side-panel/sw.js @@ -0,0 +1,3 @@ +chrome.sidePanel + .setPanelBehavior({openPanelOnActionClick: true}) + .catch(console.error); diff --git a/tests/tools/pages.test.js.snapshot b/tests/tools/pages.test.js.snapshot new file mode 100644 index 000000000..278f3469a --- /dev/null +++ b/tests/tools/pages.test.js.snapshot @@ -0,0 +1,27 @@ +exports[`pages > list_pages > list pages for extension pages with --category-extensions 1`] = ` +## Pages +1: about:blank [selected] +## Extension Pages +2: chrome-extension:///popup.html +`; + +exports[`pages > list_pages > list pages for extension service workers with --category-extensions 1`] = ` +## Pages +1: about:blank [selected] +## Extension Service Workers +sw-1: chrome-extension:///sw.js +`; + +exports[`pages > list_pages > list pages for extension service workers without --category-extensions 1`] = ` +## Pages +1: about:blank [selected] +`; + +exports[`pages > list_pages > list pages for side panels with --category-extensions 1`] = ` +## Pages +1: about:blank +## Extension Pages +2: chrome-extension:///sidepanel.html [selected] +## Extension Service Workers +sw-1: chrome-extension:///sw.js +`; diff --git a/tests/tools/pages.test.ts b/tests/tools/pages.test.ts index 454ffc657..61bd8ee5f 100644 --- a/tests/tools/pages.test.ts +++ b/tests/tools/pages.test.ts @@ -12,7 +12,6 @@ import type {Dialog} from 'puppeteer-core'; import sinon from 'sinon'; import type {ParsedArguments} from '../../src/cli.js'; -import {installExtension} from '../../src/tools/extensions.js'; import { listPages, newPage, @@ -25,10 +24,18 @@ import { } from '../../src/tools/pages.js'; import {html, withMcpContext} from '../utils.js'; -const EXTENSION_PATH = path.join( +const EXTENSION_SW_PATH = path.join( import.meta.dirname, '../../../tests/tools/fixtures/extension-sw', ); +const EXTENSION_PATH = path.join( + import.meta.dirname, + '../../../tests/tools/fixtures/extension', +); +const EXTENSION_SIDE_PANEL_PATH = path.join( + import.meta.dirname, + '../../../tests/tools/fixtures/extension-side-panel', +); describe('pages', () => { afterEach(() => { @@ -46,25 +53,66 @@ describe('pages', () => { assert.ok(response.includePages); }); }); + it(`list pages for extension pages with --category-extensions`, async t => { + await withMcpContext( + async (response, context) => { + const extensionId = await context.installExtension(EXTENSION_PATH); + + assert.ok(extensionId); + + await context.triggerExtensionAction(extensionId); + + const _popupTarget = await context.browser.waitForTarget( + t => t.type() === 'page' && t.url().includes('chrome-extension://'), + ); + + response.resetResponseLineForTesting(); + const listPageDef = listPages({ + categoryExtensions: true, + } as ParsedArguments); + await listPageDef.handler( + {params: {}, page: context.getSelectedMcpPage()}, + response, + context, + ); + + const result = await response.handle(listPageDef.name, context); + const textContent = result.content.find(c => c.type === 'text') as { + type: 'text'; + text: string; + }; + assert.ok(textContent); + + const text = textContent.text.replaceAll( + extensionId, + '', + ); + t.assert.snapshot?.(text); + }, + { + executablePath: process.env.CHROME_M146_EXECUTABLE_PATH, + }, + { + categoryExtensions: true, + } as ParsedArguments, + ); + }); + for (const categoryExtensions of [true, false]) { - it(`list pages ${categoryExtensions ? 'with' : 'without'} --category-extensions`, async () => { + it(`list pages for extension service workers ${categoryExtensions ? 'with' : 'without'} --category-extensions`, async t => { await withMcpContext( async (response, context) => { - await installExtension.handler( - {params: {path: EXTENSION_PATH}}, - response, - context, - ); + const extensionId = + await context.installExtension(EXTENSION_SW_PATH); + assert.ok(extensionId); const swTarget = await context.browser.waitForTarget( - t => - t.type() === 'service_worker' && - t.url().includes('chrome-extension://'), + target => + target.type() === 'service_worker' && + target.url().includes('chrome-extension://'), ); const swUrl = swTarget.url(); - response.resetResponseLineForTesting(); - const listPageDef = listPages({ categoryExtensions, } as ParsedArguments); @@ -82,7 +130,6 @@ describe('pages', () => { assert.ok(textContent); if (categoryExtensions) { - assert.ok(textContent.text.includes(swUrl)); const structured = result.structuredContent as { extensionServiceWorkers: Array<{url: string}>; }; @@ -90,9 +137,13 @@ describe('pages', () => { structured.extensionServiceWorkers.map(sw => sw.url), [swUrl], ); - } else { - assert.ok(!textContent.text.includes(swUrl)); } + + const text = textContent.text.replaceAll( + extensionId, + '', + ); + t.assert.snapshot?.(text); }, {}, { @@ -101,6 +152,53 @@ describe('pages', () => { ); }); } + + it('list pages for side panels with --category-extensions', async t => { + await withMcpContext( + async (response, context) => { + const extensionId = await context.installExtension( + EXTENSION_SIDE_PANEL_PATH, + ); + + assert.ok(extensionId); + + const _sidePanelPage = await context.newPage(); + await _sidePanelPage.pptrPage.goto( + `chrome-extension://${extensionId}/sidepanel.html`, + ); + + await context.waitForTextOnPage(['Side Panel']); + + const listPageDef = listPages({ + categoryExtensions: true, + } as ParsedArguments); + await listPageDef.handler( + {params: {}, page: context.getSelectedMcpPage()}, + response, + context, + ); + + const result = await response.handle(listPageDef.name, context); + const textContent = result.content.find(c => c.type === 'text') as { + type: 'text'; + text: string; + }; + assert.ok(textContent); + + const text = textContent.text.replaceAll( + extensionId, + '', + ); + t.assert.snapshot?.(text); + }, + { + executablePath: process.env.CHROME_M146_EXECUTABLE_PATH, + }, + { + categoryExtensions: true, + } as ParsedArguments, + ); + }); }); describe('new_page', () => { it('create a page', async () => {