refactor: detect DevTools page for each page (#467)

This commit is contained in:
Alex Rudenko authored and GitHub committed 2025-10-27 09:53:34 +00:00
1 parent 66557071d4
commit 1560ff23ca
7 files changed
+195 -13

No files matched your search

+51
View File
@@ -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;
}
+47 -7
View File
@@ -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<Page, Page>();
#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<Page[]> {
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<Page, Page>();
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.
*/
+2 -2
View File
@@ -51,7 +51,7 @@ export async function ensureBrowserConnected(options: {
const connectOptions: Parameters<typeof puppeteer.connect>[0] = {
targetFilter: makeTargetFilter(),
defaultViewport: null,
handleDevToolsAsPage: options.devtools,
handleDevToolsAsPage: true,
};
if (options.wsEndpoint) {
@@ -134,7 +134,7 @@ export async function launch(options: McpLaunchOptions): Promise<Browser> {
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
+1 -1
View File
@@ -82,7 +82,7 @@ async function getContext(): Promise<McpContext> {
if (context?.browser !== browser) {
context = await McpContext.from(browser, logger, {
devtools,
experimentalDevToolsDebugging: devtools,
});
}
return context;
+71
View File
@@ -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,
);
});
});
+18
View File
@@ -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,
},
);
});
});
+5 -3
View File
@@ -16,14 +16,16 @@ let browser: Browser | undefined;
export async function withBrowser(
cb: (response: McpResponse, context: McpContext) => Promise<void>,
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,
);