@mcp-b/chrome-devtools-mcp 0.12.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +554 -0
- package/build/src/DevToolsConnectionAdapter.js +69 -0
- package/build/src/DevtoolsUtils.js +206 -0
- package/build/src/McpContext.js +499 -0
- package/build/src/McpResponse.js +396 -0
- package/build/src/Mutex.js +37 -0
- package/build/src/PageCollector.js +283 -0
- package/build/src/WaitForHelper.js +139 -0
- package/build/src/browser.js +134 -0
- package/build/src/cli.js +213 -0
- package/build/src/formatters/consoleFormatter.js +121 -0
- package/build/src/formatters/networkFormatter.js +77 -0
- package/build/src/formatters/snapshotFormatter.js +73 -0
- package/build/src/index.js +21 -0
- package/build/src/issue-descriptions.js +39 -0
- package/build/src/logger.js +27 -0
- package/build/src/main.js +130 -0
- package/build/src/polyfill.js +7 -0
- package/build/src/third_party/index.js +16 -0
- package/build/src/tools/ToolDefinition.js +20 -0
- package/build/src/tools/categories.js +24 -0
- package/build/src/tools/console.js +85 -0
- package/build/src/tools/emulation.js +87 -0
- package/build/src/tools/input.js +268 -0
- package/build/src/tools/network.js +106 -0
- package/build/src/tools/pages.js +237 -0
- package/build/src/tools/performance.js +147 -0
- package/build/src/tools/screenshot.js +84 -0
- package/build/src/tools/script.js +71 -0
- package/build/src/tools/snapshot.js +52 -0
- package/build/src/tools/tools.js +31 -0
- package/build/src/tools/webmcp.js +233 -0
- package/build/src/trace-processing/parse.js +84 -0
- package/build/src/transports/WebMCPBridgeScript.js +196 -0
- package/build/src/transports/WebMCPClientTransport.js +276 -0
- package/build/src/transports/index.js +7 -0
- package/build/src/utils/keyboard.js +296 -0
- package/build/src/utils/pagination.js +49 -0
- package/build/src/utils/types.js +6 -0
- package/package.json +87 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Google LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
import { DebuggerModel, Foundation, TargetManager, MarkdownIssueDescription, Marked, ProtocolClient, Common, I18n, } from '../node_modules/chrome-devtools-frontend/mcp/mcp.js';
|
|
7
|
+
import { PuppeteerDevToolsConnection } from './DevToolsConnectionAdapter.js';
|
|
8
|
+
import { ISSUE_UTILS } from './issue-descriptions.js';
|
|
9
|
+
import { logger } from './logger.js';
|
|
10
|
+
import { Mutex } from './Mutex.js';
|
|
11
|
+
export function extractUrlLikeFromDevToolsTitle(title) {
|
|
12
|
+
const match = title.match(new RegExp(`DevTools - (.*)`));
|
|
13
|
+
return match?.[1] ?? undefined;
|
|
14
|
+
}
|
|
15
|
+
export function urlsEqual(url1, url2) {
|
|
16
|
+
const normalizedUrl1 = normalizeUrl(url1);
|
|
17
|
+
const normalizedUrl2 = normalizeUrl(url2);
|
|
18
|
+
return normalizedUrl1 === normalizedUrl2;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* For the sake of the MCP server, when we determine if two URLs are equal we
|
|
22
|
+
* remove some parts:
|
|
23
|
+
*
|
|
24
|
+
* 1. We do not care about the protocol.
|
|
25
|
+
* 2. We do not care about trailing slashes.
|
|
26
|
+
* 3. We do not care about "www".
|
|
27
|
+
* 4. We ignore the hash parts.
|
|
28
|
+
*
|
|
29
|
+
* For example, if the user types "record a trace on foo.com", we would want to
|
|
30
|
+
* match a tab in the connected Chrome instance that is showing "www.foo.com/"
|
|
31
|
+
*/
|
|
32
|
+
function normalizeUrl(url) {
|
|
33
|
+
let result = url.trim();
|
|
34
|
+
// Remove protocols
|
|
35
|
+
if (result.startsWith('https://')) {
|
|
36
|
+
result = result.slice(8);
|
|
37
|
+
}
|
|
38
|
+
else if (result.startsWith('http://')) {
|
|
39
|
+
result = result.slice(7);
|
|
40
|
+
}
|
|
41
|
+
// Remove 'www.'. This ensures that we find the right URL regardless of if the user adds `www` or not.
|
|
42
|
+
if (result.startsWith('www.')) {
|
|
43
|
+
result = result.slice(4);
|
|
44
|
+
}
|
|
45
|
+
// We use target URLs to locate DevTools but those often do
|
|
46
|
+
// no include hash.
|
|
47
|
+
const hashIdx = result.lastIndexOf('#');
|
|
48
|
+
if (hashIdx !== -1) {
|
|
49
|
+
result = result.slice(0, hashIdx);
|
|
50
|
+
}
|
|
51
|
+
// Remove trailing slash
|
|
52
|
+
if (result.endsWith('/')) {
|
|
53
|
+
result = result.slice(0, -1);
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A mock implementation of an issues manager that only implements the methods
|
|
59
|
+
* that are actually used by the IssuesAggregator
|
|
60
|
+
*/
|
|
61
|
+
export class FakeIssuesManager extends Common.ObjectWrapper
|
|
62
|
+
.ObjectWrapper {
|
|
63
|
+
issues() {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function mapIssueToMessageObject(issue) {
|
|
68
|
+
const count = issue.getAggregatedIssuesCount();
|
|
69
|
+
const markdownDescription = issue.getDescription();
|
|
70
|
+
const filename = markdownDescription?.file;
|
|
71
|
+
if (!markdownDescription) {
|
|
72
|
+
logger(`no description found for issue:` + issue.code);
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const rawMarkdown = filename
|
|
76
|
+
? ISSUE_UTILS.getIssueDescription(filename)
|
|
77
|
+
: null;
|
|
78
|
+
if (!rawMarkdown) {
|
|
79
|
+
logger(`no markdown ${filename} found for issue:` + issue.code);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
let processedMarkdown;
|
|
83
|
+
let title;
|
|
84
|
+
try {
|
|
85
|
+
processedMarkdown = MarkdownIssueDescription.substitutePlaceholders(rawMarkdown, markdownDescription.substitutions);
|
|
86
|
+
const markdownAst = Marked.Marked.lexer(processedMarkdown);
|
|
87
|
+
title = MarkdownIssueDescription.findTitleFromMarkdownAst(markdownAst);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
logger('error parsing markdown for issue ' + issue.code());
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (!title) {
|
|
94
|
+
logger('cannot read issue title from ' + filename);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
type: 'issue',
|
|
99
|
+
item: issue,
|
|
100
|
+
message: title,
|
|
101
|
+
count,
|
|
102
|
+
description: processedMarkdown,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// DevTools CDP errors can get noisy.
|
|
106
|
+
ProtocolClient.InspectorBackend.test.suppressRequestErrors = true;
|
|
107
|
+
I18n.DevToolsLocale.DevToolsLocale.instance({
|
|
108
|
+
create: true,
|
|
109
|
+
data: {
|
|
110
|
+
navigatorLanguage: 'en-US',
|
|
111
|
+
settingLanguage: 'en-US',
|
|
112
|
+
lookupClosestDevToolsLocale: l => l,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
I18n.i18n.registerLocaleDataForTest('en-US', {});
|
|
116
|
+
export class UniverseManager {
|
|
117
|
+
#browser;
|
|
118
|
+
#createUniverseFor;
|
|
119
|
+
#universes = new WeakMap();
|
|
120
|
+
/** Guard access to #universes so we don't create unnecessary universes */
|
|
121
|
+
#mutex = new Mutex();
|
|
122
|
+
constructor(browser, factory = DEFAULT_FACTORY) {
|
|
123
|
+
this.#browser = browser;
|
|
124
|
+
this.#createUniverseFor = factory;
|
|
125
|
+
}
|
|
126
|
+
async init(pages) {
|
|
127
|
+
try {
|
|
128
|
+
await this.#mutex.acquire();
|
|
129
|
+
const promises = [];
|
|
130
|
+
for (const page of pages) {
|
|
131
|
+
promises.push(this.#createUniverseFor(page).then(targetUniverse => this.#universes.set(page, targetUniverse)));
|
|
132
|
+
}
|
|
133
|
+
this.#browser.on('targetcreated', this.#onTargetCreated);
|
|
134
|
+
this.#browser.on('targetdestroyed', this.#onTargetDestroyed);
|
|
135
|
+
await Promise.all(promises);
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
this.#mutex.release();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
get(page) {
|
|
142
|
+
return this.#universes.get(page) ?? null;
|
|
143
|
+
}
|
|
144
|
+
dispose() {
|
|
145
|
+
this.#browser.off('targetcreated', this.#onTargetCreated);
|
|
146
|
+
this.#browser.off('targetdestroyed', this.#onTargetDestroyed);
|
|
147
|
+
}
|
|
148
|
+
#onTargetCreated = async (target) => {
|
|
149
|
+
const page = await target.page();
|
|
150
|
+
try {
|
|
151
|
+
await this.#mutex.acquire();
|
|
152
|
+
if (!page || this.#universes.has(page)) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
this.#universes.set(page, await this.#createUniverseFor(page));
|
|
156
|
+
}
|
|
157
|
+
finally {
|
|
158
|
+
this.#mutex.release();
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
#onTargetDestroyed = async (target) => {
|
|
162
|
+
const page = await target.page();
|
|
163
|
+
try {
|
|
164
|
+
await this.#mutex.acquire();
|
|
165
|
+
if (!page || !this.#universes.has(page)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
this.#universes.delete(page);
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
this.#mutex.release();
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const DEFAULT_FACTORY = async (page) => {
|
|
176
|
+
const settingStorage = new Common.Settings.SettingsStorage({});
|
|
177
|
+
const universe = new Foundation.Universe.Universe({
|
|
178
|
+
settingsCreationOptions: {
|
|
179
|
+
syncedStorage: settingStorage,
|
|
180
|
+
globalStorage: settingStorage,
|
|
181
|
+
localStorage: settingStorage,
|
|
182
|
+
settingRegistrations: Common.SettingRegistration.getRegisteredSettings(),
|
|
183
|
+
},
|
|
184
|
+
overrideAutoStartModels: new Set([DebuggerModel]),
|
|
185
|
+
});
|
|
186
|
+
const session = await page.createCDPSession();
|
|
187
|
+
const connection = new PuppeteerDevToolsConnection(session);
|
|
188
|
+
const targetManager = universe.context.get(TargetManager);
|
|
189
|
+
targetManager.observeModels(DebuggerModel, SKIP_ALL_PAUSES);
|
|
190
|
+
const target = targetManager.createTarget('main', '', 'frame', // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
191
|
+
/* parentTarget */ null, session.id(), undefined, connection);
|
|
192
|
+
return { target, universe };
|
|
193
|
+
};
|
|
194
|
+
// We don't want to pause any DevTools universe session ever on the MCP side.
|
|
195
|
+
//
|
|
196
|
+
// Note that calling `setSkipAllPauses` only affects the session on which it was
|
|
197
|
+
// sent. This means DevTools can still pause, step and do whatever. We just won't
|
|
198
|
+
// see the `Debugger.paused`/`Debugger.resumed` events on the MCP side.
|
|
199
|
+
const SKIP_ALL_PAUSES = {
|
|
200
|
+
modelAdded(model) {
|
|
201
|
+
void model.agent.invoke_setSkipAllPauses({ skip: true });
|
|
202
|
+
},
|
|
203
|
+
modelRemoved() {
|
|
204
|
+
// Do nothing.
|
|
205
|
+
},
|
|
206
|
+
};
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Google LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs/promises';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { extractUrlLikeFromDevToolsTitle, urlsEqual } from './DevtoolsUtils.js';
|
|
10
|
+
import { NetworkCollector, ConsoleCollector } from './PageCollector.js';
|
|
11
|
+
import { Locator } from './third_party/index.js';
|
|
12
|
+
import { listPages } from './tools/pages.js';
|
|
13
|
+
import { takeSnapshot } from './tools/snapshot.js';
|
|
14
|
+
import { CLOSE_PAGE_ERROR } from './tools/ToolDefinition.js';
|
|
15
|
+
import { WaitForHelper } from './WaitForHelper.js';
|
|
16
|
+
const DEFAULT_TIMEOUT = 5_000;
|
|
17
|
+
const NAVIGATION_TIMEOUT = 10_000;
|
|
18
|
+
function getNetworkMultiplierFromString(condition) {
|
|
19
|
+
const puppeteerCondition = condition;
|
|
20
|
+
switch (puppeteerCondition) {
|
|
21
|
+
case 'Fast 4G':
|
|
22
|
+
return 1;
|
|
23
|
+
case 'Slow 4G':
|
|
24
|
+
return 2.5;
|
|
25
|
+
case 'Fast 3G':
|
|
26
|
+
return 5;
|
|
27
|
+
case 'Slow 3G':
|
|
28
|
+
return 10;
|
|
29
|
+
}
|
|
30
|
+
return 1;
|
|
31
|
+
}
|
|
32
|
+
function getExtensionFromMimeType(mimeType) {
|
|
33
|
+
switch (mimeType) {
|
|
34
|
+
case 'image/png':
|
|
35
|
+
return 'png';
|
|
36
|
+
case 'image/jpeg':
|
|
37
|
+
return 'jpeg';
|
|
38
|
+
case 'image/webp':
|
|
39
|
+
return 'webp';
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`No mapping for Mime type ${mimeType}.`);
|
|
42
|
+
}
|
|
43
|
+
export class McpContext {
|
|
44
|
+
browser;
|
|
45
|
+
logger;
|
|
46
|
+
// The most recent page state.
|
|
47
|
+
#pages = [];
|
|
48
|
+
#pageToDevToolsPage = new Map();
|
|
49
|
+
#selectedPage;
|
|
50
|
+
// The most recent snapshot.
|
|
51
|
+
#textSnapshot = null;
|
|
52
|
+
#networkCollector;
|
|
53
|
+
#consoleCollector;
|
|
54
|
+
#isRunningTrace = false;
|
|
55
|
+
#networkConditionsMap = new WeakMap();
|
|
56
|
+
#cpuThrottlingRateMap = new WeakMap();
|
|
57
|
+
#geolocationMap = new WeakMap();
|
|
58
|
+
#dialog;
|
|
59
|
+
#nextSnapshotId = 1;
|
|
60
|
+
#traceResults = [];
|
|
61
|
+
#locatorClass;
|
|
62
|
+
#options;
|
|
63
|
+
constructor(browser, logger, options, locatorClass) {
|
|
64
|
+
this.browser = browser;
|
|
65
|
+
this.logger = logger;
|
|
66
|
+
this.#locatorClass = locatorClass;
|
|
67
|
+
this.#options = options;
|
|
68
|
+
this.#networkCollector = new NetworkCollector(this.browser);
|
|
69
|
+
this.#consoleCollector = new ConsoleCollector(this.browser, collect => {
|
|
70
|
+
return {
|
|
71
|
+
console: event => {
|
|
72
|
+
collect(event);
|
|
73
|
+
},
|
|
74
|
+
pageerror: event => {
|
|
75
|
+
if (event instanceof Error) {
|
|
76
|
+
collect(event);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
const error = new Error(`${event}`);
|
|
80
|
+
error.stack = undefined;
|
|
81
|
+
collect(error);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
issue: event => {
|
|
85
|
+
collect(event);
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
async #init() {
|
|
91
|
+
const pages = await this.createPagesSnapshot();
|
|
92
|
+
await this.#networkCollector.init(pages);
|
|
93
|
+
await this.#consoleCollector.init(pages);
|
|
94
|
+
}
|
|
95
|
+
dispose() {
|
|
96
|
+
this.#networkCollector.dispose();
|
|
97
|
+
this.#consoleCollector.dispose();
|
|
98
|
+
}
|
|
99
|
+
static async from(browser, logger, opts,
|
|
100
|
+
/* Let tests use unbundled Locator class to avoid overly strict checks within puppeteer that fail when mixing bundled and unbundled class instances */
|
|
101
|
+
locatorClass = Locator) {
|
|
102
|
+
const context = new McpContext(browser, logger, opts, locatorClass);
|
|
103
|
+
await context.#init();
|
|
104
|
+
return context;
|
|
105
|
+
}
|
|
106
|
+
resolveCdpRequestId(cdpRequestId) {
|
|
107
|
+
const selectedPage = this.getSelectedPage();
|
|
108
|
+
if (!cdpRequestId) {
|
|
109
|
+
this.logger('no network request');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const request = this.#networkCollector.find(selectedPage, request => {
|
|
113
|
+
// @ts-expect-error id is internal.
|
|
114
|
+
return request.id === cdpRequestId;
|
|
115
|
+
});
|
|
116
|
+
if (!request) {
|
|
117
|
+
this.logger('no network request for ' + cdpRequestId);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
return this.#networkCollector.getIdForResource(request);
|
|
121
|
+
}
|
|
122
|
+
resolveCdpElementId(cdpBackendNodeId) {
|
|
123
|
+
if (!cdpBackendNodeId) {
|
|
124
|
+
this.logger('no cdpBackendNodeId');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (this.#textSnapshot === null) {
|
|
128
|
+
this.logger('no text snapshot');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// TODO: index by backendNodeId instead.
|
|
132
|
+
const queue = [this.#textSnapshot.root];
|
|
133
|
+
while (queue.length) {
|
|
134
|
+
const current = queue.pop();
|
|
135
|
+
if (current.backendNodeId === cdpBackendNodeId) {
|
|
136
|
+
return current.id;
|
|
137
|
+
}
|
|
138
|
+
for (const child of current.children) {
|
|
139
|
+
queue.push(child);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
getNetworkRequests(includePreservedRequests) {
|
|
145
|
+
const page = this.getSelectedPage();
|
|
146
|
+
return this.#networkCollector.getData(page, includePreservedRequests);
|
|
147
|
+
}
|
|
148
|
+
getConsoleData(includePreservedMessages) {
|
|
149
|
+
const page = this.getSelectedPage();
|
|
150
|
+
return this.#consoleCollector.getData(page, includePreservedMessages);
|
|
151
|
+
}
|
|
152
|
+
getConsoleMessageStableId(message) {
|
|
153
|
+
return this.#consoleCollector.getIdForResource(message);
|
|
154
|
+
}
|
|
155
|
+
getConsoleMessageById(id) {
|
|
156
|
+
return this.#consoleCollector.getById(this.getSelectedPage(), id);
|
|
157
|
+
}
|
|
158
|
+
async newPage() {
|
|
159
|
+
const page = await this.browser.newPage();
|
|
160
|
+
await this.createPagesSnapshot();
|
|
161
|
+
this.selectPage(page);
|
|
162
|
+
this.#networkCollector.addPage(page);
|
|
163
|
+
this.#consoleCollector.addPage(page);
|
|
164
|
+
return page;
|
|
165
|
+
}
|
|
166
|
+
async closePage(pageIdx) {
|
|
167
|
+
if (this.#pages.length === 1) {
|
|
168
|
+
throw new Error(CLOSE_PAGE_ERROR);
|
|
169
|
+
}
|
|
170
|
+
const page = this.getPageByIdx(pageIdx);
|
|
171
|
+
await page.close({ runBeforeUnload: false });
|
|
172
|
+
}
|
|
173
|
+
getNetworkRequestById(reqid) {
|
|
174
|
+
return this.#networkCollector.getById(this.getSelectedPage(), reqid);
|
|
175
|
+
}
|
|
176
|
+
setNetworkConditions(conditions) {
|
|
177
|
+
const page = this.getSelectedPage();
|
|
178
|
+
if (conditions === null) {
|
|
179
|
+
this.#networkConditionsMap.delete(page);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
this.#networkConditionsMap.set(page, conditions);
|
|
183
|
+
}
|
|
184
|
+
this.#updateSelectedPageTimeouts();
|
|
185
|
+
}
|
|
186
|
+
getNetworkConditions() {
|
|
187
|
+
const page = this.getSelectedPage();
|
|
188
|
+
return this.#networkConditionsMap.get(page) ?? null;
|
|
189
|
+
}
|
|
190
|
+
setCpuThrottlingRate(rate) {
|
|
191
|
+
const page = this.getSelectedPage();
|
|
192
|
+
this.#cpuThrottlingRateMap.set(page, rate);
|
|
193
|
+
this.#updateSelectedPageTimeouts();
|
|
194
|
+
}
|
|
195
|
+
getCpuThrottlingRate() {
|
|
196
|
+
const page = this.getSelectedPage();
|
|
197
|
+
return this.#cpuThrottlingRateMap.get(page) ?? 1;
|
|
198
|
+
}
|
|
199
|
+
setGeolocation(geolocation) {
|
|
200
|
+
const page = this.getSelectedPage();
|
|
201
|
+
if (geolocation === null) {
|
|
202
|
+
this.#geolocationMap.delete(page);
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
this.#geolocationMap.set(page, geolocation);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
getGeolocation() {
|
|
209
|
+
const page = this.getSelectedPage();
|
|
210
|
+
return this.#geolocationMap.get(page) ?? null;
|
|
211
|
+
}
|
|
212
|
+
setIsRunningPerformanceTrace(x) {
|
|
213
|
+
this.#isRunningTrace = x;
|
|
214
|
+
}
|
|
215
|
+
isRunningPerformanceTrace() {
|
|
216
|
+
return this.#isRunningTrace;
|
|
217
|
+
}
|
|
218
|
+
getDialog() {
|
|
219
|
+
return this.#dialog;
|
|
220
|
+
}
|
|
221
|
+
clearDialog() {
|
|
222
|
+
this.#dialog = undefined;
|
|
223
|
+
}
|
|
224
|
+
getSelectedPage() {
|
|
225
|
+
const page = this.#selectedPage;
|
|
226
|
+
if (!page) {
|
|
227
|
+
throw new Error('No page selected');
|
|
228
|
+
}
|
|
229
|
+
if (page.isClosed()) {
|
|
230
|
+
throw new Error(`The selected page has been closed. Call ${listPages.name} to see open pages.`);
|
|
231
|
+
}
|
|
232
|
+
return page;
|
|
233
|
+
}
|
|
234
|
+
getPageByIdx(idx) {
|
|
235
|
+
const pages = this.#pages;
|
|
236
|
+
const page = pages[idx];
|
|
237
|
+
if (!page) {
|
|
238
|
+
throw new Error('No page found');
|
|
239
|
+
}
|
|
240
|
+
return page;
|
|
241
|
+
}
|
|
242
|
+
#dialogHandler = (dialog) => {
|
|
243
|
+
this.#dialog = dialog;
|
|
244
|
+
};
|
|
245
|
+
isPageSelected(page) {
|
|
246
|
+
return this.#selectedPage === page;
|
|
247
|
+
}
|
|
248
|
+
selectPage(newPage) {
|
|
249
|
+
const oldPage = this.#selectedPage;
|
|
250
|
+
if (oldPage) {
|
|
251
|
+
oldPage.off('dialog', this.#dialogHandler);
|
|
252
|
+
}
|
|
253
|
+
this.#selectedPage = newPage;
|
|
254
|
+
newPage.on('dialog', this.#dialogHandler);
|
|
255
|
+
this.#updateSelectedPageTimeouts();
|
|
256
|
+
}
|
|
257
|
+
#updateSelectedPageTimeouts() {
|
|
258
|
+
const page = this.getSelectedPage();
|
|
259
|
+
// For waiters 5sec timeout should be sufficient.
|
|
260
|
+
// Increased in case we throttle the CPU
|
|
261
|
+
const cpuMultiplier = this.getCpuThrottlingRate();
|
|
262
|
+
page.setDefaultTimeout(DEFAULT_TIMEOUT * cpuMultiplier);
|
|
263
|
+
// 10sec should be enough for the load event to be emitted during
|
|
264
|
+
// navigations.
|
|
265
|
+
// Increased in case we throttle the network requests
|
|
266
|
+
const networkMultiplier = getNetworkMultiplierFromString(this.getNetworkConditions());
|
|
267
|
+
page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT * networkMultiplier);
|
|
268
|
+
}
|
|
269
|
+
getNavigationTimeout() {
|
|
270
|
+
const page = this.getSelectedPage();
|
|
271
|
+
return page.getDefaultNavigationTimeout();
|
|
272
|
+
}
|
|
273
|
+
getAXNodeByUid(uid) {
|
|
274
|
+
return this.#textSnapshot?.idToNode.get(uid);
|
|
275
|
+
}
|
|
276
|
+
async getElementByUid(uid) {
|
|
277
|
+
if (!this.#textSnapshot?.idToNode.size) {
|
|
278
|
+
throw new Error(`No snapshot found. Use ${takeSnapshot.name} to capture one.`);
|
|
279
|
+
}
|
|
280
|
+
const [snapshotId] = uid.split('_');
|
|
281
|
+
if (this.#textSnapshot.snapshotId !== snapshotId) {
|
|
282
|
+
throw new Error('This uid is coming from a stale snapshot. Call take_snapshot to get a fresh snapshot.');
|
|
283
|
+
}
|
|
284
|
+
const node = this.#textSnapshot?.idToNode.get(uid);
|
|
285
|
+
if (!node) {
|
|
286
|
+
throw new Error('No such element found in the snapshot');
|
|
287
|
+
}
|
|
288
|
+
const handle = await node.elementHandle();
|
|
289
|
+
if (!handle) {
|
|
290
|
+
throw new Error('No such element found in the snapshot');
|
|
291
|
+
}
|
|
292
|
+
return handle;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Creates a snapshot of the pages.
|
|
296
|
+
*/
|
|
297
|
+
async createPagesSnapshot() {
|
|
298
|
+
const allPages = await this.browser.pages(this.#options.experimentalIncludeAllPages);
|
|
299
|
+
this.#pages = allPages.filter(page => {
|
|
300
|
+
// If we allow debugging DevTools windows, return all pages.
|
|
301
|
+
// If we are in regular mode, the user should only see non-DevTools page.
|
|
302
|
+
return (this.#options.experimentalDevToolsDebugging ||
|
|
303
|
+
!page.url().startsWith('devtools://'));
|
|
304
|
+
});
|
|
305
|
+
if ((!this.#selectedPage || this.#pages.indexOf(this.#selectedPage) === -1) &&
|
|
306
|
+
this.#pages[0]) {
|
|
307
|
+
this.selectPage(this.#pages[0]);
|
|
308
|
+
}
|
|
309
|
+
await this.detectOpenDevToolsWindows();
|
|
310
|
+
return this.#pages;
|
|
311
|
+
}
|
|
312
|
+
async detectOpenDevToolsWindows() {
|
|
313
|
+
this.logger('Detecting open DevTools windows');
|
|
314
|
+
const pages = await this.browser.pages(this.#options.experimentalIncludeAllPages);
|
|
315
|
+
this.#pageToDevToolsPage = new Map();
|
|
316
|
+
for (const devToolsPage of pages) {
|
|
317
|
+
if (devToolsPage.url().startsWith('devtools://')) {
|
|
318
|
+
try {
|
|
319
|
+
this.logger('Calling getTargetInfo for ' + devToolsPage.url());
|
|
320
|
+
const data = await devToolsPage
|
|
321
|
+
// @ts-expect-error no types for _client().
|
|
322
|
+
._client()
|
|
323
|
+
.send('Target.getTargetInfo');
|
|
324
|
+
const devtoolsPageTitle = data.targetInfo.title;
|
|
325
|
+
const urlLike = extractUrlLikeFromDevToolsTitle(devtoolsPageTitle);
|
|
326
|
+
if (!urlLike) {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
// TODO: lookup without a loop.
|
|
330
|
+
for (const page of this.#pages) {
|
|
331
|
+
if (urlsEqual(page.url(), urlLike)) {
|
|
332
|
+
this.#pageToDevToolsPage.set(page, devToolsPage);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
this.logger('Issue occurred while trying to find DevTools', error);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
getPages() {
|
|
343
|
+
return this.#pages;
|
|
344
|
+
}
|
|
345
|
+
getDevToolsPage(page) {
|
|
346
|
+
return this.#pageToDevToolsPage.get(page);
|
|
347
|
+
}
|
|
348
|
+
async getDevToolsData() {
|
|
349
|
+
try {
|
|
350
|
+
this.logger('Getting DevTools UI data');
|
|
351
|
+
const selectedPage = this.getSelectedPage();
|
|
352
|
+
const devtoolsPage = this.getDevToolsPage(selectedPage);
|
|
353
|
+
if (!devtoolsPage) {
|
|
354
|
+
this.logger('No DevTools page detected');
|
|
355
|
+
return {};
|
|
356
|
+
}
|
|
357
|
+
const { cdpRequestId, cdpBackendNodeId } = await devtoolsPage.evaluate(async () => {
|
|
358
|
+
// @ts-expect-error no types
|
|
359
|
+
const UI = await import('/bundled/ui/legacy/legacy.js');
|
|
360
|
+
// @ts-expect-error no types
|
|
361
|
+
const SDK = await import('/bundled/core/sdk/sdk.js');
|
|
362
|
+
const request = UI.Context.Context.instance().flavor(SDK.NetworkRequest.NetworkRequest);
|
|
363
|
+
const node = UI.Context.Context.instance().flavor(SDK.DOMModel.DOMNode);
|
|
364
|
+
return {
|
|
365
|
+
cdpRequestId: request?.requestId(),
|
|
366
|
+
cdpBackendNodeId: node?.backendNodeId(),
|
|
367
|
+
};
|
|
368
|
+
});
|
|
369
|
+
return { cdpBackendNodeId, cdpRequestId };
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
this.logger('error getting devtools data', err);
|
|
373
|
+
}
|
|
374
|
+
return {};
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Creates a text snapshot of a page.
|
|
378
|
+
*/
|
|
379
|
+
async createTextSnapshot(verbose = false, devtoolsData = undefined) {
|
|
380
|
+
const page = this.getSelectedPage();
|
|
381
|
+
const rootNode = await page.accessibility.snapshot({
|
|
382
|
+
includeIframes: true,
|
|
383
|
+
interestingOnly: !verbose,
|
|
384
|
+
});
|
|
385
|
+
if (!rootNode) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const snapshotId = this.#nextSnapshotId++;
|
|
389
|
+
// Iterate through the whole accessibility node tree and assign node ids that
|
|
390
|
+
// will be used for the tree serialization and mapping ids back to nodes.
|
|
391
|
+
let idCounter = 0;
|
|
392
|
+
const idToNode = new Map();
|
|
393
|
+
const assignIds = (node) => {
|
|
394
|
+
const nodeWithId = {
|
|
395
|
+
...node,
|
|
396
|
+
id: `${snapshotId}_${idCounter++}`,
|
|
397
|
+
children: node.children
|
|
398
|
+
? node.children.map(child => assignIds(child))
|
|
399
|
+
: [],
|
|
400
|
+
};
|
|
401
|
+
// The AXNode for an option doesn't contain its `value`.
|
|
402
|
+
// Therefore, set text content of the option as value.
|
|
403
|
+
if (node.role === 'option') {
|
|
404
|
+
const optionText = node.name;
|
|
405
|
+
if (optionText) {
|
|
406
|
+
nodeWithId.value = optionText.toString();
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
idToNode.set(nodeWithId.id, nodeWithId);
|
|
410
|
+
return nodeWithId;
|
|
411
|
+
};
|
|
412
|
+
const rootNodeWithId = assignIds(rootNode);
|
|
413
|
+
this.#textSnapshot = {
|
|
414
|
+
root: rootNodeWithId,
|
|
415
|
+
snapshotId: String(snapshotId),
|
|
416
|
+
idToNode,
|
|
417
|
+
hasSelectedElement: false,
|
|
418
|
+
verbose,
|
|
419
|
+
};
|
|
420
|
+
const data = devtoolsData ?? (await this.getDevToolsData());
|
|
421
|
+
if (data?.cdpBackendNodeId) {
|
|
422
|
+
this.#textSnapshot.hasSelectedElement = true;
|
|
423
|
+
this.#textSnapshot.selectedElementUid = this.resolveCdpElementId(data?.cdpBackendNodeId);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
getTextSnapshot() {
|
|
427
|
+
return this.#textSnapshot;
|
|
428
|
+
}
|
|
429
|
+
async saveTemporaryFile(data, mimeType) {
|
|
430
|
+
try {
|
|
431
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'chrome-devtools-mcp-'));
|
|
432
|
+
const filename = path.join(dir, `screenshot.${getExtensionFromMimeType(mimeType)}`);
|
|
433
|
+
await fs.writeFile(filename, data);
|
|
434
|
+
return { filename };
|
|
435
|
+
}
|
|
436
|
+
catch (err) {
|
|
437
|
+
this.logger(err);
|
|
438
|
+
throw new Error('Could not save a screenshot to a file', { cause: err });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
async saveFile(data, filename) {
|
|
442
|
+
try {
|
|
443
|
+
const filePath = path.resolve(filename);
|
|
444
|
+
await fs.writeFile(filePath, data);
|
|
445
|
+
return { filename };
|
|
446
|
+
}
|
|
447
|
+
catch (err) {
|
|
448
|
+
this.logger(err);
|
|
449
|
+
throw new Error('Could not save a screenshot to a file', { cause: err });
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
storeTraceRecording(result) {
|
|
453
|
+
this.#traceResults.push(result);
|
|
454
|
+
}
|
|
455
|
+
recordedTraces() {
|
|
456
|
+
return this.#traceResults;
|
|
457
|
+
}
|
|
458
|
+
getWaitForHelper(page, cpuMultiplier, networkMultiplier) {
|
|
459
|
+
return new WaitForHelper(page, cpuMultiplier, networkMultiplier);
|
|
460
|
+
}
|
|
461
|
+
waitForEventsAfterAction(action) {
|
|
462
|
+
const page = this.getSelectedPage();
|
|
463
|
+
const cpuMultiplier = this.getCpuThrottlingRate();
|
|
464
|
+
const networkMultiplier = getNetworkMultiplierFromString(this.getNetworkConditions());
|
|
465
|
+
const waitForHelper = this.getWaitForHelper(page, cpuMultiplier, networkMultiplier);
|
|
466
|
+
return waitForHelper.waitForEventsAfterAction(action);
|
|
467
|
+
}
|
|
468
|
+
getNetworkRequestStableId(request) {
|
|
469
|
+
return this.#networkCollector.getIdForResource(request);
|
|
470
|
+
}
|
|
471
|
+
waitForTextOnPage(text, timeout) {
|
|
472
|
+
const page = this.getSelectedPage();
|
|
473
|
+
const frames = page.frames();
|
|
474
|
+
let locator = this.#locatorClass.race(frames.flatMap(frame => [
|
|
475
|
+
frame.locator(`aria/${text}`),
|
|
476
|
+
frame.locator(`text/${text}`),
|
|
477
|
+
]));
|
|
478
|
+
if (timeout) {
|
|
479
|
+
locator = locator.setTimeout(timeout);
|
|
480
|
+
}
|
|
481
|
+
return locator.wait();
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* We need to ignore favicon request as they make our test flaky
|
|
485
|
+
*/
|
|
486
|
+
async setUpNetworkCollectorForTesting() {
|
|
487
|
+
this.#networkCollector = new NetworkCollector(this.browser, collect => {
|
|
488
|
+
return {
|
|
489
|
+
request: req => {
|
|
490
|
+
if (req.url().includes('favicon.ico')) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
collect(req);
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
});
|
|
497
|
+
await this.#networkCollector.init(await this.browser.pages());
|
|
498
|
+
}
|
|
499
|
+
}
|