Skip to content

Commit 3e12aa9

Browse files
committed
feat: read elements from DevTools UI
1 parent e5653b0 commit 3e12aa9

File tree

5 files changed

+73
-33
lines changed

5 files changed

+73
-33
lines changed

src/McpContext.ts

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@ 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 {
@@ -378,39 +380,72 @@ export class McpContext implements Context {
378380
return this.#pageToDevToolsPage.get(page);
379381
}
380382

381-
async getDevToolsData(): Promise<undefined | {requestId?: number}> {
383+
async getDevToolsData(): Promise<
384+
undefined | {requestId?: number; uid?: string}
385+
> {
382386
try {
387+
this.logger('Getting DevTools UI data')
383388
const selectedPage = this.getSelectedPage();
384389
const devtoolsPage = this.getDevToolsPage(selectedPage);
385-
if (devtoolsPage) {
386-
const cdpRequestId = await devtoolsPage.evaluate(async () => {
390+
if (!devtoolsPage) {
391+
this.logger('No DevTools page detected');
392+
return;
393+
}
394+
const {cdpRequestId, cdpBackendNodeId} = await devtoolsPage.evaluate(
395+
async () => {
387396
// @ts-expect-error no types
388397
const UI = await import('/bundled/ui/legacy/legacy.js');
389398
// @ts-expect-error no types
390399
const SDK = await import('/bundled/core/sdk/sdk.js');
391400
const request = UI.Context.Context.instance().flavor(
392401
SDK.NetworkRequest.NetworkRequest,
393402
);
394-
return request?.requestId();
395-
});
403+
const node = UI.Context.Context.instance().flavor(
404+
SDK.DOMModel.DOMNode,
405+
);
406+
return {
407+
cdpRequestId: request?.requestId(),
408+
cdpBackendNodeId: node?.backendNodeId(),
409+
};
410+
},
411+
);
412+
const resolveNetworkRequestId = () => {
396413
if (!cdpRequestId) {
397-
this.logger('no context request');
414+
this.logger('no network request');
398415
return;
399416
}
400417
const request = this.#networkCollector.find(selectedPage, request => {
401418
// @ts-expect-error id is internal.
402419
return request.id === cdpRequestId;
403420
});
404421
if (!request) {
405-
this.logger('no collected request for ' + cdpRequestId);
422+
this.logger('no network request for ' + cdpRequestId);
406423
return;
407424
}
408-
return {
409-
requestId: this.#networkCollector.getIdForResource(request),
410-
};
411-
} else {
412-
this.logger('no devtools page deteched');
413-
}
425+
return this.#networkCollector.getIdForResource(request);
426+
};
427+
const resolveNodeUid = (): string | undefined => {
428+
if (!cdpBackendNodeId) {
429+
this.logger('no cdpBackendNodeId');
430+
return;
431+
}
432+
// TODO: index by backendNodeId instead.
433+
const queue = [this.#textSnapshot?.root];
434+
while (queue.length) {
435+
const current = queue.pop()!;
436+
if (current.backendNodeId === cdpBackendNodeId) {
437+
return current.id;
438+
}
439+
for (const child of current.children) {
440+
queue.push(child);
441+
}
442+
}
443+
return;
444+
};
445+
return {
446+
requestId: resolveNetworkRequestId(),
447+
uid: resolveNodeUid(),
448+
};
414449
} catch (err) {
415450
this.logger('error getting devtools data', err);
416451
}
@@ -463,6 +498,10 @@ export class McpContext implements Context {
463498
snapshotId: String(snapshotId),
464499
idToNode,
465500
};
501+
const data = await this.getDevToolsData();
502+
if (data?.uid) {
503+
this.#textSnapshot.selectedElementUid = data.uid;
504+
}
466505
}
467506

468507
getTextSnapshot(): TextSnapshot | null {

src/McpResponse.ts

Lines changed: 3 additions & 3 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,
@@ -184,12 +184,12 @@ export class McpResponse implements Response {
184184
if (snapshot) {
185185
if (this.#snapshotParams.filePath) {
186186
await context.saveFile(
187-
new TextEncoder().encode(formatA11ySnapshot(snapshot.root)),
187+
new TextEncoder().encode(formatSnapshotNode(snapshot.root, snapshot)),
188188
this.#snapshotParams.filePath,
189189
);
190190
formattedSnapshot = `Saved snapshot to ${this.#snapshotParams.filePath}.`;
191191
} else {
192-
formattedSnapshot = formatA11ySnapshot(snapshot.root);
192+
formattedSnapshot = formatSnapshotNode(snapshot.root, snapshot);
193193
}
194194
}
195195
}

src/formatters/snapshotFormatter.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,20 @@
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 = ' '.repeat(depth * 2) + attributes.join(' ') + (root.id === snapshot?.selectedElementUid ? ' [selected in DevTools UI]' : '') + '\n';
1516
result += line;
1617

17-
for (const child of serializedAXNodeRoot.children) {
18-
result += formatA11ySnapshot(child, depth + 1);
18+
for (const child of root.children) {
19+
result += formatSnapshotNode(child, snapshot, depth + 1);
1920
}
2021

2122
return result;

src/tools/ToolDefinition.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ export type Context = Readonly<{
103103
text: string;
104104
timeout?: number | undefined;
105105
}): Promise<Element>;
106-
getDevToolsData(): Promise<undefined | {requestId?: number}>;
106+
getDevToolsData(): Promise<undefined | {requestId?: number, uid?: string}>;
107107
}>;
108108

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

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)