chrome-e2e-sdk 0.0.0-stage → 1.0.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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +739 -2
  3. package/dist/assertion/Assertion.d.ts +87 -0
  4. package/dist/assertion/Assertion.d.ts.map +1 -0
  5. package/dist/assertion/Assertion.js +459 -0
  6. package/dist/assertion/Assertion.js.map +1 -0
  7. package/dist/assertion/recorder.d.ts +41 -0
  8. package/dist/assertion/recorder.d.ts.map +1 -0
  9. package/dist/assertion/recorder.js +37 -0
  10. package/dist/assertion/recorder.js.map +1 -0
  11. package/dist/browser/Browser.d.ts +44 -0
  12. package/dist/browser/Browser.d.ts.map +1 -0
  13. package/dist/browser/Browser.js +167 -0
  14. package/dist/browser/Browser.js.map +1 -0
  15. package/dist/browser/Locator.d.ts +94 -0
  16. package/dist/browser/Locator.d.ts.map +1 -0
  17. package/dist/browser/Locator.js +533 -0
  18. package/dist/browser/Locator.js.map +1 -0
  19. package/dist/browser/Page.d.ts +272 -0
  20. package/dist/browser/Page.d.ts.map +1 -0
  21. package/dist/browser/Page.js +979 -0
  22. package/dist/browser/Page.js.map +1 -0
  23. package/dist/client/MCPClient.d.ts +72 -0
  24. package/dist/client/MCPClient.d.ts.map +1 -0
  25. package/dist/client/MCPClient.js +302 -0
  26. package/dist/client/MCPClient.js.map +1 -0
  27. package/dist/client/MCPCommand.d.ts +51 -0
  28. package/dist/client/MCPCommand.d.ts.map +1 -0
  29. package/dist/client/MCPCommand.js +74 -0
  30. package/dist/client/MCPCommand.js.map +1 -0
  31. package/dist/env/loadEnv.d.ts +8 -0
  32. package/dist/env/loadEnv.d.ts.map +1 -0
  33. package/dist/env/loadEnv.js +19 -0
  34. package/dist/env/loadEnv.js.map +1 -0
  35. package/dist/errors/E2EError.d.ts +50 -0
  36. package/dist/errors/E2EError.d.ts.map +1 -0
  37. package/dist/errors/E2EError.js +90 -0
  38. package/dist/errors/E2EError.js.map +1 -0
  39. package/dist/index.d.ts +32 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +37 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/logger.d.ts +12 -0
  44. package/dist/logger.d.ts.map +1 -0
  45. package/dist/logger.js +57 -0
  46. package/dist/logger.js.map +1 -0
  47. package/dist/network/Network.d.ts +72 -0
  48. package/dist/network/Network.d.ts.map +1 -0
  49. package/dist/network/Network.js +236 -0
  50. package/dist/network/Network.js.map +1 -0
  51. package/dist/test/HtmlReport.d.ts +26 -0
  52. package/dist/test/HtmlReport.d.ts.map +1 -0
  53. package/dist/test/HtmlReport.js +653 -0
  54. package/dist/test/HtmlReport.js.map +1 -0
  55. package/dist/test/Runner.d.ts +146 -0
  56. package/dist/test/Runner.d.ts.map +1 -0
  57. package/dist/test/Runner.js +390 -0
  58. package/dist/test/Runner.js.map +1 -0
  59. package/dist/test/index.d.ts +4 -0
  60. package/dist/test/index.d.ts.map +1 -0
  61. package/dist/test/index.js +3 -0
  62. package/dist/test/index.js.map +1 -0
  63. package/dist/version.d.ts +2 -0
  64. package/dist/version.d.ts.map +1 -0
  65. package/dist/version.js +4 -0
  66. package/dist/version.js.map +1 -0
  67. 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