diff --git a/src/DevtoolsUtils.ts b/src/DevtoolsUtils.ts index 7a4abe9dc..c1f6efcb4 100644 --- a/src/DevtoolsUtils.ts +++ b/src/DevtoolsUtils.ts @@ -15,6 +15,61 @@ import type { Target as PuppeteerTarget, } from './third_party/index.js'; +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". + * 4. We ignore the hash parts. + * + * 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); + } + + // We use target URLs to locate DevTools but those often do + // no include hash. + const hashIdx = result.lastIndexOf('#'); + if (hashIdx !== -1) { + result = result.slice(0, hashIdx); + } + + // Remove trailing slash + if (result.endsWith('/')) { + result = result.slice(0, -1); + } + + return result; +} + /** * A mock implementation of an issues manager that only implements the methods * that are actually used by the IssuesAggregator diff --git a/src/McpContext.ts b/src/McpContext.ts index 4647c6118..889103fab 100644 --- a/src/McpContext.ts +++ b/src/McpContext.ts @@ -8,7 +8,11 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import type {TargetUniverse} from './DevtoolsUtils.js'; -import {UniverseManager} from './DevtoolsUtils.js'; +import { + extractUrlLikeFromDevToolsTitle, + UniverseManager, + urlsEqual, +} from './DevtoolsUtils.js'; import {McpPage} from './McpPage.js'; import type {ListenerMap, UncaughtError} from './PageCollector.js'; import {NetworkCollector, ConsoleCollector} from './PageCollector.js'; @@ -649,21 +653,37 @@ export class McpContext implements Context { async detectOpenDevToolsWindows() { this.logger('Detecting open DevTools windows'); const {pages} = await this.#getAllPages(); - - await Promise.all( - pages.map(async page => { - const mcpPage = this.#mcpPages.get(page); - if (!mcpPage) { - return; - } - - if (await page.hasDevTools()) { - mcpPage.devToolsPage = await page.openDevTools(); - } else { - mcpPage.devToolsPage = undefined; + // Clear all devToolsPage references before re-detecting. + for (const mcpPage of this.#mcpPages.values()) { + mcpPage.devToolsPage = undefined; + } + for (const devToolsPage of pages) { + if (devToolsPage.url().startsWith('devtools://')) { + try { + this.logger('Calling getTargetInfo for ' + devToolsPage.url()); + 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)) { + const mcpPage = this.#mcpPages.get(page); + if (mcpPage) { + mcpPage.devToolsPage = devToolsPage; + } + } + } + } catch (error) { + this.logger('Issue occurred while trying to find DevTools', error); } - }), - ); + } + } } getExtensionServiceWorkers(): ExtensionServiceWorker[] { diff --git a/tests/DevtoolsUtils.test.ts b/tests/DevtoolsUtils.test.ts index 8f9aa3794..a5e43009c 100644 --- a/tests/DevtoolsUtils.test.ts +++ b/tests/DevtoolsUtils.test.ts @@ -9,7 +9,11 @@ import {afterEach, describe, it} from 'node:test'; import sinon from 'sinon'; -import {UniverseManager} from '../src/DevtoolsUtils.js'; +import { + extractUrlLikeFromDevToolsTitle, + urlsEqual, + UniverseManager, +} from '../src/DevtoolsUtils.js'; import {DevTools} from '../src/third_party/index.js'; import type {Browser, Target} from '../src/third_party/index.js'; @@ -20,6 +24,76 @@ import { withBrowser, } from './utils.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, + ); + }); + + it('ignores hash', () => { + assert.strictEqual( + urlsEqual('https://google.com/#', 'http://www.google.com'), + true, + ); + assert.strictEqual( + urlsEqual('https://google.com/#21', 'http://www.google.com#12'), + true, + ); + }); +}); + describe('UniverseManager', () => { afterEach(() => { sinon.restore();