mirror of
https://github.com/ChromeDevTools/chrome-devtools-mcp.git
synced 2026-09-29 16:58:10 +08:00
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:
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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.`);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
`;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user