mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-11-04 15:00:11 +00:00
refactor: detect DevTools page for each page (#467)
This commit is contained in:
1 parent
66557071d4
commit
1560ff23ca
7 files changed
+195
-13
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -82,7 +82,7 @@ async function getContext(): Promise<McpContext> {
|
||||
|
||||
if (context?.browser !== browser) {
|
||||
context = await McpContext.from(browser, logger, {
|
||||
devtools,
|
||||
experimentalDevToolsDebugging: devtools,
|
||||
});
|
||||
}
|
||||
return context;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user