chore: implement bridge and DevTools comments tools (#2694)

This is part 3 of stacked PRs for DevTools comments support.

Implements `DevToolsCommentBridge`, `McpPage` and `McpContext` bridge
integration, full handlers for comment tools.

### Stack
1. #2692 chore: add hidden devtoolsComments flag
2. #2693 chore: add comments and open_devtools tools without
implementation
3. **#this PR**: chore: implement bridge and DevTools comments tools
4. Skill PR
This commit is contained in:
Alex Rudenko
2026-09-09 13:22:41 +00:00
committed by GitHub
parent fb47e6ca31
commit d7d6d31db3
11 changed files with 996 additions and 25 deletions
+3
View File
@@ -75,6 +75,8 @@ interface McpContextOptions {
navigationTimeout?: number;
// Whether extension tools and targets are enabled.
categoryExtensions?: boolean;
// Callback when a notification should be emitted to MCP client.
onNotification?: (message: string) => void;
}
// Page ids are handed out from a process-wide counter so they stay unique
@@ -570,6 +572,7 @@ export class McpContext implements Context {
),
navigationTimeout: this.#options.navigationTimeout,
sourceMaps: this.#options.sourceMaps,
onNotification: this.#options.onNotification,
});
this.#mcpPages.set(page, mcpPage);
await mcpPage.init();
+72
View File
@@ -55,6 +55,7 @@ export function replaceHtmlElementsWithUids(schema: JSONSchema7Definition) {
}
}
import {DevToolsCommentBridge} from './devtools/DevToolsCommentBridge.js';
import {
createTargetUniverse,
type TargetUniverse,
@@ -139,6 +140,8 @@ export class McpPage implements ContextPage {
#locatorClass: typeof Locator;
#navigationTimeout: number;
#sourceMaps: boolean;
#commentBridge?: DevToolsCommentBridge;
#onNotification?: (message: string) => void;
constructor(
page: Page,
@@ -149,12 +152,14 @@ export class McpPage implements ContextPage {
isolatedContextName?: string;
navigationTimeout?: number;
sourceMaps?: boolean;
onNotification?: (message: string) => void;
},
) {
this.#hasNetworkBlockOrAllowlist = options.hasNetworkBlockOrAllowlist;
this.#locatorClass = options.locatorClass;
this.#navigationTimeout = options.navigationTimeout ?? NAVIGATION_TIMEOUT;
this.#sourceMaps = options.sourceMaps ?? true;
this.#onNotification = options.onNotification;
this.pptrPage = page;
this.id = id;
this.isolatedContextName = options.isolatedContextName;
@@ -352,10 +357,32 @@ export class McpPage implements ContextPage {
return this.networkCollector.getIdForResource(request);
}
resolveReqidToCdpRequestId(reqid: number): string | undefined {
const request = this.networkCollector.getById(reqid);
if (!request) {
return undefined;
}
// @ts-expect-error id is internal.
return request.id;
}
getNetworkRequests(includePreservedRequests?: boolean): HTTPRequest[] {
return this.networkCollector.getData(includePreservedRequests);
}
get commentBridge(): DevToolsCommentBridge | undefined {
return this.#commentBridge;
}
async ensureDevToolsCommentBridge(devtoolsPage: Page): Promise<void> {
if (!this.#commentBridge) {
this.#commentBridge = new DevToolsCommentBridge({
onNotification: this.#onNotification,
});
}
await this.#commentBridge.attach(devtoolsPage);
}
async getDevToolsPage(): Promise<Page | undefined> {
try {
if (await this.pptrPage.hasDevTools()) {
@@ -370,6 +397,12 @@ export class McpPage implements ContextPage {
}
}
async openDevTools(): Promise<Page | undefined> {
const devtoolsPage = await this.pptrPage.openDevTools();
await this.ensureDevToolsCommentBridge(devtoolsPage);
return devtoolsPage;
}
getConsoleData(
includePreservedMessages?: boolean,
): Array<ConsoleMessage | Error | DevTools.AggregatedIssue | UncaughtError> {
@@ -436,6 +469,8 @@ export class McpPage implements ContextPage {
}
dispose(): void {
this.#commentBridge?.dispose();
this.#commentBridge = undefined;
this.pptrPage.off('dialog', this.#dialogHandler);
this.networkCollector.dispose();
this.consoleCollector.dispose();
@@ -676,6 +711,43 @@ export class McpPage implements ContextPage {
return this.textSnapshot?.idToNode.get(uid);
}
async resolveBackendNodeId(
backendNodeId: number,
): Promise<string | undefined> {
if (!this.textSnapshot) {
this.textSnapshot = await TextSnapshot.create(this);
}
let id = this.textSnapshot.resolveCdpElementId(backendNodeId);
if (!id) {
this.textSnapshot = await TextSnapshot.create(this);
id = this.textSnapshot.resolveCdpElementId(backendNodeId);
}
return id;
}
async resolveUidToBackendNodeId(
uid: string,
): Promise<{backendNodeId: number; targetId?: string} | undefined> {
const target = this.pptrPage.target();
const targetId =
Boolean(target) &&
'_targetId' in target &&
typeof target._targetId === 'string'
? target._targetId
: undefined;
const node = this.getAXNodeByUid(uid);
if (node?.backendNodeId !== undefined) {
return {backendNodeId: node.backendNodeId, targetId};
}
try {
const handle = await this.getElementByUid(uid);
const backendNodeId = await handle.backendNodeId();
return backendNodeId ? {backendNodeId, targetId} : undefined;
} catch {
return undefined;
}
}
async getDevToolsData(): Promise<DevToolsData> {
try {
logger?.('Getting DevTools UI data');
+107
View File
@@ -0,0 +1,107 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {Page} from '../third_party/index.js';
import {logger} from '../utils/logger.js';
export interface DevToolsCommentBridgeOptions {
onNotification?: (message: string) => void;
debounceMs?: number;
}
const DEFAULT_DEBOUNCE_MS = 200;
/**
* Manages the bidirectional bridge between DevTools frontend comments
* and the MCP server notification system.
*/
export class DevToolsCommentBridge {
readonly #onNotification?: (message: string) => void;
readonly #debounceMs: number;
#commentDebounceTimer?: NodeJS.Timeout;
readonly #attachedPages = new Set<Page>();
constructor(options?: DevToolsCommentBridgeOptions) {
this.#onNotification = options?.onNotification;
this.#debounceMs = options?.debounceMs ?? DEFAULT_DEBOUNCE_MS;
}
isAttached(devtoolsPage: Page): boolean {
return this.#attachedPages.has(devtoolsPage);
}
async attach(devtoolsPage: Page): Promise<void> {
if (this.#attachedPages.has(devtoolsPage)) {
return;
}
this.#attachedPages.add(devtoolsPage);
try {
await devtoolsPage.exposeFunction('__onDevToolsCommentEvent', () => {
this.#handleCommentEvent();
});
} catch (e) {
logger?.(
'DevToolsCommentBridge: exposeFunction already bound or failed',
e,
);
}
try {
await devtoolsPage.evaluate(() => {
window.__onDevToolsCommentListener = () => {
window.__onDevToolsCommentEvent?.();
};
window.universe?.cd4aBridge?.addEventListener(
'CommentThreadsChanged',
window.__onDevToolsCommentListener,
);
});
} catch (e) {
logger?.('DevToolsCommentBridge: evaluate failed', e);
}
}
#handleCommentEvent(): void {
if (this.#commentDebounceTimer) {
clearTimeout(this.#commentDebounceTimer);
}
this.#commentDebounceTimer = setTimeout(() => {
this.#onNotification?.('DevTools comment threads updated');
}, this.#debounceMs);
}
dispose(): void {
if (this.#commentDebounceTimer) {
clearTimeout(this.#commentDebounceTimer);
this.#commentDebounceTimer = undefined;
}
for (const page of this.#attachedPages) {
try {
const result = page.evaluate(() => {
if (window.__onDevToolsCommentListener) {
window.universe?.cd4aBridge?.removeEventListener?.(
'CommentThreadsChanged',
window.__onDevToolsCommentListener,
);
delete window.__onDevToolsCommentListener;
}
});
if (result && typeof result.catch === 'function') {
void result.catch(e => {
logger?.(
'DevToolsCommentBridge: failed to remove event listener',
e,
);
});
}
} catch (e) {
logger?.('DevToolsCommentBridge: failed to remove event listener', e);
}
}
this.#attachedPages.clear();
}
}
+10
View File
@@ -266,6 +266,16 @@ export class McpServer {
// Surfaces a one-time note in the next response after a reconnect.
reconnected: this.#context !== undefined,
categoryExtensions: this.#serverArgs.categoryExtensions,
onNotification: (message: string) => {
void this.server
.sendLoggingMessage({
level: 'info',
data: message,
})
.catch(e => {
logger?.('Failed to send MCP notification', e);
});
},
});
this.#context.setRoots(this.#combinedRoots());
if (this.#lastClientRoots === undefined) {
+7
View File
@@ -336,6 +336,11 @@ export type ContextPage = Readonly<{
* Returns a reqid for a cdpRequestId.
*/
resolveCdpRequestId(cdpRequestId: string): number | undefined;
resolveReqidToCdpRequestId(reqid: number): string | undefined;
resolveBackendNodeId(backendNodeId: number): Promise<string | undefined>;
resolveUidToBackendNodeId(
uid: string,
): Promise<{backendNodeId: number; targetId?: string} | undefined>;
getDialog(): Dialog | undefined;
clearDialog(): void;
@@ -367,6 +372,8 @@ export type ContextPage = Readonly<{
viewport?: Viewport;
}): Promise<void>;
waitForTextOnPage(text: string[], timeout?: number): Promise<Element>;
getDevToolsPage(): Promise<Page | undefined>;
openDevTools(): Promise<Page | undefined>;
}>;
export function defineTool<Schema extends zod.ZodRawShape>(
+161 -8
View File
@@ -6,9 +6,19 @@
import {zod} from '../third_party/index.js';
import type {
CD4ACommentThread,
CD4AEditorAnchorSignature,
CD4ARevealTarget,
} from '../types.js';
import {ToolCategory} from './categories.js';
import {definePageTool} from './ToolDefinition.js';
export type CommentThreadPayload = CD4ACommentThread;
export type CommentEditorPayload = CD4AEditorAnchorSignature;
export type RevealTargetPayload = CD4ARevealTarget;
export const openDevtools = definePageTool({
name: 'open_devtools',
description: 'Open a DevTools window for the selected page.',
@@ -20,8 +30,16 @@ export const openDevtools = definePageTool({
schema: {},
blockedByDialog: false,
verifyFilesSchema: [],
handler: async () => {
throw new Error('Not implemented');
handler: async (request, response) => {
const page = request.page;
try {
await page.openDevTools();
response.appendResponseLine('DevTools window opened successfully.');
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
response.appendResponseLine(`Failed to open DevTools: ${message}`);
}
response.setIncludePages(true);
},
});
@@ -36,8 +54,54 @@ export const getDevtoolsComments = definePageTool({
schema: {},
blockedByDialog: false,
verifyFilesSchema: {},
handler: async () => {
throw new Error('Not implemented');
handler: async (request, response) => {
const page = request.page;
const devtoolsPage = await page.getDevToolsPage();
if (!devtoolsPage) {
response.appendResponseLine(
'DevTools window is not open for this page. Call open_devtools first to open DevTools.',
);
return;
}
const threads = await devtoolsPage.evaluate(() => {
return window.universe?.cd4aBridge?.getCommentThreads() ?? [];
});
if (threads.length === 0) {
response.appendResponseLine('No open DevTools comments found.');
return;
}
response.appendResponseLine(
`Found ${threads.length} DevTools comment thread(s):`,
);
for (const thread of threads) {
response.appendResponseLine(`\n### Thread: ${thread.id}`);
response.appendResponseLine(`- Comment: ${thread.text}`);
if (thread.backendNodeId !== undefined) {
const elementUid = await page.resolveBackendNodeId(
thread.backendNodeId,
);
if (elementUid) {
response.appendResponseLine(
`- Target element (snapshot UID): ${elementUid}`,
);
}
}
if (thread.networkRequestId) {
const reqid = page.resolveCdpRequestId(thread.networkRequestId);
if (reqid !== undefined) {
response.appendResponseLine(`- Network request ID (reqid): ${reqid}`);
}
}
if (thread.editor) {
const location = thread.editor.filePath
? `${thread.editor.filePath}:${thread.editor.lineNumber}`
: `line ${thread.editor.lineNumber}`;
response.appendResponseLine(`- Editor location: ${location}`);
}
}
},
});
@@ -65,8 +129,39 @@ export const resolveDevtoolsComment = definePageTool({
},
blockedByDialog: false,
verifyFilesSchema: {},
handler: async () => {
throw new Error('Not implemented');
handler: async (request, response) => {
const page = request.page;
const devtoolsPage = await page.getDevToolsPage();
if (!devtoolsPage) {
response.appendResponseLine(
'DevTools window is not open for this page. Call open_devtools first to open DevTools.',
);
return;
}
const {threadId, replyText} = request.params;
const success = await devtoolsPage.evaluate(
(id: string, reply: string | undefined) => {
return (
window.universe?.cd4aBridge?.resolveCommentThread(id, reply) ?? false
);
},
threadId,
replyText,
);
if (success) {
response.appendResponseLine(
`Comment thread ${threadId} resolved successfully.`,
);
if (replyText) {
response.appendResponseLine(`Agent reply added: "${replyText}"`);
}
} else {
response.appendResponseLine(
`Failed to resolve comment thread "${threadId}". Thread not found.`,
);
}
},
});
@@ -99,7 +194,65 @@ export const revealInDevtools = definePageTool({
},
blockedByDialog: false,
verifyFilesSchema: {},
handler: async () => {
throw new Error('Not implemented');
handler: async (request, response) => {
const page = request.page;
const devtoolsPage = await page.getDevToolsPage();
if (!devtoolsPage) {
response.appendResponseLine(
'DevTools window is not open for this page. Call open_devtools first to open DevTools.',
);
return;
}
const {panelName, uid, reqid} = request.params;
let backendNodeId: number | undefined;
let targetId: string | undefined;
let networkRequestId: string | undefined;
if (uid) {
const resolved = await page.resolveUidToBackendNodeId(uid);
if (resolved) {
backendNodeId = resolved.backendNodeId;
targetId = resolved.targetId;
} else {
response.appendResponseLine(
`Warning: Could not resolve snapshot UID "${uid}" to a backend DOM node ID.`,
);
}
}
if (reqid !== undefined) {
const resolvedCdpRequestId = page.resolveReqidToCdpRequestId(reqid);
if (resolvedCdpRequestId !== undefined) {
networkRequestId = resolvedCdpRequestId;
} else {
response.appendResponseLine(
`Warning: Could not resolve network request ID ${reqid} to a CDP request ID.`,
);
}
}
await devtoolsPage.evaluate(
async (panel: string | undefined, target: CD4ARevealTarget) => {
await window.universe?.cd4aBridge?.reveal(panel, target);
},
panelName,
{backendNodeId, targetId, networkRequestId},
);
let targetDesc = '';
if (uid && backendNodeId !== undefined) {
targetDesc = ` (revealing element ${uid} [backend node ${backendNodeId}])`;
} else if (reqid !== undefined && networkRequestId) {
targetDesc = ` (revealing network request ${reqid} [${networkRequestId}])`;
}
if (panelName) {
response.appendResponseLine(
`Navigated to ${panelName} panel${targetDesc} in DevTools.`,
);
} else {
response.appendResponseLine(`Revealed target${targetDesc} in DevTools.`);
}
},
});
+55
View File
@@ -40,3 +40,58 @@ export interface PaginationOptions {
pageSize?: number;
pageIdx?: number;
}
export interface CD4AEditorAnchorSignature {
/** 1-based line number for CodeMirror text editor anchors */
lineNumber: number;
/** File path associated with the editor */
filePath?: string;
}
export interface CD4ACommentThread {
id: string;
text: string;
networkRequestId?: string;
backendNodeId?: number;
editor?: CD4AEditorAnchorSignature;
}
export interface CD4ARevealTarget {
networkRequestId?: string;
backendNodeId?: number;
targetId?: string;
}
export enum CD4ABridgeEvents {
COMMENT_THREADS_CHANGED = 'CommentThreadsChanged',
}
export interface CD4ABridge {
dispose?(): void;
getCommentThreads(): CD4ACommentThread[];
takeComments(): CD4ACommentThread[];
resolveCommentThread(threadId: string, replyText?: string): boolean;
reveal(panelName?: string, target?: CD4ARevealTarget): Promise<void>;
addEventListener(
event: CD4ABridgeEvents | 'CommentThreadsChanged' | string,
listener: () => void,
): void;
removeEventListener?(
event: CD4ABridgeEvents | 'CommentThreadsChanged' | string,
listener: () => void,
): void;
}
export type CommentThread = CD4ACommentThread;
export type RevealTarget = CD4ARevealTarget;
export type EditorAnchorSignature = CD4AEditorAnchorSignature;
declare global {
interface Window {
universe?: {
cd4aBridge?: CD4ABridge | null;
};
__onDevToolsCommentEvent?: () => void;
__onDevToolsCommentListener?: () => void;
}
}
+65 -17
View File
@@ -283,28 +283,26 @@ describe('McpPage', () => {
});
});
function createMcpPage(options: {hasNetworkBlockOrAllowlist?: boolean} = {}) {
const pptrPage = createMockPuppeteerPage();
const mcpPage = new McpPage(pptrPage as unknown as Page, 1, {
hasNetworkBlockOrAllowlist: options.hasNetworkBlockOrAllowlist ?? false,
locatorClass: Locator,
});
const mockSession = {
send: sinon.stub().resolves(),
};
sinon
.stub(mcpPage, 'devtoolsUniverse')
.get(() => ({session: mockSession}) as unknown as TargetUniverse);
return {mcpPage, pptrPage, mockSession};
}
describe('emulate()', () => {
afterEach(() => {
sinon.restore();
});
function createMcpPage(
options: {hasNetworkBlockOrAllowlist?: boolean} = {},
) {
const pptrPage = createMockPuppeteerPage();
const mcpPage = new McpPage(pptrPage as unknown as Page, 1, {
hasNetworkBlockOrAllowlist: options.hasNetworkBlockOrAllowlist ?? false,
locatorClass: Locator,
});
const mockSession = {
send: sinon.stub().resolves(),
};
sinon
.stub(mcpPage, 'devtoolsUniverse')
.get(() => ({session: mockSession}) as unknown as TargetUniverse);
return {mcpPage, pptrPage, mockSession};
}
it('calls emulateNetworkConditions with offline settings', async () => {
const {mcpPage, pptrPage} = createMcpPage();
await mcpPage.emulate({networkConditions: 'Offline'});
@@ -623,4 +621,54 @@ describe('McpPage', () => {
});
});
});
describe('DevToolsCommentBridge lifecycle', () => {
afterEach(() => {
sinon.restore();
});
it('does not create commentBridge on construction or getDevToolsPage', async () => {
const {mcpPage, pptrPage} = createMcpPage();
pptrPage.hasDevTools.resolves(true);
const devtoolsPage = createMockPuppeteerPage();
pptrPage.openDevTools.resolves(devtoolsPage);
assert.strictEqual(mcpPage.commentBridge, undefined);
const retrieved = await mcpPage.getDevToolsPage();
assert.strictEqual(retrieved, devtoolsPage);
assert.strictEqual(mcpPage.commentBridge, undefined);
});
it('creates and attaches commentBridge when openDevTools is called', async () => {
const {mcpPage, pptrPage} = createMcpPage();
const devtoolsPage = createMockPuppeteerPage();
pptrPage.openDevTools.resolves(devtoolsPage);
assert.strictEqual(mcpPage.commentBridge, undefined);
const result = await mcpPage.openDevTools();
assert.strictEqual(result, devtoolsPage);
assert.notStrictEqual(mcpPage.commentBridge, undefined);
sinon.assert.calledOnce(devtoolsPage.exposeFunction);
});
it('disposes commentBridge on mcpPage.dispose()', async () => {
const {mcpPage, pptrPage} = createMcpPage();
const devtoolsPage = createMockPuppeteerPage();
pptrPage.openDevTools.resolves(devtoolsPage);
await mcpPage.openDevTools();
const bridge = mcpPage.commentBridge;
assert.notStrictEqual(bridge, undefined);
if (bridge) {
const disposeSpy = sinon.spy(bridge, 'dispose');
mcpPage.dispose();
sinon.assert.calledOnce(disposeSpy);
assert.strictEqual(mcpPage.commentBridge, undefined);
}
});
});
});
@@ -0,0 +1,122 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import {afterEach, beforeEach, describe, it} from 'node:test';
import sinon from 'sinon';
import {DevToolsCommentBridge} from '../../src/devtools/DevToolsCommentBridge.js';
import {createMockPuppeteerPage} from '../mocks.js';
describe('DevToolsCommentBridge', () => {
let clock: sinon.SinonFakeTimers;
beforeEach(() => {
clock = sinon.useFakeTimers();
});
afterEach(() => {
clock.restore();
sinon.restore();
});
it('attaches to DevTools page and registers function and evaluation', async () => {
const devtoolsPage = createMockPuppeteerPage();
const bridge = new DevToolsCommentBridge();
assert.strictEqual(bridge.isAttached(devtoolsPage), false);
await bridge.attach(devtoolsPage);
assert.strictEqual(bridge.isAttached(devtoolsPage), true);
sinon.assert.calledOnce(devtoolsPage.exposeFunction);
sinon.assert.calledOnce(devtoolsPage.evaluate);
});
it('is idempotent when attaching to the same DevTools page', async () => {
const devtoolsPage = createMockPuppeteerPage();
const bridge = new DevToolsCommentBridge();
await bridge.attach(devtoolsPage);
await bridge.attach(devtoolsPage);
sinon.assert.calledOnce(devtoolsPage.exposeFunction);
sinon.assert.calledOnce(devtoolsPage.evaluate);
});
it('debounces comment notifications', async () => {
const devtoolsPage = createMockPuppeteerPage();
const onNotification = sinon.stub();
let exposedCallback: (() => void) | undefined;
devtoolsPage.exposeFunction.callsFake((name: string, fn: unknown) => {
if (name === '__onDevToolsCommentEvent' && typeof fn === 'function') {
exposedCallback = () => {
fn();
};
}
return Promise.resolve();
});
const bridge = new DevToolsCommentBridge({
onNotification,
debounceMs: 150,
});
await bridge.attach(devtoolsPage);
assert.strictEqual(typeof exposedCallback, 'function');
if (exposedCallback) {
// Trigger multiple times rapidly
exposedCallback();
exposedCallback();
exposedCallback();
}
clock.tick(100);
sinon.assert.notCalled(onNotification);
clock.tick(60);
sinon.assert.calledOnceWithExactly(
onNotification,
'DevTools comment threads updated',
);
});
it('clears debounce timer on dispose', async () => {
const devtoolsPage = createMockPuppeteerPage();
const onNotification = sinon.stub();
let exposedCallback: (() => void) | undefined;
devtoolsPage.exposeFunction.callsFake((name: string, fn: unknown) => {
if (name === '__onDevToolsCommentEvent' && typeof fn === 'function') {
exposedCallback = () => {
fn();
};
}
return Promise.resolve();
});
const bridge = new DevToolsCommentBridge({
onNotification,
debounceMs: 150,
});
await bridge.attach(devtoolsPage);
assert.strictEqual(typeof exposedCallback, 'function');
if (exposedCallback) {
exposedCallback();
}
bridge.dispose();
clock.tick(200);
sinon.assert.notCalled(onNotification);
sinon.assert.calledTwice(devtoolsPage.evaluate);
});
});
+55
View File
@@ -0,0 +1,55 @@
exports[`comments tools > get_devtools_comments > omits target element and network request ID if resolution returns undefined 1`] = `
Found 1 DevTools comment thread(s):
### Thread: comment-2
- Comment: Fix heading font size
`;
exports[`comments tools > get_devtools_comments > formats comment threads with text, targets, and editor location 1`] = `
Found 1 DevTools comment thread(s):
### Thread: comment-1
- Comment: Fix the color contrast here
- Target element (snapshot UID): element-uid-42
- Network request ID (reqid): 7
- Editor location: src/style.css:10
`;
exports[`comments tools > get_devtools_comments > reports error when DevTools window is not open 1`] = `
DevTools window is not open for this page. Call open_devtools first to open DevTools.
`;
exports[`comments tools > get_devtools_comments > reports message when no comments are found 1`] = `
No open DevTools comments found.
`;
exports[`comments tools > resolve_devtools_comment > reports error when DevTools window is not open 1`] = `
DevTools window is not open for this page. Call open_devtools first to open DevTools.
`;
exports[`comments tools > resolve_devtools_comment > reports error when thread is not found 1`] = `
Failed to resolve comment thread "comment-nonexistent". Thread not found.
`;
exports[`comments tools > resolve_devtools_comment > resolves comment thread and appends reply text 1`] = `
Comment thread comment-1 resolved successfully.
Agent reply added: "Updated background color in index.css"
`;
exports[`comments tools > reveal_in_devtools > reports error when DevTools window is not open 1`] = `
DevTools window is not open for this page. Call open_devtools first to open DevTools.
`;
exports[`comments tools > reveal_in_devtools > reveals element by snapshot uid 1`] = `
Navigated to elements panel (revealing element uid-header [backend node 101]) in DevTools.
`;
exports[`comments tools > reveal_in_devtools > reveals network request by reqid 1`] = `
Navigated to network panel (revealing network request 5 [cdp-req-123]) in DevTools.
`;
exports[`comments tools > reveal_in_devtools > warns when snapshot uid or reqid cannot be resolved 1`] = `
Warning: Could not resolve snapshot UID "unknown-uid" to a backend DOM node ID.
Warning: Could not resolve network request ID 999 to a CDP request ID.
Navigated to elements panel in DevTools.
`;
+339
View File
@@ -0,0 +1,339 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {afterEach, describe, it} from 'node:test';
import sinon from 'sinon';
import type {CommentThreadPayload} from '../../src/tools/comments.js';
import {
getDevtoolsComments,
openDevtools,
resolveDevtoolsComment,
revealInDevtools,
} from '../../src/tools/comments.js';
import {createHandlerMocks, createMockPuppeteerPage} from '../mocks.js';
function trackResponseLines(
response: ReturnType<typeof createHandlerMocks>['response'],
): string[] {
const lines: string[] = [];
response.appendResponseLine.callsFake((line: string) => {
lines.push(line);
});
return lines;
}
describe('comments tools', () => {
afterEach(() => {
sinon.restore();
});
describe('get_devtools_comments', () => {
it('reports error when DevTools window is not open', async t => {
const {page, context, response} = createHandlerMocks();
const lines = trackResponseLines(response);
page.getDevToolsPage.resolves(undefined);
await getDevtoolsComments.handler({params: {}, page}, response, context);
sinon.assert.calledOnce(page.getDevToolsPage);
sinon.assert.calledOnceWithExactly(
response.appendResponseLine,
'DevTools window is not open for this page. Call open_devtools first to open DevTools.',
);
t.assert.snapshot(lines.join('\n'));
});
it('reports message when no comments are found', async t => {
const {page, context, response} = createHandlerMocks();
const lines = trackResponseLines(response);
const devtoolsPage = createMockPuppeteerPage();
page.getDevToolsPage.resolves(devtoolsPage);
devtoolsPage.evaluate.resolves([]);
await getDevtoolsComments.handler({params: {}, page}, response, context);
sinon.assert.calledOnce(page.getDevToolsPage);
sinon.assert.calledOnce(devtoolsPage.evaluate);
sinon.assert.calledOnceWithExactly(
response.appendResponseLine,
'No open DevTools comments found.',
);
t.assert.snapshot(lines.join('\n'));
});
it('formats comment threads with text, targets, and editor location', async t => {
const {page, context, response} = createHandlerMocks();
const lines = trackResponseLines(response);
const devtoolsPage = createMockPuppeteerPage();
page.getDevToolsPage.resolves(devtoolsPage);
page.resolveBackendNodeId.resolves('element-uid-42');
page.resolveCdpRequestId.returns(7);
const mockThread: CommentThreadPayload = {
id: 'comment-1',
text: 'Fix the color contrast here',
backendNodeId: 42,
networkRequestId: 'req-99',
editor: {
filePath: 'src/style.css',
lineNumber: 10,
},
};
devtoolsPage.evaluate.resolves([mockThread]);
await getDevtoolsComments.handler({params: {}, page}, response, context);
sinon.assert.calledOnce(page.getDevToolsPage);
sinon.assert.calledOnce(devtoolsPage.evaluate);
sinon.assert.calledOnceWithExactly(page.resolveBackendNodeId, 42);
sinon.assert.calledOnceWithExactly(page.resolveCdpRequestId, 'req-99');
t.assert.snapshot(lines.join('\n'));
});
it('omits target element and network request ID if resolution returns undefined', async t => {
const {page, context, response} = createHandlerMocks();
const lines = trackResponseLines(response);
const devtoolsPage = createMockPuppeteerPage();
page.getDevToolsPage.resolves(devtoolsPage);
page.resolveBackendNodeId.resolves(undefined);
page.resolveCdpRequestId.returns(undefined);
const mockThread: CommentThreadPayload = {
id: 'comment-2',
text: 'Fix heading font size',
backendNodeId: 42,
networkRequestId: 'req-99',
};
devtoolsPage.evaluate.resolves([mockThread]);
await getDevtoolsComments.handler({params: {}, page}, response, context);
t.assert.snapshot(lines.join('\n'));
});
});
describe('resolve_devtools_comment', () => {
it('reports error when DevTools window is not open', async t => {
const {page, context, response} = createHandlerMocks();
const lines = trackResponseLines(response);
page.getDevToolsPage.resolves(undefined);
await resolveDevtoolsComment.handler(
{params: {threadId: 'comment-1'}, page},
response,
context,
);
sinon.assert.calledOnce(page.getDevToolsPage);
t.assert.snapshot(lines.join('\n'));
});
it('resolves comment thread and appends reply text', async t => {
const {page, context, response} = createHandlerMocks();
const lines = trackResponseLines(response);
const devtoolsPage = createMockPuppeteerPage();
page.getDevToolsPage.resolves(devtoolsPage);
devtoolsPage.evaluate.resolves(true);
await resolveDevtoolsComment.handler(
{
params: {
threadId: 'comment-1',
replyText: 'Updated background color in index.css',
},
page,
},
response,
context,
);
sinon.assert.calledOnce(devtoolsPage.evaluate);
t.assert.snapshot(lines.join('\n'));
});
it('reports error when thread is not found', async t => {
const {page, context, response} = createHandlerMocks();
const lines = trackResponseLines(response);
const devtoolsPage = createMockPuppeteerPage();
page.getDevToolsPage.resolves(devtoolsPage);
devtoolsPage.evaluate.resolves(false);
await resolveDevtoolsComment.handler(
{params: {threadId: 'comment-nonexistent'}, page},
response,
context,
);
sinon.assert.calledOnce(devtoolsPage.evaluate);
t.assert.snapshot(lines.join('\n'));
});
});
describe('reveal_in_devtools', () => {
it('reports error when DevTools window is not open', async t => {
const {page, context, response} = createHandlerMocks();
const lines = trackResponseLines(response);
page.getDevToolsPage.resolves(undefined);
await revealInDevtools.handler(
{params: {panelName: 'elements'}, page},
response,
context,
);
sinon.assert.calledOnce(page.getDevToolsPage);
t.assert.snapshot(lines.join('\n'));
});
it('reveals element by snapshot uid', async t => {
const {page, context, response} = createHandlerMocks();
const lines = trackResponseLines(response);
const devtoolsPage = createMockPuppeteerPage();
page.getDevToolsPage.resolves(devtoolsPage);
page.resolveUidToBackendNodeId.resolves({
backendNodeId: 101,
targetId: 'target-1',
});
devtoolsPage.evaluate.resolves(undefined);
await revealInDevtools.handler(
{
params: {
panelName: 'elements',
uid: 'uid-header',
},
page,
},
response,
context,
);
sinon.assert.calledOnceWithExactly(
page.resolveUidToBackendNodeId,
'uid-header',
);
sinon.assert.calledOnce(devtoolsPage.evaluate);
t.assert.snapshot(lines.join('\n'));
});
it('reveals network request by reqid', async t => {
const {page, context, response} = createHandlerMocks();
const lines = trackResponseLines(response);
const devtoolsPage = createMockPuppeteerPage();
page.getDevToolsPage.resolves(devtoolsPage);
page.resolveReqidToCdpRequestId.returns('cdp-req-123');
devtoolsPage.evaluate.resolves(undefined);
await revealInDevtools.handler(
{
params: {
panelName: 'network',
reqid: 5,
},
page,
},
response,
context,
);
sinon.assert.calledOnceWithExactly(page.resolveReqidToCdpRequestId, 5);
sinon.assert.calledOnce(devtoolsPage.evaluate);
t.assert.snapshot(lines.join('\n'));
});
it('warns when snapshot uid or reqid cannot be resolved', async t => {
const {page, context, response} = createHandlerMocks();
const lines = trackResponseLines(response);
const devtoolsPage = createMockPuppeteerPage();
page.getDevToolsPage.resolves(devtoolsPage);
page.resolveUidToBackendNodeId.resolves(undefined);
page.resolveReqidToCdpRequestId.returns(undefined);
devtoolsPage.evaluate.resolves(undefined);
await revealInDevtools.handler(
{
params: {
panelName: 'elements',
uid: 'unknown-uid',
reqid: 999,
},
page,
},
response,
context,
);
t.assert.snapshot(lines.join('\n'));
});
it('reveals element when panelName is omitted', async () => {
const {page, context, response} = createHandlerMocks();
const devtoolsPage = createMockPuppeteerPage();
page.getDevToolsPage.resolves(devtoolsPage);
page.resolveUidToBackendNodeId.resolves({
backendNodeId: 101,
targetId: 'target-1',
});
devtoolsPage.evaluate.resolves(undefined);
await revealInDevtools.handler(
{
params: {
uid: 'uid-header',
},
page,
},
response,
context,
);
sinon.assert.calledOnceWithExactly(
page.resolveUidToBackendNodeId,
'uid-header',
);
sinon.assert.calledOnce(devtoolsPage.evaluate);
sinon.assert.calledWithExactly(
response.appendResponseLine,
'Revealed target (revealing element uid-header [backend node 101]) in DevTools.',
);
});
});
describe('open_devtools', () => {
it('opens DevTools window successfully', async () => {
const {page, context, response} = createHandlerMocks();
const devtoolsPage = createMockPuppeteerPage();
page.openDevTools.resolves(devtoolsPage);
await openDevtools.handler({params: {}, page}, response, context);
sinon.assert.calledOnce(page.openDevTools);
sinon.assert.calledOnceWithExactly(
response.appendResponseLine,
'DevTools window opened successfully.',
);
sinon.assert.calledOnceWithExactly(response.setIncludePages, true);
});
it('reports failure when openDevTools throws', async () => {
const {page, context, response} = createHandlerMocks();
page.openDevTools.rejects(new Error('Connection closed'));
await openDevtools.handler({params: {}, page}, response, context);
sinon.assert.calledOnce(page.openDevTools);
sinon.assert.calledOnceWithExactly(
response.appendResponseLine,
'Failed to open DevTools: Connection closed',
);
sinon.assert.calledOnceWithExactly(response.setIncludePages, true);
});
});
});