chrome-e2e-sdk 0.0.0-stage → 1.0.1
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 +21 -0
- package/README.md +739 -2
- package/dist/assertion/Assertion.d.ts +87 -0
- package/dist/assertion/Assertion.d.ts.map +1 -0
- package/dist/assertion/Assertion.js +459 -0
- package/dist/assertion/Assertion.js.map +1 -0
- package/dist/assertion/recorder.d.ts +41 -0
- package/dist/assertion/recorder.d.ts.map +1 -0
- package/dist/assertion/recorder.js +37 -0
- package/dist/assertion/recorder.js.map +1 -0
- package/dist/browser/Browser.d.ts +44 -0
- package/dist/browser/Browser.d.ts.map +1 -0
- package/dist/browser/Browser.js +167 -0
- package/dist/browser/Browser.js.map +1 -0
- package/dist/browser/Locator.d.ts +94 -0
- package/dist/browser/Locator.d.ts.map +1 -0
- package/dist/browser/Locator.js +533 -0
- package/dist/browser/Locator.js.map +1 -0
- package/dist/browser/Page.d.ts +272 -0
- package/dist/browser/Page.d.ts.map +1 -0
- package/dist/browser/Page.js +979 -0
- package/dist/browser/Page.js.map +1 -0
- package/dist/client/MCPClient.d.ts +72 -0
- package/dist/client/MCPClient.d.ts.map +1 -0
- package/dist/client/MCPClient.js +302 -0
- package/dist/client/MCPClient.js.map +1 -0
- package/dist/client/MCPCommand.d.ts +51 -0
- package/dist/client/MCPCommand.d.ts.map +1 -0
- package/dist/client/MCPCommand.js +74 -0
- package/dist/client/MCPCommand.js.map +1 -0
- package/dist/env/loadEnv.d.ts +8 -0
- package/dist/env/loadEnv.d.ts.map +1 -0
- package/dist/env/loadEnv.js +19 -0
- package/dist/env/loadEnv.js.map +1 -0
- package/dist/errors/E2EError.d.ts +50 -0
- package/dist/errors/E2EError.d.ts.map +1 -0
- package/dist/errors/E2EError.js +90 -0
- package/dist/errors/E2EError.js.map +1 -0
- package/dist/index.d.ts +32 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +57 -0
- package/dist/logger.js.map +1 -0
- package/dist/network/Network.d.ts +72 -0
- package/dist/network/Network.d.ts.map +1 -0
- package/dist/network/Network.js +236 -0
- package/dist/network/Network.js.map +1 -0
- package/dist/test/HtmlReport.d.ts +26 -0
- package/dist/test/HtmlReport.d.ts.map +1 -0
- package/dist/test/HtmlReport.js +653 -0
- package/dist/test/HtmlReport.js.map +1 -0
- package/dist/test/Runner.d.ts +146 -0
- package/dist/test/Runner.d.ts.map +1 -0
- package/dist/test/Runner.js +390 -0
- package/dist/test/Runner.js.map +1 -0
- package/dist/test/index.d.ts +4 -0
- package/dist/test/index.d.ts.map +1 -0
- package/dist/test/index.js +3 -0
- package/dist/test/index.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +4 -0
- package/dist/version.js.map +1 -0
- package/package.json +63 -4
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
import { MCPClient } from '../client/MCPClient.js';
|
|
2
|
+
import { Locator } from './Locator.js';
|
|
3
|
+
import { Network } from '../network/Network.js';
|
|
4
|
+
import {} from '../network/Network.js';
|
|
5
|
+
import { E2EError, E2EErrorCode } from '../errors/E2EError.js';
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
/** Snapshot node keys that describe the node itself rather than its attributes. */
|
|
8
|
+
const SNAPSHOT_RESERVED_KEYS = new Set([
|
|
9
|
+
'id',
|
|
10
|
+
'uid',
|
|
11
|
+
'role',
|
|
12
|
+
'name',
|
|
13
|
+
'text',
|
|
14
|
+
'tagName',
|
|
15
|
+
'children',
|
|
16
|
+
'attributes',
|
|
17
|
+
'elementHandle',
|
|
18
|
+
'backendNodeId',
|
|
19
|
+
'loaderId'
|
|
20
|
+
]);
|
|
21
|
+
export class PageLocalStorage {
|
|
22
|
+
page;
|
|
23
|
+
constructor(page) {
|
|
24
|
+
this.page = page;
|
|
25
|
+
}
|
|
26
|
+
async setItem(key, value, options = {}) {
|
|
27
|
+
await this.page.evaluate(`(storageKey, storageValue) => {
|
|
28
|
+
window.localStorage.setItem(storageKey, storageValue)
|
|
29
|
+
}`, [key, value], options);
|
|
30
|
+
}
|
|
31
|
+
async getItem(key, options = {}) {
|
|
32
|
+
return this.page.evaluate(`(storageKey) => window.localStorage.getItem(storageKey)`, [key], options);
|
|
33
|
+
}
|
|
34
|
+
async removeItem(key, options = {}) {
|
|
35
|
+
await this.page.evaluate(`(storageKey) => {
|
|
36
|
+
window.localStorage.removeItem(storageKey)
|
|
37
|
+
}`, [key], options);
|
|
38
|
+
}
|
|
39
|
+
/** Keys currently present, in insertion order. */
|
|
40
|
+
async keys(options = {}) {
|
|
41
|
+
return this.page.evaluate(`() => Object.keys(window.localStorage)`, undefined, options);
|
|
42
|
+
}
|
|
43
|
+
async clear(options = {}) {
|
|
44
|
+
await this.page.evaluate(`() => {
|
|
45
|
+
window.localStorage.clear()
|
|
46
|
+
}`, undefined, options);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export class Page {
|
|
50
|
+
mcpClient;
|
|
51
|
+
pageId;
|
|
52
|
+
defaultTimeout;
|
|
53
|
+
closed = false;
|
|
54
|
+
networkInstance;
|
|
55
|
+
localStorageInstance;
|
|
56
|
+
_url = '';
|
|
57
|
+
_title = '';
|
|
58
|
+
_active = false;
|
|
59
|
+
logger;
|
|
60
|
+
constructor(mcpClient, pageId, defaultTimeout) {
|
|
61
|
+
this.mcpClient = mcpClient;
|
|
62
|
+
this.pageId = pageId;
|
|
63
|
+
this.defaultTimeout = defaultTimeout;
|
|
64
|
+
this.networkInstance = new Network(mcpClient, this.pageId, defaultTimeout);
|
|
65
|
+
this.localStorageInstance = new PageLocalStorage(this);
|
|
66
|
+
this.logger = this.createLogger();
|
|
67
|
+
}
|
|
68
|
+
createLogger() {
|
|
69
|
+
return (level, message) => {
|
|
70
|
+
if (process.env['DEBUG']?.includes('chrome-e2e')) {
|
|
71
|
+
console.error(`[ChromeE2E:Page:${this.pageId}] ${level.toUpperCase()}: ${message}`);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Navigation
|
|
76
|
+
async goto(url, options) {
|
|
77
|
+
this.ensureNotClosed();
|
|
78
|
+
const timeout = options?.timeout ?? this.defaultTimeout;
|
|
79
|
+
this.logger('debug', `Navigating to: ${url}`);
|
|
80
|
+
try {
|
|
81
|
+
const result = await this.withTimeout(this.mcpClient.call('navigate_page', { pageId: this.pageId, type: 'url', url }), timeout, `Navigation to ${url}`);
|
|
82
|
+
// chrome-devtools-mcp reports navigation problems as a response line instead
|
|
83
|
+
// of raising, so the message has to be inspected to surface the failure.
|
|
84
|
+
const failure = this.parseNavigationFailure(result);
|
|
85
|
+
if (failure) {
|
|
86
|
+
throw E2EError.navigationError(url, new Error(failure));
|
|
87
|
+
}
|
|
88
|
+
await this.waitForLoadState(options?.waitUntil ?? 'load', { timeout });
|
|
89
|
+
// The title is invalidated because the document was replaced. The URL is
|
|
90
|
+
// not overwritten with the requested one: the browser may have redirected,
|
|
91
|
+
// normalised the trailing slash, or dropped a fragment, and a cache
|
|
92
|
+
// holding the request instead of the destination is what made url()
|
|
93
|
+
// disagree with the page.
|
|
94
|
+
this._title = '';
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
if (err instanceof E2EError) {
|
|
98
|
+
throw err;
|
|
99
|
+
}
|
|
100
|
+
throw E2EError.navigationError(url, err instanceof Error ? err : undefined);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async reload(options) {
|
|
104
|
+
this.ensureNotClosed();
|
|
105
|
+
const timeout = options?.timeout ?? this.defaultTimeout;
|
|
106
|
+
this.logger('debug', 'Reloading page');
|
|
107
|
+
await this.withTimeout(this.mcpClient.call('navigate_page', { pageId: this.pageId, type: 'reload' }), timeout, 'Page reload');
|
|
108
|
+
}
|
|
109
|
+
async back(options) {
|
|
110
|
+
this.ensureNotClosed();
|
|
111
|
+
const timeout = options?.timeout ?? this.defaultTimeout;
|
|
112
|
+
this.logger('debug', 'Going back');
|
|
113
|
+
await this.withTimeout(this.mcpClient.call('navigate_page', { pageId: this.pageId, type: 'back' }), timeout, 'Go back');
|
|
114
|
+
}
|
|
115
|
+
async forward(options) {
|
|
116
|
+
this.ensureNotClosed();
|
|
117
|
+
const timeout = options?.timeout ?? this.defaultTimeout;
|
|
118
|
+
this.logger('debug', 'Going forward');
|
|
119
|
+
await this.withTimeout(this.mcpClient.call('navigate_page', { pageId: this.pageId, type: 'forward' }), timeout, 'Go forward');
|
|
120
|
+
}
|
|
121
|
+
async waitForTimeout(timeout) {
|
|
122
|
+
if (!Number.isFinite(timeout) || timeout < 0) {
|
|
123
|
+
throw E2EError.invalidArgument('timeout', 'non-negative number', timeout);
|
|
124
|
+
}
|
|
125
|
+
await new Promise((resolve) => setTimeout(resolve, timeout));
|
|
126
|
+
}
|
|
127
|
+
async waitForURL(url, options = {}) {
|
|
128
|
+
this.ensureNotClosed();
|
|
129
|
+
const timeout = options.timeout ?? this.defaultTimeout;
|
|
130
|
+
const startedAt = Date.now();
|
|
131
|
+
// A wait is nearly always called right after triggering a navigation, which
|
|
132
|
+
// is exactly when the execution context is torn down and evaluate throws.
|
|
133
|
+
// That is a transient state, not a failure, so it is swallowed and the poll
|
|
134
|
+
// continues; only the timeout is allowed to end it.
|
|
135
|
+
let lastError;
|
|
136
|
+
while (Date.now() - startedAt < timeout) {
|
|
137
|
+
try {
|
|
138
|
+
const currentURL = await this.evaluate('() => ({ url: window.location.href })');
|
|
139
|
+
if (this.matchesURL(currentURL, url)) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
lastError = undefined;
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
146
|
+
}
|
|
147
|
+
await this.waitForTimeout(100);
|
|
148
|
+
}
|
|
149
|
+
const suffix = lastError ? ` (last read failed: ${firstLine(lastError)})` : '';
|
|
150
|
+
throw E2EError.timeout(`Wait for URL: ${this.describeURLMatcher(url)}${suffix}`, timeout, { lastError });
|
|
151
|
+
}
|
|
152
|
+
async waitForLoadState(state = 'load', options = {}) {
|
|
153
|
+
this.ensureNotClosed();
|
|
154
|
+
const timeout = options.timeout ?? this.defaultTimeout;
|
|
155
|
+
const startedAt = Date.now();
|
|
156
|
+
// Kept so the timeout can say what the page was actually doing. A bare
|
|
157
|
+
// "load timed out" is indistinguishable from a hung browser, a blocked
|
|
158
|
+
// subresource and a page that never started navigating.
|
|
159
|
+
let lastReadyState;
|
|
160
|
+
while (Date.now() - startedAt < timeout) {
|
|
161
|
+
lastReadyState = await this.evaluate('() => document.readyState');
|
|
162
|
+
if (this.matchesLoadState(lastReadyState, state)) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
await this.waitForTimeout(100);
|
|
166
|
+
}
|
|
167
|
+
// Read only on failure. Polling for it would double the round-trips of
|
|
168
|
+
// every successful wait to produce a message nobody sees.
|
|
169
|
+
const url = await this.url().catch(() => undefined);
|
|
170
|
+
throw E2EError.timeout(`Wait for load state "${state}" on ${url ?? 'unknown URL'}: ` +
|
|
171
|
+
`still at readyState "${lastReadyState ?? 'unknown'}" after ${timeout}ms. ` +
|
|
172
|
+
`"load" waits for every subresource, so one slow request is enough; ` +
|
|
173
|
+
`retry with waitUntil: 'domcontentloaded'.`, timeout, { state, readyState: lastReadyState, url, pageId: this.pageId });
|
|
174
|
+
}
|
|
175
|
+
waitForRequest(options) {
|
|
176
|
+
return this.network.waitForRequest(options);
|
|
177
|
+
}
|
|
178
|
+
waitForResponse(options) {
|
|
179
|
+
return this.network.waitForResponse(options);
|
|
180
|
+
}
|
|
181
|
+
async close() {
|
|
182
|
+
if (this.closed) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
this.logger('debug', 'Closing page');
|
|
186
|
+
try {
|
|
187
|
+
await this.mcpClient.call('close_page', { pageId: this.pageId });
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// ignore
|
|
191
|
+
}
|
|
192
|
+
this.closed = true;
|
|
193
|
+
}
|
|
194
|
+
// Page info
|
|
195
|
+
//
|
|
196
|
+
// Both of these read live rather than serving a cache. The cache went stale
|
|
197
|
+
// after any navigation the SDK did not perform itself - a form submit, a
|
|
198
|
+
// link click, history.pushState - and even straight after goto(), which
|
|
199
|
+
// stored the URL that was requested instead of the one the browser landed
|
|
200
|
+
// on. toHaveURL() and toHaveURLContaining() read url(), so after submitting
|
|
201
|
+
// a search they compared against the pre-submit address and polled until
|
|
202
|
+
// they timed out, on a page that had navigated correctly.
|
|
203
|
+
async url() {
|
|
204
|
+
this.ensureNotClosed();
|
|
205
|
+
try {
|
|
206
|
+
const result = await this.evaluate('() => ({ url: window.location.href })');
|
|
207
|
+
if (result.url) {
|
|
208
|
+
this._url = result.url;
|
|
209
|
+
return result.url;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
// Mid-navigation, or the page is gone. The last known value beats
|
|
214
|
+
// nothing, and the caller is about to be told the wait timed out anyway.
|
|
215
|
+
}
|
|
216
|
+
return this._url;
|
|
217
|
+
}
|
|
218
|
+
async title() {
|
|
219
|
+
this.ensureNotClosed();
|
|
220
|
+
try {
|
|
221
|
+
const result = await this.evaluate('() => ({ title: document.title })');
|
|
222
|
+
if (result.title) {
|
|
223
|
+
this._title = result.title;
|
|
224
|
+
return result.title;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
// Same reasoning as url(): a best-effort read, never a hard failure.
|
|
229
|
+
}
|
|
230
|
+
return this._title;
|
|
231
|
+
}
|
|
232
|
+
isActive() {
|
|
233
|
+
return this._active;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Update cached page info without replacing the instance, so Locator and
|
|
237
|
+
* Network objects created from this page stay attached to it.
|
|
238
|
+
*
|
|
239
|
+
* @internal
|
|
240
|
+
*/
|
|
241
|
+
updatePageInfo(url, title, active) {
|
|
242
|
+
if (url !== undefined)
|
|
243
|
+
this._url = url;
|
|
244
|
+
if (title !== undefined)
|
|
245
|
+
this._title = title;
|
|
246
|
+
if (active !== undefined)
|
|
247
|
+
this._active = active;
|
|
248
|
+
}
|
|
249
|
+
// Snapshot & Screenshot
|
|
250
|
+
async snapshot() {
|
|
251
|
+
this.ensureNotClosed();
|
|
252
|
+
this.logger('debug', 'Taking page snapshot');
|
|
253
|
+
const result = await this.mcpClient.call('take_snapshot', {
|
|
254
|
+
pageId: this.pageId,
|
|
255
|
+
verbose: true
|
|
256
|
+
});
|
|
257
|
+
return this.parseSnapshot(result);
|
|
258
|
+
}
|
|
259
|
+
async screenshot(options = {}) {
|
|
260
|
+
this.ensureNotClosed();
|
|
261
|
+
this.logger('debug', 'Taking screenshot');
|
|
262
|
+
const result = await this.mcpClient.call('take_screenshot', {
|
|
263
|
+
pageId: this.pageId,
|
|
264
|
+
fullPage: options.fullPage ?? false,
|
|
265
|
+
format: options.type ?? 'png',
|
|
266
|
+
// quality is ignored for PNG and rejected for unknown values upstream
|
|
267
|
+
...(options.type && options.type !== 'png' && options.quality !== undefined
|
|
268
|
+
? { quality: options.quality }
|
|
269
|
+
: {})
|
|
270
|
+
});
|
|
271
|
+
if (typeof result === 'string') {
|
|
272
|
+
return this.readSavedScreenshot(result);
|
|
273
|
+
}
|
|
274
|
+
// Convert base64 to Buffer
|
|
275
|
+
const base64Data = result.data.replace(/^data:image\/\w+;base64,/, '');
|
|
276
|
+
return Buffer.from(base64Data, 'base64');
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Screenshot a single element identified by its snapshot uid.
|
|
280
|
+
* Shared with `Locator.screenshot()` so both handle the base64 and the
|
|
281
|
+
* "saved to file" response shapes identically.
|
|
282
|
+
*
|
|
283
|
+
* @internal
|
|
284
|
+
*/
|
|
285
|
+
async screenshotElement(uid, options = {}) {
|
|
286
|
+
this.ensureNotClosed();
|
|
287
|
+
const result = await this.mcpClient.call('take_screenshot', {
|
|
288
|
+
pageId: this.pageId,
|
|
289
|
+
uid,
|
|
290
|
+
format: 'png'
|
|
291
|
+
});
|
|
292
|
+
if (typeof result === 'string') {
|
|
293
|
+
return this.readSavedScreenshot(result);
|
|
294
|
+
}
|
|
295
|
+
const base64Data = result.data.replace(/^data:image\/\w+;base64,/, '');
|
|
296
|
+
return Buffer.from(base64Data, 'base64');
|
|
297
|
+
}
|
|
298
|
+
async resize(options) {
|
|
299
|
+
this.ensureNotClosed();
|
|
300
|
+
const timeout = options.timeout ?? this.defaultTimeout;
|
|
301
|
+
await this.withTimeout(this.mcpClient.call('resize_page', {
|
|
302
|
+
pageId: this.pageId,
|
|
303
|
+
width: options.width,
|
|
304
|
+
height: options.height
|
|
305
|
+
}), timeout, `Resize page to ${options.width}x${options.height}`);
|
|
306
|
+
}
|
|
307
|
+
// Locator factory methods
|
|
308
|
+
getByText(text, options) {
|
|
309
|
+
return new Locator(this, { text, exact: options?.exact });
|
|
310
|
+
}
|
|
311
|
+
getByRole(role, options) {
|
|
312
|
+
return new Locator(this, { role, name: options?.name, exact: options?.exact });
|
|
313
|
+
}
|
|
314
|
+
getByLabel(label, options) {
|
|
315
|
+
return new Locator(this, { label, exact: options?.exact });
|
|
316
|
+
}
|
|
317
|
+
locator(selector) {
|
|
318
|
+
return new Locator(this, { css: selector });
|
|
319
|
+
}
|
|
320
|
+
click(selector, options) {
|
|
321
|
+
return new Locator(this, this.toSelector(selector, options)).click(options);
|
|
322
|
+
}
|
|
323
|
+
fill(selector, value, options) {
|
|
324
|
+
return new Locator(this, this.toSelector(selector, options)).fill(value, options);
|
|
325
|
+
}
|
|
326
|
+
toSelector(selector, options) {
|
|
327
|
+
if (typeof selector === 'string') {
|
|
328
|
+
return { text: selector, exact: options?.exact };
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
...selector,
|
|
332
|
+
exact: selector.exact ?? options?.exact
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Resolve a selector to a Chrome DevTools node uid.
|
|
337
|
+
* CSS and label selectors go through injected scripts; role, name and text
|
|
338
|
+
* selectors are matched against the a11y snapshot.
|
|
339
|
+
*
|
|
340
|
+
* @internal
|
|
341
|
+
*/
|
|
342
|
+
async resolve(selector) {
|
|
343
|
+
this.ensureNotClosed();
|
|
344
|
+
this.logger('debug', `Resolving selector: ${JSON.stringify(selector)}`);
|
|
345
|
+
if (selector.css) {
|
|
346
|
+
return this.resolveCssSelector(selector);
|
|
347
|
+
}
|
|
348
|
+
if (selector.label) {
|
|
349
|
+
return this.resolveLabelSelector(selector);
|
|
350
|
+
}
|
|
351
|
+
// Take snapshot to get current DOM
|
|
352
|
+
const snapshot = await this.snapshot();
|
|
353
|
+
// Find matching element
|
|
354
|
+
const element = this.findElement(snapshot, selector);
|
|
355
|
+
if (!element) {
|
|
356
|
+
throw E2EError.elementNotFound(selector, this._url);
|
|
357
|
+
}
|
|
358
|
+
return { uid: element.uid };
|
|
359
|
+
}
|
|
360
|
+
/** @internal */
|
|
361
|
+
async callTool(name, arguments_) {
|
|
362
|
+
this.ensureNotClosed();
|
|
363
|
+
const result = await this.mcpClient.call(name, arguments_);
|
|
364
|
+
// evaluate_script is the one tool whose "structured" content is not
|
|
365
|
+
// structured: upstream has no field for it and drops the whole reply,
|
|
366
|
+
// fenced value and all, into `message`. Every caller of that tool assumes
|
|
367
|
+
// it got the script's return value, so the envelope is unwrapped here
|
|
368
|
+
// rather than at each of the ten call sites.
|
|
369
|
+
return (name === 'evaluate_script' ? unwrapScriptResult(result) : result);
|
|
370
|
+
}
|
|
371
|
+
/** @internal */
|
|
372
|
+
async evaluateLocator(selector, body, operation, options = {}) {
|
|
373
|
+
const timeout = options.timeout ?? this.defaultTimeout;
|
|
374
|
+
if (selector.css) {
|
|
375
|
+
return this.withTimeout(this.callTool('evaluate_script', {
|
|
376
|
+
pageId: this.pageId,
|
|
377
|
+
function: `() => {
|
|
378
|
+
const elements = Array.from(document.querySelectorAll(${JSON.stringify(selector.css)}));
|
|
379
|
+
const element = elements[${selector.index ?? 0}];
|
|
380
|
+
if (!element) return false;
|
|
381
|
+
${body}
|
|
382
|
+
}`,
|
|
383
|
+
waitForStableDom: false
|
|
384
|
+
}), timeout, operation).then((result) => {
|
|
385
|
+
if (result === false) {
|
|
386
|
+
throw E2EError.elementNotFound(selector);
|
|
387
|
+
}
|
|
388
|
+
return result;
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
if (selector.label) {
|
|
392
|
+
return this.withTimeout(this.callTool('evaluate_script', {
|
|
393
|
+
pageId: this.pageId,
|
|
394
|
+
function: `() => {
|
|
395
|
+
${this.labelFinderSource(selector)}
|
|
396
|
+
const element = findByLabel();
|
|
397
|
+
if (!element) return false;
|
|
398
|
+
${body}
|
|
399
|
+
}`,
|
|
400
|
+
waitForStableDom: false
|
|
401
|
+
}), timeout, operation).then((result) => {
|
|
402
|
+
if (result === false) {
|
|
403
|
+
throw E2EError.elementNotFound(selector);
|
|
404
|
+
}
|
|
405
|
+
return result;
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
throw E2EError.invalidArgument('selector', 'one of role, name, text, label or css', selector);
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Injected helper that locates the form control a visible label refers to.
|
|
412
|
+
*
|
|
413
|
+
* Covers the shapes `getByLabel` has to survive in real pages: `aria-label`,
|
|
414
|
+
* `<label for>` / wrapping labels, and components that render a label
|
|
415
|
+
* element next to the input inside a `.el-form-item`-style row.
|
|
416
|
+
*/
|
|
417
|
+
labelFinderSource(selector) {
|
|
418
|
+
return `
|
|
419
|
+
const expected = ${JSON.stringify(selector.label)};
|
|
420
|
+
const exact = ${JSON.stringify(selector.exact ?? false)};
|
|
421
|
+
const index = ${selector.index ?? 0};
|
|
422
|
+
const controlSelector = 'input, textarea, select, [contenteditable="true"], [role="textbox"]';
|
|
423
|
+
const normalize = (value) => (value ?? '').replace(/\\s+/g, ' ').trim();
|
|
424
|
+
const matches = (value) => {
|
|
425
|
+
const text = normalize(value);
|
|
426
|
+
return exact ? text === expected : text.includes(expected);
|
|
427
|
+
};
|
|
428
|
+
const ownText = (node) => normalize(Array.from(node.childNodes)
|
|
429
|
+
.filter((child) => child.nodeType === Node.TEXT_NODE)
|
|
430
|
+
.map((child) => child.textContent)
|
|
431
|
+
.join(' '));
|
|
432
|
+
const findControlNear = (node) => {
|
|
433
|
+
if (!node) return null;
|
|
434
|
+
if (node.matches?.(controlSelector)) return node;
|
|
435
|
+
const inside = node.querySelector?.(controlSelector);
|
|
436
|
+
if (inside) return inside;
|
|
437
|
+
const container = node.closest?.('.w-search-item, .el-form-item, [class*="search-item"], [class*="form-item"], label, td, tr, li, div');
|
|
438
|
+
return container?.querySelector?.(controlSelector) ?? null;
|
|
439
|
+
};
|
|
440
|
+
const unique = (elements) => Array.from(new Set(elements));
|
|
441
|
+
const findByLabel = () => {
|
|
442
|
+
const matchesByAria = Array.from(document.querySelectorAll(controlSelector))
|
|
443
|
+
.filter((element) => matches(element.getAttribute('aria-label')));
|
|
444
|
+
const nativeLabelMatches = Array.from(document.querySelectorAll('label'))
|
|
445
|
+
.filter((element) => matches(element.textContent))
|
|
446
|
+
.map((element) => element.htmlFor ? document.getElementById(element.htmlFor) : findControlNear(element))
|
|
447
|
+
.filter(Boolean);
|
|
448
|
+
const directTextMatches = Array.from(document.querySelectorAll('body *'))
|
|
449
|
+
.filter((element) => matches(ownText(element)))
|
|
450
|
+
.map((element) => findControlNear(element))
|
|
451
|
+
.filter(Boolean);
|
|
452
|
+
const descendantTextMatches = Array.from(document.querySelectorAll('body *'))
|
|
453
|
+
.filter((element) => matches(element.textContent))
|
|
454
|
+
.sort((left, right) => left.querySelectorAll('*').length - right.querySelectorAll('*').length)
|
|
455
|
+
.map((element) => findControlNear(element))
|
|
456
|
+
.filter(Boolean);
|
|
457
|
+
return unique([...matchesByAria, ...nativeLabelMatches, ...directTextMatches, ...descendantTextMatches])[index] ?? null;
|
|
458
|
+
};
|
|
459
|
+
`;
|
|
460
|
+
}
|
|
461
|
+
findElement(snapshot, selector) {
|
|
462
|
+
for (const node of snapshot.nodes) {
|
|
463
|
+
// Match by role
|
|
464
|
+
if (selector.role && node.role === selector.role) {
|
|
465
|
+
if (!selector.name || this.matchName(node.name, selector.name, selector.exact)) {
|
|
466
|
+
return { uid: node.uid };
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// Match by text
|
|
470
|
+
if (selector.text && node.text && this.matchName(node.text, selector.text, selector.exact)) {
|
|
471
|
+
return { uid: node.uid };
|
|
472
|
+
}
|
|
473
|
+
// Match by label (aria-label, aria-labelledby, etc.)
|
|
474
|
+
if (selector.label && node.attributes) {
|
|
475
|
+
const ariaLabel = node.attributes['aria-label'] || node.attributes['aria-labelledby'];
|
|
476
|
+
if (ariaLabel && this.matchName(ariaLabel, selector.label, selector.exact)) {
|
|
477
|
+
return { uid: node.uid };
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
async resolveCssSelector(selector) {
|
|
484
|
+
return this.resolveViaMarker(selector, `
|
|
485
|
+
const elements = Array.from(document.querySelectorAll(${JSON.stringify(selector.css)}));
|
|
486
|
+
const element = elements[${selector.index ?? 0}];
|
|
487
|
+
`);
|
|
488
|
+
}
|
|
489
|
+
async resolveLabelSelector(selector) {
|
|
490
|
+
return this.resolveViaMarker(selector, `
|
|
491
|
+
${this.labelFinderSource(selector)}
|
|
492
|
+
const element = findByLabel();
|
|
493
|
+
`);
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Tag the target element with a throwaway attribute, read its snapshot uid
|
|
497
|
+
* back, then remove the attribute. `evaluate_script` only accepts element
|
|
498
|
+
* uids as arguments, so this is how a CSS or label selector is turned into
|
|
499
|
+
* something the uid-based tools understand.
|
|
500
|
+
*/
|
|
501
|
+
async resolveViaMarker(selector, findSource) {
|
|
502
|
+
const marker = `chrome-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
503
|
+
const marked = await this.callTool('evaluate_script', {
|
|
504
|
+
pageId: this.pageId,
|
|
505
|
+
function: `() => {
|
|
506
|
+
${findSource}
|
|
507
|
+
if (!element) return false;
|
|
508
|
+
element.setAttribute('data-chrome-e2e-resolve', ${JSON.stringify(marker)});
|
|
509
|
+
return true;
|
|
510
|
+
}`,
|
|
511
|
+
waitForStableDom: false
|
|
512
|
+
});
|
|
513
|
+
if (marked === false) {
|
|
514
|
+
throw E2EError.elementNotFound(selector, this._url);
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
const snapshot = await this.snapshot();
|
|
518
|
+
const element = snapshot.nodes.find((node) => node.attributes?.['data-chrome-e2e-resolve'] === marker);
|
|
519
|
+
if (!element) {
|
|
520
|
+
throw E2EError.elementNotFound(selector, this._url);
|
|
521
|
+
}
|
|
522
|
+
return { uid: element.uid };
|
|
523
|
+
}
|
|
524
|
+
finally {
|
|
525
|
+
await this.callTool('evaluate_script', {
|
|
526
|
+
pageId: this.pageId,
|
|
527
|
+
function: `() => {
|
|
528
|
+
document.querySelectorAll(${JSON.stringify(`[data-chrome-e2e-resolve="${marker}"]`)}).forEach((element) => {
|
|
529
|
+
element.removeAttribute('data-chrome-e2e-resolve');
|
|
530
|
+
});
|
|
531
|
+
return true;
|
|
532
|
+
}`,
|
|
533
|
+
waitForStableDom: false
|
|
534
|
+
})
|
|
535
|
+
.catch(() => undefined);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
parseNavigationFailure(result) {
|
|
539
|
+
if (typeof result !== 'string') {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
const match = result.match(/Unable to navigate[^:]*:\s*(.+)/);
|
|
543
|
+
return match?.[1]?.trim() ?? null;
|
|
544
|
+
}
|
|
545
|
+
matchName(actual, expected, exact = false) {
|
|
546
|
+
if (!actual)
|
|
547
|
+
return false;
|
|
548
|
+
if (exact)
|
|
549
|
+
return actual === expected;
|
|
550
|
+
return actual.includes(expected);
|
|
551
|
+
}
|
|
552
|
+
matchesURL(actual, expected) {
|
|
553
|
+
const actualURL = typeof actual === 'string' ? actual : actual.url;
|
|
554
|
+
if (typeof expected === 'function') {
|
|
555
|
+
return expected(actualURL);
|
|
556
|
+
}
|
|
557
|
+
if (expected instanceof RegExp) {
|
|
558
|
+
return expected.test(actualURL);
|
|
559
|
+
}
|
|
560
|
+
return actualURL.includes(expected);
|
|
561
|
+
}
|
|
562
|
+
describeURLMatcher(matcher) {
|
|
563
|
+
if (typeof matcher === 'function')
|
|
564
|
+
return 'function';
|
|
565
|
+
if (matcher instanceof RegExp)
|
|
566
|
+
return matcher.toString();
|
|
567
|
+
return matcher;
|
|
568
|
+
}
|
|
569
|
+
matchesLoadState(actual, expected) {
|
|
570
|
+
if (expected === 'domcontentloaded') {
|
|
571
|
+
return actual === 'interactive' || actual === 'complete';
|
|
572
|
+
}
|
|
573
|
+
if (expected === 'networkidle') {
|
|
574
|
+
return actual === 'complete';
|
|
575
|
+
}
|
|
576
|
+
return actual === 'complete';
|
|
577
|
+
}
|
|
578
|
+
// Wait for any of the given texts to appear on the page
|
|
579
|
+
async waitForText(texts, options = {}) {
|
|
580
|
+
this.ensureNotClosed();
|
|
581
|
+
const list = Array.isArray(texts) ? texts : [texts];
|
|
582
|
+
if (list.length === 0) {
|
|
583
|
+
throw E2EError.invalidArgument('texts', 'non-empty string or string[]', list);
|
|
584
|
+
}
|
|
585
|
+
const timeout = options.timeout ?? this.defaultTimeout;
|
|
586
|
+
await this.withTimeout(this.callTool('wait_for', { pageId: this.pageId, text: list, timeout }), timeout, `Wait for text: ${list.join(' | ')}`);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Fill several form controls in one call. `chrome-devtools-mcp` documents
|
|
590
|
+
* `fill_form` as strongly preferred over repeated `fill` calls: it is faster
|
|
591
|
+
* and more reliable because every control is resolved from one snapshot.
|
|
592
|
+
*/
|
|
593
|
+
async fillForm(fields, options = {}) {
|
|
594
|
+
this.ensureNotClosed();
|
|
595
|
+
if (!Array.isArray(fields) || fields.length === 0) {
|
|
596
|
+
throw E2EError.invalidArgument('fields', 'non-empty array of { label | uid, value }', fields);
|
|
597
|
+
}
|
|
598
|
+
const timeout = options.timeout ?? this.defaultTimeout;
|
|
599
|
+
const explicit = new Map();
|
|
600
|
+
const labels = new Map();
|
|
601
|
+
fields.forEach((field, index) => {
|
|
602
|
+
if ('uid' in field && field.uid) {
|
|
603
|
+
explicit.set(index, field.uid);
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
labels.set(index, field.label);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
// Label lookups are marked in one script and read back from a single
|
|
610
|
+
// snapshot, so a multi-field form costs one snapshot instead of one each.
|
|
611
|
+
const resolved = labels.size > 0 ? await this.resolveLabels([...labels.values()]) : new Map();
|
|
612
|
+
const elements = fields.map((field, index) => ({
|
|
613
|
+
uid: explicit.get(index) ?? resolved.get(index),
|
|
614
|
+
value: field.value
|
|
615
|
+
}));
|
|
616
|
+
await this.withTimeout(this.callTool('fill_form', { pageId: this.pageId, elements }), timeout, `Fill form (${fields.length} fields)`);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Resolve several label selectors at once: mark every match, take a single
|
|
620
|
+
* snapshot, then read each uid back and clean up.
|
|
621
|
+
*/
|
|
622
|
+
async resolveLabels(labels) {
|
|
623
|
+
const marker = `chrome-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
624
|
+
const attribute = 'data-chrome-e2e-resolve';
|
|
625
|
+
const source = labels.map((label, index) => {
|
|
626
|
+
const selector = { label };
|
|
627
|
+
return `
|
|
628
|
+
{
|
|
629
|
+
${this.labelFinderSource(selector)}
|
|
630
|
+
const target = findByLabel();
|
|
631
|
+
if (target) target.setAttribute(${JSON.stringify(attribute)}, ${JSON.stringify(`${marker}-${index}`)});
|
|
632
|
+
}`;
|
|
633
|
+
}).join('\n');
|
|
634
|
+
await this.callTool('evaluate_script', {
|
|
635
|
+
pageId: this.pageId,
|
|
636
|
+
function: `() => {
|
|
637
|
+
${source}
|
|
638
|
+
return true;
|
|
639
|
+
}`,
|
|
640
|
+
waitForStableDom: false
|
|
641
|
+
});
|
|
642
|
+
try {
|
|
643
|
+
const snapshot = await this.snapshot();
|
|
644
|
+
const byMarker = new Map();
|
|
645
|
+
for (const node of snapshot.nodes) {
|
|
646
|
+
const value = node.attributes?.[attribute];
|
|
647
|
+
if (value)
|
|
648
|
+
byMarker.set(value, node.uid);
|
|
649
|
+
}
|
|
650
|
+
const result = new Map();
|
|
651
|
+
labels.forEach((label, index) => {
|
|
652
|
+
const uid = byMarker.get(`${marker}-${index}`);
|
|
653
|
+
if (!uid) {
|
|
654
|
+
throw E2EError.elementNotFound({ label }, this._url);
|
|
655
|
+
}
|
|
656
|
+
result.set(index, uid);
|
|
657
|
+
});
|
|
658
|
+
return result;
|
|
659
|
+
}
|
|
660
|
+
finally {
|
|
661
|
+
await this.callTool('evaluate_script', {
|
|
662
|
+
pageId: this.pageId,
|
|
663
|
+
function: `() => {
|
|
664
|
+
document.querySelectorAll(${JSON.stringify(`[${attribute}^="${marker}-"]`)}).forEach((element) => {
|
|
665
|
+
element.removeAttribute(${JSON.stringify(attribute)});
|
|
666
|
+
});
|
|
667
|
+
return true;
|
|
668
|
+
}`,
|
|
669
|
+
waitForStableDom: false
|
|
670
|
+
}).catch(() => undefined);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Emulate network, CPU, user agent, colour scheme, viewport or geolocation.
|
|
675
|
+
* Omitting a key leaves that dimension untouched upstream; `clearEmulation()`
|
|
676
|
+
* resets all of them at once.
|
|
677
|
+
*/
|
|
678
|
+
async emulate(options = {}) {
|
|
679
|
+
this.ensureNotClosed();
|
|
680
|
+
const timeout = options.timeout ?? this.defaultTimeout;
|
|
681
|
+
const params = { pageId: this.pageId };
|
|
682
|
+
if (options.networkConditions !== undefined)
|
|
683
|
+
params['networkConditions'] = options.networkConditions;
|
|
684
|
+
if (options.cpuThrottlingRate !== undefined)
|
|
685
|
+
params['cpuThrottlingRate'] = options.cpuThrottlingRate;
|
|
686
|
+
if (options.userAgent !== undefined)
|
|
687
|
+
params['userAgent'] = options.userAgent;
|
|
688
|
+
if (options.colorScheme !== undefined)
|
|
689
|
+
params['colorScheme'] = options.colorScheme;
|
|
690
|
+
if (options.viewport !== undefined)
|
|
691
|
+
params['viewport'] = options.viewport;
|
|
692
|
+
if (options.geolocation !== undefined)
|
|
693
|
+
params['geolocation'] = options.geolocation;
|
|
694
|
+
await this.withTimeout(this.callTool('emulate', params), timeout, 'Emulate');
|
|
695
|
+
}
|
|
696
|
+
/** Reset every emulated dimension. */
|
|
697
|
+
async clearEmulation(options = {}) {
|
|
698
|
+
this.ensureNotClosed();
|
|
699
|
+
const timeout = options.timeout ?? this.defaultTimeout;
|
|
700
|
+
await this.withTimeout(this.callTool('emulate', { pageId: this.pageId }), timeout, 'Clear emulation');
|
|
701
|
+
}
|
|
702
|
+
/** Fetch one console message with its arguments and stack trace. */
|
|
703
|
+
async getConsoleMessage(messageId) {
|
|
704
|
+
this.ensureNotClosed();
|
|
705
|
+
const result = await this.mcpClient.call('get_console_message', { pageId: this.pageId, msgid: messageId });
|
|
706
|
+
if (typeof result === 'object' && result !== null && result.consoleMessage) {
|
|
707
|
+
return result.consoleMessage;
|
|
708
|
+
}
|
|
709
|
+
if (typeof result === 'string') {
|
|
710
|
+
return this.parseDetailedConsoleMessage(result, messageId);
|
|
711
|
+
}
|
|
712
|
+
throw E2EError.mcpError(`get_console_message returned no message for id ${messageId}`);
|
|
713
|
+
}
|
|
714
|
+
// Network access
|
|
715
|
+
get network() {
|
|
716
|
+
return this.networkInstance;
|
|
717
|
+
}
|
|
718
|
+
get localStorage() {
|
|
719
|
+
return this.localStorageInstance;
|
|
720
|
+
}
|
|
721
|
+
// Console messages
|
|
722
|
+
async consoleMessages(options = {}) {
|
|
723
|
+
this.ensureNotClosed();
|
|
724
|
+
const result = await this.mcpClient.call('list_console_messages', {
|
|
725
|
+
pageId: this.pageId,
|
|
726
|
+
includeStackTraces: options.includeStackTraces ?? false
|
|
727
|
+
});
|
|
728
|
+
if (Array.isArray(result))
|
|
729
|
+
return result;
|
|
730
|
+
if (typeof result === 'object' && result !== null && Array.isArray(result.consoleMessages)) {
|
|
731
|
+
return result.consoleMessages;
|
|
732
|
+
}
|
|
733
|
+
return this.parseConsoleMessages(String(result));
|
|
734
|
+
}
|
|
735
|
+
// Evaluate script in page context
|
|
736
|
+
async evaluate(script, args, options) {
|
|
737
|
+
this.ensureNotClosed();
|
|
738
|
+
const timeout = options?.timeout ?? this.defaultTimeout;
|
|
739
|
+
if (!args || args.length === 0) {
|
|
740
|
+
return this.withTimeout(this.callTool('evaluate_script', { pageId: this.pageId, function: script }), timeout, 'Evaluate script');
|
|
741
|
+
}
|
|
742
|
+
return this.withTimeout(this.callTool('evaluate_script', {
|
|
743
|
+
pageId: this.pageId,
|
|
744
|
+
function: `() => {
|
|
745
|
+
const fn = (${script});
|
|
746
|
+
return fn(...${JSON.stringify(args)});
|
|
747
|
+
}`
|
|
748
|
+
}), timeout, 'Evaluate script');
|
|
749
|
+
}
|
|
750
|
+
// Page ID getter
|
|
751
|
+
getPageId() {
|
|
752
|
+
return this.pageId;
|
|
753
|
+
}
|
|
754
|
+
isClosed() {
|
|
755
|
+
return this.closed;
|
|
756
|
+
}
|
|
757
|
+
getDefaultTimeout() {
|
|
758
|
+
return this.defaultTimeout;
|
|
759
|
+
}
|
|
760
|
+
setDefaultTimeout(timeout) {
|
|
761
|
+
if (!Number.isFinite(timeout) || timeout < 0) {
|
|
762
|
+
throw E2EError.invalidArgument('timeout', 'non-negative number', timeout);
|
|
763
|
+
}
|
|
764
|
+
this.defaultTimeout = timeout;
|
|
765
|
+
this.networkInstance.setDefaultTimeout(timeout);
|
|
766
|
+
}
|
|
767
|
+
ensureNotClosed() {
|
|
768
|
+
if (this.closed) {
|
|
769
|
+
throw E2EError.browserClosed();
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
parseSnapshot(result) {
|
|
773
|
+
if (typeof result === 'object' && result !== null && result.snapshot) {
|
|
774
|
+
return this.parseStructuredSnapshot(result.snapshot);
|
|
775
|
+
}
|
|
776
|
+
const text = String(result);
|
|
777
|
+
const nodes = [];
|
|
778
|
+
for (const line of text.split(/\r?\n/)) {
|
|
779
|
+
const uidMatch = line.match(/uid=([^\s]+)/);
|
|
780
|
+
const uid = uidMatch?.[1];
|
|
781
|
+
if (!uid)
|
|
782
|
+
continue;
|
|
783
|
+
const rest = line.slice(line.indexOf(uidMatch[0]) + uidMatch[0].length).trim();
|
|
784
|
+
const role = rest.match(/^([^\s"]+)/)?.[1] ?? '';
|
|
785
|
+
const name = rest.match(/"([^"]*)"/)?.[1];
|
|
786
|
+
const attributes = Object.fromEntries(Array.from(rest.matchAll(/\s([a-zA-Z][\w-]*)="([^"]*)"/g))
|
|
787
|
+
.filter((match) => match[1] && match[2] !== undefined)
|
|
788
|
+
.map((match) => [match[1], match[2]]));
|
|
789
|
+
nodes.push({
|
|
790
|
+
uid,
|
|
791
|
+
tagName: role,
|
|
792
|
+
attributes,
|
|
793
|
+
children: [],
|
|
794
|
+
text: name,
|
|
795
|
+
role,
|
|
796
|
+
name
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
return { nodes };
|
|
800
|
+
}
|
|
801
|
+
parseStructuredSnapshot(snapshot) {
|
|
802
|
+
// `chrome-devtools-mcp` returns a single nested tree rooted at the document node.
|
|
803
|
+
if (typeof snapshot === 'object' && snapshot !== null && !Array.isArray(snapshot)) {
|
|
804
|
+
return { nodes: this.flattenSnapshotNodes(snapshot) };
|
|
805
|
+
}
|
|
806
|
+
if (Array.isArray(snapshot)) {
|
|
807
|
+
return {
|
|
808
|
+
nodes: snapshot
|
|
809
|
+
.filter((node) => typeof node === 'object' && node !== null)
|
|
810
|
+
.flatMap((node) => this.flattenSnapshotNodes(node))
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
return this.parseSnapshot(String(snapshot));
|
|
814
|
+
}
|
|
815
|
+
flattenSnapshotNodes(node) {
|
|
816
|
+
const normalized = this.normalizeSnapshotNode(node);
|
|
817
|
+
const children = Array.isArray(node['children']) ? node['children'] : [];
|
|
818
|
+
return [
|
|
819
|
+
normalized,
|
|
820
|
+
...children
|
|
821
|
+
.filter((child) => typeof child === 'object' && child !== null)
|
|
822
|
+
.flatMap((child) => this.flattenSnapshotNodes(child))
|
|
823
|
+
];
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* `chrome-devtools-mcp` serializes each snapshot node as a flat object where
|
|
827
|
+
* `id`, `role` and `name` sit next to every extracted DOM attribute
|
|
828
|
+
* (`aria-label`, `placeholder`, `disabled`, ...). Both that flat shape and a
|
|
829
|
+
* nested `attributes` object are accepted.
|
|
830
|
+
*/
|
|
831
|
+
normalizeSnapshotNode(node) {
|
|
832
|
+
const uid = String(node['uid'] ?? node['id'] ?? '');
|
|
833
|
+
const role = typeof node['role'] === 'string' ? node['role'] : '';
|
|
834
|
+
const name = typeof node['name'] === 'string' ? node['name'] : undefined;
|
|
835
|
+
const text = typeof node['text'] === 'string' ? node['text'] : name;
|
|
836
|
+
const attributes = {};
|
|
837
|
+
for (const [key, value] of Object.entries(node)) {
|
|
838
|
+
if (SNAPSHOT_RESERVED_KEYS.has(key))
|
|
839
|
+
continue;
|
|
840
|
+
if (value === null || value === undefined)
|
|
841
|
+
continue;
|
|
842
|
+
if (typeof value === 'object')
|
|
843
|
+
continue;
|
|
844
|
+
attributes[key] = String(value);
|
|
845
|
+
}
|
|
846
|
+
if (typeof node['attributes'] === 'object' && node['attributes'] !== null) {
|
|
847
|
+
for (const [key, value] of Object.entries(node['attributes'])) {
|
|
848
|
+
attributes[key] = String(value);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
return {
|
|
852
|
+
uid,
|
|
853
|
+
tagName: typeof node['tagName'] === 'string' ? node['tagName'] : role,
|
|
854
|
+
attributes,
|
|
855
|
+
// Children are nested node objects upstream; expose their uids for traversal.
|
|
856
|
+
children: Array.isArray(node['children'])
|
|
857
|
+
? node['children']
|
|
858
|
+
.map((child) => typeof child === 'object' && child !== null
|
|
859
|
+
? String(child['id'] ?? child['uid'] ?? '')
|
|
860
|
+
: String(child))
|
|
861
|
+
.filter(Boolean)
|
|
862
|
+
: [],
|
|
863
|
+
text,
|
|
864
|
+
role,
|
|
865
|
+
name
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
parseConsoleMessages(text) {
|
|
869
|
+
const messages = [];
|
|
870
|
+
for (const line of text.split(/\r?\n/)) {
|
|
871
|
+
const match = line.match(/^msgid=(\d+)\s+\[([^\]]+)\]\s+(.*?)\s+\((\d+)\s+args\)(?:\s+\[(\d+) times\])?/);
|
|
872
|
+
if (match?.[2] && match[3] !== undefined) {
|
|
873
|
+
messages.push({
|
|
874
|
+
id: match[1] ? Number(match[1]) : undefined,
|
|
875
|
+
type: match[2],
|
|
876
|
+
text: match[3],
|
|
877
|
+
argsCount: match[4] ? Number(match[4]) : undefined,
|
|
878
|
+
count: match[5] ? Number(match[5]) : undefined
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return messages;
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* `get_console_message` answers with text when structured content is off:
|
|
886
|
+
*
|
|
887
|
+
* ```text
|
|
888
|
+
* ID: 7
|
|
889
|
+
* Message: error> Uncaught TypeError
|
|
890
|
+
* ### Arguments
|
|
891
|
+
* Arg #0: undefined
|
|
892
|
+
* ### Stack trace
|
|
893
|
+
* at ...
|
|
894
|
+
* ```
|
|
895
|
+
*/
|
|
896
|
+
parseDetailedConsoleMessage(text, messageId) {
|
|
897
|
+
const id = Number(text.match(/^ID:\s*(\d+)/m)?.[1] ?? messageId);
|
|
898
|
+
const messageLine = text.match(/^Message:\s*([a-z]+)>(.*)$/m);
|
|
899
|
+
if (!messageLine) {
|
|
900
|
+
throw E2EError.mcpError(`Could not parse get_console_message response for id ${messageId}`);
|
|
901
|
+
}
|
|
902
|
+
const detail = {
|
|
903
|
+
id,
|
|
904
|
+
type: messageLine[1] ?? 'log',
|
|
905
|
+
text: (messageLine[2] ?? '').trim()
|
|
906
|
+
};
|
|
907
|
+
const argsBlock = text.match(/### Arguments\n([\s\S]*?)(?=\n###|$)/);
|
|
908
|
+
if (argsBlock?.[1]) {
|
|
909
|
+
detail.args = argsBlock[1]
|
|
910
|
+
.split('\n')
|
|
911
|
+
.map((line) => line.replace(/^Arg #\d+:\s*/, '').trim())
|
|
912
|
+
.filter(Boolean);
|
|
913
|
+
}
|
|
914
|
+
const stackBlock = text.match(/### Stack trace\n([\s\S]*?)(?=\n###|$)/);
|
|
915
|
+
if (stackBlock?.[1]) {
|
|
916
|
+
detail.stackTrace = stackBlock[1].trim();
|
|
917
|
+
}
|
|
918
|
+
return detail;
|
|
919
|
+
}
|
|
920
|
+
async readSavedScreenshot(text) {
|
|
921
|
+
const match = text.match(/Saved screenshot to (.+?)(?:\r?\n|$)/);
|
|
922
|
+
if (!match?.[1]) {
|
|
923
|
+
throw E2EError.mcpError('Screenshot response did not include image data or a saved file path');
|
|
924
|
+
}
|
|
925
|
+
return readFile(match[1].replace(/\.$/, ''));
|
|
926
|
+
}
|
|
927
|
+
async withTimeout(promise, ms, operation) {
|
|
928
|
+
let timeoutId;
|
|
929
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
930
|
+
timeoutId = setTimeout(() => reject(E2EError.timeout(operation, ms)), ms);
|
|
931
|
+
});
|
|
932
|
+
try {
|
|
933
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
934
|
+
}
|
|
935
|
+
finally {
|
|
936
|
+
clearTimeout(timeoutId);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
/** The first line of a message, so an error stays readable on one line. */
|
|
941
|
+
function firstLine(message) {
|
|
942
|
+
return message.split('\n')[0]?.trim() ?? message;
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Unwraps the envelope `evaluate_script` returns.
|
|
946
|
+
*
|
|
947
|
+
* The tool has no dedicated structured field, so upstream falls back to
|
|
948
|
+
* `structuredContent.message` and leaves the whole human-readable reply in it,
|
|
949
|
+
* fenced value included:
|
|
950
|
+
*
|
|
951
|
+
* { message: 'Script ran on page and returned:\n```json\n"complete"\n```' }
|
|
952
|
+
*
|
|
953
|
+
* Returning that verbatim makes every caller believe the script returned an
|
|
954
|
+
* object. `waitForLoadState` compared it against 'complete', never matched, and
|
|
955
|
+
* polled until the timeout fired on a page that had in fact loaded instantly.
|
|
956
|
+
* Every other evaluate-backed helper was silently broken the same way.
|
|
957
|
+
*/
|
|
958
|
+
export function unwrapScriptResult(raw) {
|
|
959
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
960
|
+
return raw;
|
|
961
|
+
}
|
|
962
|
+
const message = raw.message;
|
|
963
|
+
if (typeof message !== 'string') {
|
|
964
|
+
return raw;
|
|
965
|
+
}
|
|
966
|
+
const fenced = message.match(/```json\s*([\s\S]*?)\s*```/);
|
|
967
|
+
if (!fenced?.[1]) {
|
|
968
|
+
// No fenced value: the script threw, or wrote a file. Either way the
|
|
969
|
+
// message itself is the useful part, so pass it through as text.
|
|
970
|
+
return message;
|
|
971
|
+
}
|
|
972
|
+
try {
|
|
973
|
+
return JSON.parse(fenced[1]);
|
|
974
|
+
}
|
|
975
|
+
catch {
|
|
976
|
+
return message;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
//# sourceMappingURL=Page.js.map
|