diff --git a/src/DevtoolsUtils.ts b/src/DevtoolsUtils.ts new file mode 100644 index 00000000..8b12b3a9 --- /dev/null +++ b/src/DevtoolsUtils.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +export function extractUrlLikeFromDevToolsTitle( + title: string, +): string | undefined { + const match = title.match(new RegExp(`DevTools - (.*)`)); + return match?.[1] ?? undefined; +} + +export function urlsEqual(url1: string, url2: string): boolean { + const normalizedUrl1 = normalizeUrl(url1); + const normalizedUrl2 = normalizeUrl(url2); + return normalizedUrl1 === normalizedUrl2; +} + +/** + * For the sake of the MCP server, when we determine if two URLs are equal we + * remove some parts: + * + * 1. We do not care about the protocol. + * 2. We do not care about trailing slashes. + * 3. We do not care about "www". + * + * For example, if the user types "record a trace on foo.com", we would want to + * match a tab in the connected Chrome instance that is showing "www.foo.com/" + */ +function normalizeUrl(url: string): string { + let result = url.trim(); + + // Remove protocols + if (result.startsWith('https://')) { + result = result.slice(8); + } else if (result.startsWith('http://')) { + result = result.slice(7); + } + + // Remove 'www.'. This ensures that we find the right URL regardless of if the user adds `www` or not. + if (result.startsWith('www.')) { + result = result.slice(4); + } + + // Remove trailing slash + if (result.endsWith('/')) { + result = result.slice(0, -1); + } + + return result; +} diff --git a/src/McpContext.ts b/src/McpContext.ts index adee0921..db7ab002 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -7,6 +7,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import {extractUrlLikeFromDevToolsTitle, urlsEqual} from './DevtoolsUtils.js'; import type {ListenerMap} from './PageCollector.js'; import {NetworkCollector, PageCollector} from './PageCollector.js'; import {Locator} from './third_party/index.js'; @@ -40,8 +41,8 @@ export interface TextSnapshot { } interface McpContextOptions { - // Whether the DevTools windows are exposed as pages. - devtools: boolean; + // Whether the DevTools windows are exposed as pages for debugging of DevTools. + experimentalDevToolsDebugging: boolean; } const DEFAULT_TIMEOUT = 5_000; @@ -82,6 +83,7 @@ export class McpContext implements Context { // The most recent page state. #pages: Page[] = []; + #pageToDevToolsPage = new Map(); #selectedPageIdx = 0; // The most recent snapshot. #textSnapshot: TextSnapshot | null = null; @@ -324,19 +326,57 @@ export class McpContext implements Context { * Creates a snapshot of the pages. */ async createPagesSnapshot(): Promise { - this.#pages = (await this.browser.pages()).filter(page => { - if (page.url().startsWith('devtools://')) { - return this.#options.devtools; - } - return true; + const allPages = await this.browser.pages(); + + this.#pages = allPages.filter(page => { + // If we allow debugging DevTools windows, return all pages. + // If we are in regular mode, the user should only see non-DevTools page. + return ( + this.#options.experimentalDevToolsDebugging || + !page.url().startsWith('devtools://') + ); }); + + await this.#detectOpenDevToolsWindows(allPages); + return this.#pages; } + async #detectOpenDevToolsWindows(pages: Page[]) { + this.#pageToDevToolsPage = new Map(); + for (const devToolsPage of pages) { + if (devToolsPage.url().startsWith('devtools://')) { + try { + const data = await devToolsPage + // @ts-expect-error no types for _client(). + ._client() + .send('Target.getTargetInfo'); + const devtoolsPageTitle = data.targetInfo.title; + const urlLike = extractUrlLikeFromDevToolsTitle(devtoolsPageTitle); + if (!urlLike) { + continue; + } + // TODO: lookup without a loop. + for (const page of this.#pages) { + if (urlsEqual(page.url(), urlLike)) { + this.#pageToDevToolsPage.set(page, devToolsPage); + } + } + } catch { + // no-op + } + } + } + } + getPages(): Page[] { return this.#pages; } + getDevToolsPage(page: Page): Page | undefined { + return this.#pageToDevToolsPage.get(page); + } + /** * Creates a text snapshot of a page. */ diff --git a/src/browser.ts b/src/browser.ts index 88765a7d..d0c99c7f 100644 --- a/src/browser.ts +++ b/src/browser.ts @@ -51,7 +51,7 @@ export async function ensureBrowserConnected(options: { const connectOptions: Parameters[0] = { targetFilter: makeTargetFilter(), defaultViewport: null, - handleDevToolsAsPage: options.devtools, + handleDevToolsAsPage: true, }; if (options.wsEndpoint) { @@ -134,7 +134,7 @@ export async function launch(options: McpLaunchOptions): Promise { headless, args, acceptInsecureCerts: options.acceptInsecureCerts, - handleDevToolsAsPage: options.devtools, + handleDevToolsAsPage: true, }); if (options.logFile) { // FIXME: we are probably subscribing too late to catch startup logs. We diff --git a/src/main.ts b/src/main.ts index 38f39cbe..3443cecc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -82,7 +82,7 @@ async function getContext(): Promise { if (context?.browser !== browser) { context = await McpContext.from(browser, logger, { - devtools, + experimentalDevToolsDebugging: devtools, }); } return context; diff --git a/tests/DevtoolsUtils.test.ts b/tests/DevtoolsUtils.test.ts new file mode 100644 index 00000000..2c130084 --- /dev/null +++ b/tests/DevtoolsUtils.test.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import assert from 'node:assert'; +import {describe, it} from 'node:test'; + +import { + extractUrlLikeFromDevToolsTitle, + urlsEqual, +} from '../src/DevtoolsUtils.js'; + +describe('extractUrlFromDevToolsTitle', () => { + it('deals with no trailing /', () => { + assert.strictEqual( + extractUrlLikeFromDevToolsTitle('DevTools - example.com'), + 'example.com', + ); + }); + it('deals with a trailing /', () => { + assert.strictEqual( + extractUrlLikeFromDevToolsTitle('DevTools - example.com/'), + 'example.com/', + ); + }); + it('deals with www', () => { + assert.strictEqual( + extractUrlLikeFromDevToolsTitle('DevTools - www.example.com/'), + 'www.example.com/', + ); + }); + it('deals with complex url', () => { + assert.strictEqual( + extractUrlLikeFromDevToolsTitle( + 'DevTools - www.example.com/path.html?a=b#3', + ), + 'www.example.com/path.html?a=b#3', + ); + }); +}); + +describe('urlsEqual', () => { + it('ignores trailing slashes', () => { + assert.strictEqual( + urlsEqual('https://google.com/', 'https://google.com'), + true, + ); + }); + + it('ignores www', () => { + assert.strictEqual( + urlsEqual('https://google.com/', 'https://www.google.com'), + true, + ); + }); + + it('ignores protocols', () => { + assert.strictEqual( + urlsEqual('https://google.com/', 'http://www.google.com'), + true, + ); + }); + + it('does not ignore other subdomains', () => { + assert.strictEqual( + urlsEqual('https://google.com/', 'https://photos.google.com'), + false, + ); + }); +}); diff --git a/tests/McpContext.test.ts b/tests/McpContext.test.ts index a054baba..64e2110d 100644 --- a/tests/McpContext.test.ts +++ b/tests/McpContext.test.ts @@ -78,4 +78,22 @@ describe('McpContext', () => { sinon.assert.calledWithExactly(stub, page, 2, 10); }); }); + + it('should should detect open DevTools pages', async () => { + await withBrowser( + async (_response, context) => { + const page = await context.newPage(); + // TODO: we do not know when the CLI flag to auto open DevTools will run + // so we need this until + // https://github.com/puppeteer/puppeteer/issues/14368 is there. + await new Promise(resolve => setTimeout(resolve, 5000)); + await context.createPagesSnapshot(); + assert.ok(context.getDevToolsPage(page)); + }, + { + autoOpenDevToos: true, + force: true, + }, + ); + }); }); diff --git a/tests/utils.ts b/tests/utils.ts index 0c071f90..d366767d 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -16,14 +16,16 @@ let browser: Browser | undefined; export async function withBrowser( cb: (response: McpResponse, context: McpContext) => Promise, - options: {debug?: boolean} = {}, + options: {debug?: boolean; autoOpenDevToos?: boolean; force?: boolean} = {}, ) { const {debug = false} = options; - if (!browser) { + if (!browser || options.force) { browser = await puppeteer.launch({ executablePath: process.env.PUPPETEER_EXECUTABLE_PATH, headless: !debug, defaultViewport: null, + devtools: options.autoOpenDevToos ?? false, + handleDevToolsAsPage: true, }); } const newPage = await browser.newPage(); @@ -40,7 +42,7 @@ export async function withBrowser( browser, logger('test'), { - devtools: false, + experimentalDevToolsDebugging: false, }, Locator, );