Skip to content

Commit e5b4e89

Browse files
committed
feat: read elements from DevTools UI
1 parent 2f448e8 commit e5b4e89

File tree

6 files changed

+133
-50
lines changed

6 files changed

+133
-50
lines changed

src/McpContext.ts

Lines changed: 68 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -25,19 +25,21 @@ import type {
2525
import {listPages} from './tools/pages.js';
2626
import {takeSnapshot} from './tools/snapshot.js';
2727
import {CLOSE_PAGE_ERROR} from './tools/ToolDefinition.js';
28-
import type {Context} from './tools/ToolDefinition.js';
28+
import type {Context, DevToolsData} from './tools/ToolDefinition.js';
2929
import type {TraceResult} from './trace-processing/parse.js';
3030
import {WaitForHelper} from './WaitForHelper.js';
3131

3232
export interface TextSnapshotNode extends SerializedAXNode {
3333
id: string;
34+
backendNodeId?: number;
3435
children: TextSnapshotNode[];
3536
}
3637

3738
export interface TextSnapshot {
3839
root: TextSnapshotNode;
3940
idToNode: Map<string, TextSnapshotNode>;
4041
snapshotId: string;
42+
selectedElementUid?: string;
4143
}
4244

4345
interface McpContextOptions {
@@ -151,6 +153,42 @@ export class McpContext implements Context {
151153
return context;
152154
}
153155

156+
resolveCdpRequestId(cdpRequestId: string): number | undefined {
157+
const selectedPage = this.getSelectedPage();
158+
if (!cdpRequestId) {
159+
this.logger('no network request');
160+
return;
161+
}
162+
const request = this.#networkCollector.find(selectedPage, request => {
163+
// @ts-expect-error id is internal.
164+
return request.id === cdpRequestId;
165+
});
166+
if (!request) {
167+
this.logger('no network request for ' + cdpRequestId);
168+
return;
169+
}
170+
return this.#networkCollector.getIdForResource(request);
171+
}
172+
173+
resolveCdpElementId(cdpBackendNodeId: number): string | undefined {
174+
if (!cdpBackendNodeId) {
175+
this.logger('no cdpBackendNodeId');
176+
return;
177+
}
178+
// TODO: index by backendNodeId instead.
179+
const queue = [this.#textSnapshot?.root];
180+
while (queue.length) {
181+
const current = queue.pop()!;
182+
if (current.backendNodeId === cdpBackendNodeId) {
183+
return current.id;
184+
}
185+
for (const child of current.children) {
186+
queue.push(child);
187+
}
188+
}
189+
return;
190+
}
191+
154192
getNetworkRequests(includePreservedRequests?: boolean): HTTPRequest[] {
155193
const page = this.getSelectedPage();
156194
return this.#networkCollector.getData(page, includePreservedRequests);
@@ -378,49 +416,47 @@ export class McpContext implements Context {
378416
return this.#pageToDevToolsPage.get(page);
379417
}
380418

381-
async getDevToolsData(): Promise<undefined | {requestId?: number}> {
419+
async getDevToolsData(): Promise<DevToolsData> {
382420
try {
421+
this.logger('Getting DevTools UI data');
383422
const selectedPage = this.getSelectedPage();
384423
const devtoolsPage = this.getDevToolsPage(selectedPage);
385-
if (devtoolsPage) {
386-
const cdpRequestId = await devtoolsPage.evaluate(async () => {
424+
if (!devtoolsPage) {
425+
this.logger('No DevTools page detected');
426+
return {};
427+
}
428+
const {cdpRequestId, cdpBackendNodeId} = await devtoolsPage.evaluate(
429+
async () => {
387430
// @ts-expect-error no types
388431
const UI = await import('/bundled/ui/legacy/legacy.js');
389432
// @ts-expect-error no types
390433
const SDK = await import('/bundled/core/sdk/sdk.js');
391434
const request = UI.Context.Context.instance().flavor(
392435
SDK.NetworkRequest.NetworkRequest,
393436
);
394-
return request?.requestId();
395-
});
396-
if (!cdpRequestId) {
397-
this.logger('no context request');
398-
return;
399-
}
400-
const request = this.#networkCollector.find(selectedPage, request => {
401-
// @ts-expect-error id is internal.
402-
return request.id === cdpRequestId;
403-
});
404-
if (!request) {
405-
this.logger('no collected request for ' + cdpRequestId);
406-
return;
407-
}
408-
return {
409-
requestId: this.#networkCollector.getIdForResource(request),
410-
};
411-
} else {
412-
this.logger('no devtools page deteched');
413-
}
437+
const node = UI.Context.Context.instance().flavor(
438+
SDK.DOMModel.DOMNode,
439+
);
440+
return {
441+
cdpRequestId: request?.requestId(),
442+
cdpBackendNodeId: node?.backendNodeId(),
443+
};
444+
},
445+
);
446+
return {cdpBackendNodeId, cdpRequestId};
414447
} catch (err) {
415448
this.logger('error getting devtools data', err);
416449
}
417-
return;
450+
return {};
418451
}
419452

420453
/**
421454
* Creates a text snapshot of a page.
422455
*/
423-
async createTextSnapshot(verbose = false): Promise<void> {
456+
async createTextSnapshot(
457+
verbose = false,
458+
devtoolsData: DevToolsData | undefined = undefined,
459+
): Promise<void> {
424460
const page = this.getSelectedPage();
425461
const rootNode = await page.accessibility.snapshot({
426462
includeIframes: true,
@@ -463,6 +499,12 @@ export class McpContext implements Context {
463499
snapshotId: String(snapshotId),
464500
idToNode,
465501
};
502+
const data = devtoolsData ?? (await this.getDevToolsData());
503+
if (data?.cdpBackendNodeId) {
504+
this.#textSnapshot.selectedElementUid = this.resolveCdpElementId(
505+
data?.cdpBackendNodeId,
506+
);
507+
}
466508
}
467509

468510
getTextSnapshot(): TextSnapshot | null {

src/McpResponse.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
getShortDescriptionForRequest,
1616
getStatusFromRequest,
1717
} from './formatters/networkFormatter.js';
18-
import {formatA11ySnapshot} from './formatters/snapshotFormatter.js';
18+
import {formatSnapshotNode} from './formatters/snapshotFormatter.js';
1919
import type {McpContext} from './McpContext.js';
2020
import type {
2121
ConsoleMessage,
@@ -25,6 +25,7 @@ import type {
2525
} from './third_party/index.js';
2626
import {handleDialog} from './tools/pages.js';
2727
import type {
28+
DevToolsData,
2829
ImageContentData,
2930
Response,
3031
SnapshotParams,
@@ -52,6 +53,11 @@ export class McpResponse implements Response {
5253
types?: string[];
5354
includePreservedMessages?: boolean;
5455
};
56+
#devToolsData?: DevToolsData;
57+
58+
attachDevToolsData(data: DevToolsData): void {
59+
this.#devToolsData = data;
60+
}
5561

5662
setIncludePages(value: boolean): void {
5763
this.#includePages = value;
@@ -179,17 +185,22 @@ export class McpResponse implements Response {
179185

180186
let formattedSnapshot: string | undefined;
181187
if (this.#snapshotParams) {
182-
await context.createTextSnapshot(this.#snapshotParams.verbose);
188+
await context.createTextSnapshot(
189+
this.#snapshotParams.verbose,
190+
this.#devToolsData,
191+
);
183192
const snapshot = context.getTextSnapshot();
184193
if (snapshot) {
185194
if (this.#snapshotParams.filePath) {
186195
await context.saveFile(
187-
new TextEncoder().encode(formatA11ySnapshot(snapshot.root)),
196+
new TextEncoder().encode(
197+
formatSnapshotNode(snapshot.root, snapshot),
198+
),
188199
this.#snapshotParams.filePath,
189200
);
190201
formattedSnapshot = `Saved snapshot to ${this.#snapshotParams.filePath}.`;
191202
} else {
192-
formattedSnapshot = formatA11ySnapshot(snapshot.root);
203+
formattedSnapshot = formatSnapshotNode(snapshot.root, snapshot);
193204
}
194205
}
195206
}

src/formatters/snapshotFormatter.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,26 @@
33
* Copyright 2025 Google LLC
44
* SPDX-License-Identifier: Apache-2.0
55
*/
6-
import type {TextSnapshotNode} from '../McpContext.js';
6+
import type {TextSnapshot, TextSnapshotNode} from '../McpContext.js';
77

8-
export function formatA11ySnapshot(
9-
serializedAXNodeRoot: TextSnapshotNode,
8+
export function formatSnapshotNode(
9+
root: TextSnapshotNode,
10+
snapshot?: TextSnapshot,
1011
depth = 0,
1112
): string {
1213
let result = '';
13-
const attributes = getAttributes(serializedAXNodeRoot);
14-
const line = ' '.repeat(depth * 2) + attributes.join(' ') + '\n';
14+
const attributes = getAttributes(root);
15+
const line =
16+
' '.repeat(depth * 2) +
17+
attributes.join(' ') +
18+
(root.id === snapshot?.selectedElementUid
19+
? ' [selected in the DevTools Elements panel]'
20+
: '') +
21+
'\n';
1522
result += line;
1623

17-
for (const child of serializedAXNodeRoot.children) {
18-
result += formatA11ySnapshot(child, depth + 1);
24+
for (const child of root.children) {
25+
result += formatSnapshotNode(child, snapshot, depth + 1);
1926
}
2027

2128
return result;

src/tools/ToolDefinition.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ export interface SnapshotParams {
4747
filePath?: string;
4848
}
4949

50+
export interface DevToolsData {
51+
cdpRequestId?: string;
52+
cdpBackendNodeId?: number;
53+
}
54+
5055
export interface Response {
5156
appendResponseLine(value: string): void;
5257
setIncludePages(value: boolean): void;
@@ -69,6 +74,8 @@ export interface Response {
6974
attachImage(value: ImageContentData): void;
7075
attachNetworkRequest(reqid: number): void;
7176
attachConsoleMessage(msgid: number): void;
77+
// Allows re-using DevTools data queried by some tools.
78+
attachDevToolsData(data: DevToolsData): void;
7279
}
7380

7481
/**
@@ -103,7 +110,15 @@ export type Context = Readonly<{
103110
text: string;
104111
timeout?: number | undefined;
105112
}): Promise<Element>;
106-
getDevToolsData(): Promise<undefined | {requestId?: number}>;
113+
getDevToolsData(): Promise<DevToolsData>;
114+
/**
115+
* Returns a reqid for a cdpRequestId.
116+
*/
117+
resolveCdpRequestId(cdpRequestId: string): number | undefined;
118+
/**
119+
* Returns a reqid for a cdpRequestId.
120+
*/
121+
resolveCdpElementId(cdpBackendNodeId: number): string | undefined;
107122
}>;
108123

109124
export function defineTool<Schema extends zod.ZodRawShape>(

src/tools/network.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,16 @@ export const listNetworkRequests = defineTool({
7272
},
7373
handler: async (request, response, context) => {
7474
const data = await context.getDevToolsData();
75+
response.attachDevToolsData(data);
76+
const reqid = data?.cdpRequestId
77+
? context.resolveCdpRequestId(data.cdpRequestId)
78+
: undefined;
7579
response.setIncludeNetworkRequests(true, {
7680
pageSize: request.params.pageSize,
7781
pageIdx: request.params.pageIdx,
7882
resourceTypes: request.params.resourceTypes,
7983
includePreservedRequests: request.params.includePreservedRequests,
80-
networkRequestIdInDevToolsUI: data?.requestId,
84+
networkRequestIdInDevToolsUI: reqid,
8185
});
8286
},
8387
});
@@ -102,8 +106,12 @@ export const getNetworkRequest = defineTool({
102106
response.attachNetworkRequest(request.params.reqid);
103107
} else {
104108
const data = await context.getDevToolsData();
105-
if (data?.requestId) {
106-
response.attachNetworkRequest(data?.requestId);
109+
response.attachDevToolsData(data);
110+
const reqid = data?.cdpRequestId
111+
? context.resolveCdpRequestId(data.cdpRequestId)
112+
: undefined;
113+
if (reqid) {
114+
response.attachNetworkRequest(reqid);
107115
} else {
108116
response.appendResponseLine(
109117
`Nothing is currently selected in the DevTools Network panel.`,

tests/formatters/snapshotFormatter.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ import {describe, it} from 'node:test';
99

1010
import type {ElementHandle} from 'puppeteer-core';
1111

12-
import {formatA11ySnapshot} from '../../src/formatters/snapshotFormatter.js';
12+
import {formatSnapshotNode} from '../../src/formatters/snapshotFormatter.js';
1313
import type {TextSnapshotNode} from '../../src/McpContext.js';
1414

1515
describe('snapshotFormatter', () => {
1616
it('formats a snapshot with value properties', () => {
17-
const snapshot: TextSnapshotNode = {
17+
const node: TextSnapshotNode = {
1818
id: '1_1',
1919
role: 'textbox',
2020
name: 'textbox',
@@ -35,7 +35,7 @@ describe('snapshotFormatter', () => {
3535
},
3636
};
3737

38-
const formatted = formatA11ySnapshot(snapshot);
38+
const formatted = formatSnapshotNode(node);
3939
assert.strictEqual(
4040
formatted,
4141
`uid=1_1 textbox "textbox" value="value"
@@ -45,7 +45,7 @@ describe('snapshotFormatter', () => {
4545
});
4646

4747
it('formats a snapshot with boolean properties', () => {
48-
const snapshot: TextSnapshotNode = {
48+
const node: TextSnapshotNode = {
4949
id: '1_1',
5050
role: 'button',
5151
name: 'button',
@@ -66,7 +66,7 @@ describe('snapshotFormatter', () => {
6666
},
6767
};
6868

69-
const formatted = formatA11ySnapshot(snapshot);
69+
const formatted = formatSnapshotNode(node);
7070
assert.strictEqual(
7171
formatted,
7272
`uid=1_1 button "button" disableable disabled
@@ -76,7 +76,7 @@ describe('snapshotFormatter', () => {
7676
});
7777

7878
it('formats a snapshot with checked properties', () => {
79-
const snapshot: TextSnapshotNode = {
79+
const node: TextSnapshotNode = {
8080
id: '1_1',
8181
role: 'checkbox',
8282
name: 'checkbox',
@@ -97,7 +97,7 @@ describe('snapshotFormatter', () => {
9797
},
9898
};
9999

100-
const formatted = formatA11ySnapshot(snapshot);
100+
const formatted = formatSnapshotNode(node);
101101
assert.strictEqual(
102102
formatted,
103103
`uid=1_1 checkbox "checkbox" checked
@@ -107,7 +107,7 @@ describe('snapshotFormatter', () => {
107107
});
108108

109109
it('formats a snapshot with multiple different type attributes', () => {
110-
const snapshot: TextSnapshotNode = {
110+
const node: TextSnapshotNode = {
111111
id: '1_1',
112112
role: 'root',
113113
name: 'root',
@@ -139,7 +139,7 @@ describe('snapshotFormatter', () => {
139139
},
140140
};
141141

142-
const formatted = formatA11ySnapshot(snapshot);
142+
const formatted = formatSnapshotNode(node);
143143
assert.strictEqual(
144144
formatted,
145145
`uid=1_1 root "root"

0 commit comments

Comments
 (0)