Skip to content

Commit 2290623

Browse files
authored
Merge branch 'ChromeDevTools:main' into resize_bug_465
2 parents f486e01 + 8f3fcf6 commit 2290623

17 files changed

Lines changed: 571 additions & 214 deletions

GEMINI.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Instructions
2+
3+
- use `npm run build` to run tsc and test build
4+
- use `npm run test` to run tests, run all tests to verify correctness

src/McpResponse.ts

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
getShortDescriptionForRequest,
1818
getStatusFromRequest,
1919
} from './formatters/networkFormatter.js';
20-
import {formatSnapshotNode} from './formatters/snapshotFormatter.js';
20+
import {SnapshotFormatter} from './formatters/SnapshotFormatter.js';
2121
import type {McpContext} from './McpContext.js';
2222
import {DevTools} from './third_party/index.js';
2323
import type {
@@ -57,11 +57,16 @@ export class McpResponse implements Response {
5757
includePreservedMessages?: boolean;
5858
};
5959
#devToolsData?: DevToolsData;
60+
#tabId?: string;
6061

6162
attachDevToolsData(data: DevToolsData): void {
6263
this.#devToolsData = data;
6364
}
6465

66+
setTabId(tabId: string): void {
67+
this.#tabId = tabId;
68+
}
69+
6570
setIncludePages(value: boolean): void {
6671
this.#includePages = value;
6772
}
@@ -181,29 +186,31 @@ export class McpResponse implements Response {
181186
async handle(
182187
toolName: string,
183188
context: McpContext,
184-
): Promise<Array<TextContent | ImageContent>> {
189+
): Promise<{
190+
content: Array<TextContent | ImageContent>;
191+
structuredContent: object;
192+
}> {
185193
if (this.#includePages) {
186194
await context.createPagesSnapshot();
187195
}
188196

189-
let formattedSnapshot: string | undefined;
197+
let snapshot: SnapshotFormatter | string | undefined;
190198
if (this.#snapshotParams) {
191199
await context.createTextSnapshot(
192200
this.#snapshotParams.verbose,
193201
this.#devToolsData,
194202
);
195-
const snapshot = context.getTextSnapshot();
196-
if (snapshot) {
203+
const textSnapshot = context.getTextSnapshot();
204+
if (textSnapshot) {
205+
const formatter = new SnapshotFormatter(textSnapshot);
197206
if (this.#snapshotParams.filePath) {
198207
await context.saveFile(
199-
new TextEncoder().encode(
200-
formatSnapshotNode(snapshot.root, snapshot),
201-
),
208+
new TextEncoder().encode(formatter.toString()),
202209
this.#snapshotParams.filePath,
203210
);
204-
formattedSnapshot = `Saved snapshot to ${this.#snapshotParams.filePath}.`;
211+
snapshot = this.#snapshotParams.filePath;
205212
} else {
206-
formattedSnapshot = formatSnapshotNode(snapshot.root, snapshot);
213+
snapshot = formatter;
207214
}
208215
}
209216
}
@@ -335,7 +342,7 @@ export class McpResponse implements Response {
335342
bodies,
336343
consoleData,
337344
consoleListData,
338-
formattedSnapshot,
345+
snapshot,
339346
});
340347
}
341348

@@ -349,9 +356,9 @@ export class McpResponse implements Response {
349356
};
350357
consoleData: ConsoleMessageData | undefined;
351358
consoleListData: ConsoleMessageData[] | undefined;
352-
formattedSnapshot: string | undefined;
359+
snapshot: SnapshotFormatter | string | undefined;
353360
},
354-
): Array<TextContent | ImageContent> {
361+
): {content: Array<TextContent | ImageContent>; structuredContent: object} {
355362
const response = [`# ${toolName} response`];
356363
for (const line of this.#textResponseLines) {
357364
response.push(line);
@@ -393,9 +400,25 @@ Call ${handleDialog.name} to handle it before continuing.`);
393400
response.push(...parts);
394401
}
395402

396-
if (data.formattedSnapshot) {
397-
response.push('## Latest page snapshot');
398-
response.push(data.formattedSnapshot);
403+
const structuredContent: {
404+
snapshot?: object;
405+
snapshotFilePath?: string;
406+
tabId?: string;
407+
} = {};
408+
409+
if (this.#tabId) {
410+
structuredContent.tabId = this.#tabId;
411+
}
412+
413+
if (data.snapshot) {
414+
if (typeof data.snapshot === 'string') {
415+
response.push(`Saved snapshot to ${data.snapshot}.`);
416+
structuredContent.snapshotFilePath = data.snapshot;
417+
} else {
418+
response.push('## Latest page snapshot');
419+
response.push(data.snapshot.toString());
420+
structuredContent.snapshot = data.snapshot.toJSON();
421+
}
399422
}
400423

401424
response.push(...this.#formatNetworkRequestData(context, data.bodies));
@@ -468,7 +491,10 @@ Call ${handleDialog.name} to handle it before continuing.`);
468491
} as const;
469492
});
470493

471-
return [text, ...images];
494+
return {
495+
content: [text, ...images],
496+
structuredContent,
497+
};
472498
}
473499

474500
#dataWithPagination<T>(data: T[], pagination?: PaginationOptions) {

src/cli.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,22 @@ export const cliOptions = {
157157
describe: 'Whether to enable vision tools',
158158
hidden: true,
159159
},
160+
experimentalStructuredContent: {
161+
type: 'boolean',
162+
describe: 'Whether to output structured formatted content.',
163+
hidden: true,
164+
},
160165
experimentalIncludeAllPages: {
161166
type: 'boolean',
162167
describe:
163168
'Whether to include all kinds of pages such as webviews or background pages as pages.',
164169
hidden: true,
165170
},
171+
experimentalInteropTools: {
172+
type: 'boolean',
173+
describe: 'Whether to enable interoperability tools',
174+
hidden: true,
175+
},
166176
chromeArg: {
167177
type: 'array',
168178
describe:
@@ -188,6 +198,13 @@ export const cliOptions = {
188198
default: true,
189199
describe: 'Set to false to exclude tools related to network.',
190200
},
201+
usageStatistics: {
202+
type: 'boolean',
203+
// Marked as `false` until the feature is ready to be enabled by default.
204+
default: false,
205+
hidden: true,
206+
describe: 'Set to false to opt-out of usage statistics collection.',
207+
},
191208
} satisfies Record<string, YargsOptions>;
192209

193210
export function parseArguments(version: string, argv = process.argv) {
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
import type {TextSnapshot, TextSnapshotNode} from '../McpContext.js';
7+
8+
export class SnapshotFormatter {
9+
#snapshot: TextSnapshot;
10+
11+
constructor(snapshot: TextSnapshot) {
12+
this.#snapshot = snapshot;
13+
}
14+
15+
toString(): string {
16+
const chunks: string[] = [];
17+
const root = this.#snapshot.root;
18+
19+
// Top-level content of the snapshot.
20+
if (
21+
this.#snapshot.verbose &&
22+
this.#snapshot.hasSelectedElement &&
23+
!this.#snapshot.selectedElementUid
24+
) {
25+
chunks.push(`Note: there is a selected element in the DevTools Elements panel but it is not included into the current a11y tree snapshot.
26+
Get a verbose snapshot to include all elements if you are interested in the selected element.\n\n`);
27+
}
28+
29+
chunks.push(this.#formatNode(root, 0));
30+
return chunks.join('');
31+
}
32+
33+
toJSON(): object {
34+
return this.#nodeToJSON(this.#snapshot.root);
35+
}
36+
37+
#formatNode(node: TextSnapshotNode, depth = 0): string {
38+
const chunks: string[] = [];
39+
const attributes = this.#getAttributes(node);
40+
const line =
41+
' '.repeat(depth * 2) +
42+
attributes.join(' ') +
43+
(node.id === this.#snapshot.selectedElementUid
44+
? ' [selected in the DevTools Elements panel]'
45+
: '') +
46+
'\n';
47+
chunks.push(line);
48+
49+
for (const child of node.children) {
50+
chunks.push(this.#formatNode(child, depth + 1));
51+
}
52+
return chunks.join('');
53+
}
54+
55+
#nodeToJSON(node: TextSnapshotNode): object {
56+
const rawAttrs = this.#getAttributesMap(node);
57+
const children = node.children.map(child => this.#nodeToJSON(child));
58+
const result: Record<string, unknown> = structuredClone(rawAttrs);
59+
if (children.length > 0) {
60+
result.children = children;
61+
}
62+
return result;
63+
}
64+
65+
#getAttributes(serializedAXNodeRoot: TextSnapshotNode): string[] {
66+
const attributes = [`uid=${serializedAXNodeRoot.id}`];
67+
68+
if (serializedAXNodeRoot.role) {
69+
attributes.push(
70+
serializedAXNodeRoot.role === 'none'
71+
? 'ignored'
72+
: serializedAXNodeRoot.role,
73+
);
74+
}
75+
if (serializedAXNodeRoot.name) {
76+
attributes.push(`"${serializedAXNodeRoot.name}"`);
77+
}
78+
79+
const simpleAttrs = this.#getAttributesMap(
80+
serializedAXNodeRoot,
81+
/* excludeSpecial */ true,
82+
);
83+
84+
for (const attr of Object.keys(serializedAXNodeRoot).sort()) {
85+
if (excludedAttributes.has(attr)) {
86+
continue;
87+
}
88+
89+
const mapped = booleanPropertyMap[attr];
90+
if (mapped && simpleAttrs[mapped]) {
91+
attributes.push(mapped);
92+
}
93+
94+
const val = simpleAttrs[attr];
95+
if (val === true) {
96+
attributes.push(attr);
97+
} else if (typeof val === 'string' || typeof val === 'number') {
98+
attributes.push(`${attr}="${val}"`);
99+
}
100+
}
101+
102+
return attributes;
103+
}
104+
105+
#getAttributesMap(
106+
node: TextSnapshotNode,
107+
excludeSpecial = false,
108+
): Record<string, unknown> {
109+
const result: Record<string, unknown> = {};
110+
if (!excludeSpecial) {
111+
result.id = node.id;
112+
if (node.role) {
113+
result.role = node.role;
114+
}
115+
if (node.name) {
116+
result.name = node.name;
117+
}
118+
}
119+
120+
// Re-implementing the exact logic from original function for #getAttributes to be safe:
121+
return {
122+
...result,
123+
...this.#extractedAttributes(node),
124+
};
125+
}
126+
127+
#extractedAttributes(node: TextSnapshotNode): Record<string, unknown> {
128+
const result: Record<string, unknown> = {};
129+
130+
for (const attr of Object.keys(node).sort()) {
131+
if (excludedAttributes.has(attr)) {
132+
continue;
133+
}
134+
const value = (node as unknown as Record<string, unknown>)[attr];
135+
if (typeof value === 'boolean') {
136+
if (booleanPropertyMap[attr]) {
137+
result[booleanPropertyMap[attr]] = true;
138+
}
139+
if (value) {
140+
result[attr] = true;
141+
}
142+
} else if (typeof value === 'string' || typeof value === 'number') {
143+
result[attr] = value;
144+
}
145+
}
146+
return result;
147+
}
148+
}
149+
150+
const booleanPropertyMap: Record<string, string> = {
151+
disabled: 'disableable',
152+
expanded: 'expandable',
153+
focused: 'focusable',
154+
selected: 'selectable',
155+
};
156+
157+
const excludedAttributes = new Set([
158+
'id',
159+
'role',
160+
'name',
161+
'elementHandle',
162+
'children',
163+
'backendNodeId',
164+
]);

0 commit comments

Comments
 (0)