Skip to content

Commit ab9e636

Browse files
committed
feat: display whether DevTools are open for a page
1 parent a41e440 commit ab9e636

File tree

8 files changed

+197
-14
lines changed

8 files changed

+197
-14
lines changed

src/DevtoolsUtils.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
export function extractUrlLikeFromDevToolsTitle(
7+
title: string,
8+
): string | undefined {
9+
const match = title.match(new RegExp(`DevTools - (.*)`));
10+
return match?.[1] ?? undefined;
11+
}
12+
13+
export function urlsEqual(url1: string, url2: string): boolean {
14+
const normalizedUrl1 = normalizeUrl(url1);
15+
const normalizedUrl2 = normalizeUrl(url2);
16+
return normalizedUrl1 === normalizedUrl2;
17+
}
18+
19+
/**
20+
* For the sake of the MCP server, when we determine if two URLs are equal we
21+
* remove some parts:
22+
*
23+
* 1. We do not care about the protocol.
24+
* 2. We do not care about trailing slashes.
25+
* 3. We do not care about "www".
26+
*
27+
* For example, if the user types "record a trace on foo.com", we would want to
28+
* match a tab in the connected Chrome instance that is showing "www.foo.com/"
29+
*/
30+
function normalizeUrl(url: string): string {
31+
let result = url.trim();
32+
33+
// Remove protocols
34+
if (result.startsWith('https://')) {
35+
result = result.slice(8);
36+
} else if (result.startsWith('http://')) {
37+
result = result.slice(7);
38+
}
39+
40+
// Remove 'www.'. This ensures that we find the right URL regardless of if the user adds `www` or not.
41+
if (result.startsWith('www.')) {
42+
result = result.slice(4);
43+
}
44+
45+
// Remove trailing slash
46+
if (result.endsWith('/')) {
47+
result = result.slice(0, -1);
48+
}
49+
50+
return result;
51+
}

src/McpContext.ts

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import fs from 'node:fs/promises';
77
import os from 'node:os';
88
import path from 'node:path';
99

10+
import {extractUrlLikeFromDevToolsTitle, urlsEqual} from './DevtoolsUtils.js';
1011
import type {ListenerMap} from './PageCollector.js';
1112
import {NetworkCollector, PageCollector} from './PageCollector.js';
1213
import {Locator} from './third_party/index.js';
@@ -40,8 +41,8 @@ export interface TextSnapshot {
4041
}
4142

4243
interface McpContextOptions {
43-
// Whether the DevTools windows are exposed as pages.
44-
devtools: boolean;
44+
// Whether the DevTools windows are exposed as pages for debugging of DevTools.
45+
experimentalDevToolsDebugging: boolean;
4546
}
4647

4748
const DEFAULT_TIMEOUT = 5_000;
@@ -82,6 +83,7 @@ export class McpContext implements Context {
8283

8384
// The most recent page state.
8485
#pages: Page[] = [];
86+
#pageToDevToolsPage = new Map<Page, Page>();
8587
#selectedPageIdx = 0;
8688
// The most recent snapshot.
8789
#textSnapshot: TextSnapshot | null = null;
@@ -324,19 +326,57 @@ export class McpContext implements Context {
324326
* Creates a snapshot of the pages.
325327
*/
326328
async createPagesSnapshot(): Promise<Page[]> {
327-
this.#pages = (await this.browser.pages()).filter(page => {
328-
if (page.url().startsWith('devtools://')) {
329-
return this.#options.devtools;
330-
}
331-
return true;
329+
const allPages = await this.browser.pages();
330+
331+
this.#pages = allPages.filter(page => {
332+
// If we allow debugging DevTools windows, return all pages.
333+
// If we are in regular mode, the user should only see non-DevTools page.
334+
return (
335+
this.#options.experimentalDevToolsDebugging ||
336+
!page.url().startsWith('devtools://')
337+
);
332338
});
339+
340+
await this.#detectOpenDevToolsWindows(allPages);
341+
333342
return this.#pages;
334343
}
335344

345+
async #detectOpenDevToolsWindows(pages: Page[]) {
346+
this.#pageToDevToolsPage = new Map<Page, Page>();
347+
for (const devToolsPage of pages) {
348+
if (devToolsPage.url().startsWith('devtools://')) {
349+
try {
350+
const data = await devToolsPage
351+
// @ts-expect-error no types for _client().
352+
._client()
353+
.send('Target.getTargetInfo');
354+
const devtoolsPageTitle = data.targetInfo.title;
355+
const urlLike = extractUrlLikeFromDevToolsTitle(devtoolsPageTitle);
356+
if (!urlLike) {
357+
continue;
358+
}
359+
// TODO: lookup without a loop.
360+
for (const page of this.#pages) {
361+
if (urlsEqual(page.url(), urlLike)) {
362+
this.#pageToDevToolsPage.set(page, devToolsPage);
363+
}
364+
}
365+
} catch {
366+
// no-op
367+
}
368+
}
369+
}
370+
}
371+
336372
getPages(): Page[] {
337373
return this.#pages;
338374
}
339375

376+
getDevToolsPage(page: Page): Page | undefined {
377+
return this.#pageToDevToolsPage.get(page);
378+
}
379+
340380
/**
341381
* Creates a text snapshot of a page.
342382
*/

src/McpResponse.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,8 +347,9 @@ Call ${handleDialog.name} to handle it before continuing.`);
347347
const parts = [`## Pages`];
348348
let idx = 0;
349349
for (const page of context.getPages()) {
350+
const devToolsPage = context.getDevToolsPage(page);
350351
parts.push(
351-
`${idx}: ${page.url()}${idx === context.getSelectedPageIdx() ? ' [selected]' : ''}`,
352+
`${idx}: ${page.url()}${idx === context.getSelectedPageIdx() ? ' [selected]' : ''}${devToolsPage ? ' [has DevTools opened]' : ''}`,
352353
);
353354
idx++;
354355
}

src/browser.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export async function ensureBrowserConnected(options: {
5151
const connectOptions: Parameters<typeof puppeteer.connect>[0] = {
5252
targetFilter: makeTargetFilter(),
5353
defaultViewport: null,
54-
handleDevToolsAsPage: options.devtools,
54+
handleDevToolsAsPage: true,
5555
};
5656

5757
if (options.wsEndpoint) {
@@ -134,7 +134,7 @@ export async function launch(options: McpLaunchOptions): Promise<Browser> {
134134
headless,
135135
args,
136136
acceptInsecureCerts: options.acceptInsecureCerts,
137-
handleDevToolsAsPage: options.devtools,
137+
handleDevToolsAsPage: true,
138138
});
139139
if (options.logFile) {
140140
// FIXME: we are probably subscribing too late to catch startup logs. We

src/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ async function getContext(): Promise<McpContext> {
8282

8383
if (context?.browser !== browser) {
8484
context = await McpContext.from(browser, logger, {
85-
devtools,
85+
experimentalDevToolsDebugging: devtools,
8686
});
8787
}
8888
return context;

tests/DevtoolsUtils.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
import assert from 'node:assert';
7+
import {describe, it} from 'node:test';
8+
9+
import {
10+
extractUrlLikeFromDevToolsTitle,
11+
urlsEqual,
12+
} from '../src/DevtoolsUtils.js';
13+
14+
describe('extractUrlFromDevToolsTitle', () => {
15+
it('deals with no trailing /', () => {
16+
assert.strictEqual(
17+
extractUrlLikeFromDevToolsTitle('DevTools - example.com'),
18+
'example.com',
19+
);
20+
});
21+
it('deals with a trailing /', () => {
22+
assert.strictEqual(
23+
extractUrlLikeFromDevToolsTitle('DevTools - example.com/'),
24+
'example.com/',
25+
);
26+
});
27+
it('deals with www', () => {
28+
assert.strictEqual(
29+
extractUrlLikeFromDevToolsTitle('DevTools - www.example.com/'),
30+
'www.example.com/',
31+
);
32+
});
33+
it('deals with complex url', () => {
34+
assert.strictEqual(
35+
extractUrlLikeFromDevToolsTitle(
36+
'DevTools - www.example.com/path.html?a=b#3',
37+
),
38+
'www.example.com/path.html?a=b#3',
39+
);
40+
});
41+
});
42+
43+
describe('urlsEqual', () => {
44+
it('ignores trailing slashes', () => {
45+
assert.strictEqual(
46+
urlsEqual('https://google.com/', 'https://google.com'),
47+
true,
48+
);
49+
});
50+
51+
it('ignores www', () => {
52+
assert.strictEqual(
53+
urlsEqual('https://google.com/', 'https://www.google.com'),
54+
true,
55+
);
56+
});
57+
58+
it('ignores protocols', () => {
59+
assert.strictEqual(
60+
urlsEqual('https://google.com/', 'http://www.google.com'),
61+
true,
62+
);
63+
});
64+
65+
it('does not ignore other subdomains', () => {
66+
assert.strictEqual(
67+
urlsEqual('https://google.com/', 'https://photos.google.com'),
68+
false,
69+
);
70+
});
71+
});

tests/McpContext.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,22 @@ describe('McpContext', () => {
7878
sinon.assert.calledWithExactly(stub, page, 2, 10);
7979
});
8080
});
81+
82+
it('should should detect open DevTools pages', async () => {
83+
await withBrowser(
84+
async (_response, context) => {
85+
const page = await context.newPage();
86+
// TODO: we do not know when the CLI flag to auto open DevTools will run
87+
// so we need this until
88+
// https://github.com/puppeteer/puppeteer/issues/14368 is there.
89+
await new Promise(resolve => setTimeout(resolve, 3000));
90+
await context.createPagesSnapshot();
91+
assert.ok(context.getDevToolsPage(page));
92+
},
93+
{
94+
autoOpenDevToos: true,
95+
force: true,
96+
},
97+
);
98+
});
8199
});

tests/utils.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,16 @@ let browser: Browser | undefined;
1616

1717
export async function withBrowser(
1818
cb: (response: McpResponse, context: McpContext) => Promise<void>,
19-
options: {debug?: boolean} = {},
19+
options: {debug?: boolean; autoOpenDevToos?: boolean; force?: boolean} = {},
2020
) {
2121
const {debug = false} = options;
22-
if (!browser) {
22+
if (!browser || options.force) {
2323
browser = await puppeteer.launch({
2424
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
2525
headless: !debug,
2626
defaultViewport: null,
27+
devtools: options.autoOpenDevToos ?? false,
28+
handleDevToolsAsPage: true,
2729
});
2830
}
2931
const newPage = await browser.newPage();
@@ -40,7 +42,7 @@ export async function withBrowser(
4042
browser,
4143
logger('test'),
4244
{
43-
devtools: false,
45+
experimentalDevToolsDebugging: false,
4446
},
4547
Locator,
4648
);

0 commit comments

Comments
 (0)