chrometools-mcp 3.2.10 → 3.3.6
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/CHANGELOG.md +122 -0
- package/README.md +14 -5
- package/angular-tools.js +9 -3
- package/bridge/bridge-client.js +62 -7
- package/bridge/bridge-service.js +80 -2
- package/extension/background.js +117 -0
- package/extension/content.js +3 -1
- package/extension/manifest.json +2 -1
- package/index.js +164 -45
- package/models/TextInputModel.js +56 -5
- package/models/index.js +20 -6
- package/nul +0 -0
- package/package.json +1 -1
- package/pom/apom-tree-converter.js +19 -7
- package/server/tool-schemas.js +3 -0
- package/utils/hints-generator.js +46 -4
- package/utils/post-click-diagnostics.js +146 -47
package/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import {Server} from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
4
|
import {StdioServerTransport} from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
@@ -360,12 +360,65 @@ async function executeToolInternal(name, args) {
|
|
|
360
360
|
};
|
|
361
361
|
}
|
|
362
362
|
|
|
363
|
+
/**
|
|
364
|
+
* Check if identifier looks like an APOM ID (e.g., button_8, input_4, form_1)
|
|
365
|
+
*/
|
|
366
|
+
function isApomIdPattern(identifier) {
|
|
367
|
+
return /^[a-z]+_\d+$/.test(identifier);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Quick element registration - runs APOM analysis and registers elements
|
|
372
|
+
*/
|
|
373
|
+
async function quickRegisterElements(page) {
|
|
374
|
+
await page.evaluate((apomTreeConverterCode, selectorResolverCode) => {
|
|
375
|
+
// Inject utilities
|
|
376
|
+
if (typeof buildAPOMTree === 'undefined') {
|
|
377
|
+
eval(apomTreeConverterCode);
|
|
378
|
+
}
|
|
379
|
+
if (typeof registerElements === 'undefined') {
|
|
380
|
+
eval(selectorResolverCode);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Build APOM tree (interactive only for speed)
|
|
384
|
+
const apomData = buildAPOMTree(true);
|
|
385
|
+
|
|
386
|
+
// Flatten and register elements
|
|
387
|
+
const elementsArray = [];
|
|
388
|
+
function collectElements(node) {
|
|
389
|
+
if (!node) return;
|
|
390
|
+
if (node.id && node.selector) {
|
|
391
|
+
elementsArray.push({
|
|
392
|
+
id: node.id,
|
|
393
|
+
selector: node.selector,
|
|
394
|
+
metadata: { type: node.type, tag: node.tag }
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
if (node.children) {
|
|
398
|
+
node.children.forEach(child => collectElements(child));
|
|
399
|
+
}
|
|
400
|
+
// Also check for container keys (div_container_0, etc.)
|
|
401
|
+
Object.keys(node).forEach(key => {
|
|
402
|
+
if (Array.isArray(node[key])) {
|
|
403
|
+
node[key].forEach(child => collectElements(child));
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
collectElements(apomData.tree);
|
|
408
|
+
|
|
409
|
+
if (typeof registerElements !== 'undefined') {
|
|
410
|
+
registerElements(elementsArray);
|
|
411
|
+
}
|
|
412
|
+
}, apomTreeConverter, selectorResolver);
|
|
413
|
+
}
|
|
414
|
+
|
|
363
415
|
/**
|
|
364
416
|
* Helper: Resolve selector (ID or CSS selector)
|
|
365
417
|
* Injects selector-resolver and resolves element identifier
|
|
418
|
+
* Auto-refreshes element registry if APOM ID not found
|
|
366
419
|
*/
|
|
367
|
-
async function resolveSelector(page, identifier) {
|
|
368
|
-
|
|
420
|
+
async function resolveSelector(page, identifier, timeoutMs = 5000) {
|
|
421
|
+
const tryResolve = () => page.evaluate((id, selectorResolverCode) => {
|
|
369
422
|
// Inject selector resolver if not already loaded
|
|
370
423
|
if (typeof resolveSelector === 'undefined') {
|
|
371
424
|
eval(selectorResolverCode);
|
|
@@ -378,6 +431,20 @@ async function executeToolInternal(name, args) {
|
|
|
378
431
|
found: document.querySelector(resolved.selector) !== null
|
|
379
432
|
};
|
|
380
433
|
}, identifier, selectorResolver);
|
|
434
|
+
|
|
435
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
436
|
+
setTimeout(() => reject(new Error(`resolveSelector timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
437
|
+
);
|
|
438
|
+
|
|
439
|
+
let resolved = await Promise.race([tryResolve(), timeoutPromise]);
|
|
440
|
+
|
|
441
|
+
// Auto-refresh: if looks like APOM ID but not found, re-register elements and retry
|
|
442
|
+
if (!resolved.found && isApomIdPattern(identifier)) {
|
|
443
|
+
await quickRegisterElements(page);
|
|
444
|
+
resolved = await Promise.race([tryResolve(), timeoutPromise]);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return resolved;
|
|
381
448
|
}
|
|
382
449
|
|
|
383
450
|
if (name === "click") {
|
|
@@ -401,31 +468,56 @@ async function executeToolInternal(name, args) {
|
|
|
401
468
|
throw new Error(`Element not found: ${identifier}`);
|
|
402
469
|
}
|
|
403
470
|
|
|
404
|
-
// Capture timestamp BEFORE click for
|
|
471
|
+
// Capture timestamp and URL BEFORE click for diagnostics
|
|
405
472
|
const beforeClickTimestamp = Date.now();
|
|
473
|
+
const urlBeforeClick = page.url();
|
|
474
|
+
|
|
475
|
+
// ALWAYS scroll to element first to ensure it's in viewport
|
|
476
|
+
await element.evaluate(el => el.scrollIntoView({ behavior: 'instant', block: 'center' }));
|
|
477
|
+
|
|
478
|
+
// Click with timeout to prevent hanging on navigation
|
|
479
|
+
const clickWithTimeout = async (timeoutMs = 5000) => {
|
|
480
|
+
const clickPromise = element.click().catch(() => {
|
|
481
|
+
// If Puppeteer click fails, fallback to JS click
|
|
482
|
+
return element.evaluate(el => el.click());
|
|
483
|
+
});
|
|
484
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
485
|
+
setTimeout(() => reject(new Error('click timeout')), timeoutMs)
|
|
486
|
+
);
|
|
487
|
+
return Promise.race([clickPromise, timeoutPromise]).catch(() => {
|
|
488
|
+
// If click times out, try JS click as last resort
|
|
489
|
+
return element.evaluate(el => el.click());
|
|
490
|
+
});
|
|
491
|
+
};
|
|
406
492
|
|
|
407
|
-
|
|
408
|
-
try {
|
|
409
|
-
// Method 1: Puppeteer click (most reliable for most cases)
|
|
410
|
-
await element.click();
|
|
411
|
-
} catch (clickError) {
|
|
412
|
-
// Method 2: Scroll into view and try again
|
|
413
|
-
try {
|
|
414
|
-
await element.evaluate(el => el.scrollIntoView({ behavior: 'instant', block: 'center' }));
|
|
415
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
416
|
-
await element.click();
|
|
417
|
-
} catch (scrollClickError) {
|
|
418
|
-
// Method 3: JavaScript click (works for hidden/overlapping elements)
|
|
419
|
-
await element.evaluate(el => el.click());
|
|
420
|
-
}
|
|
421
|
-
}
|
|
493
|
+
await clickWithTimeout();
|
|
422
494
|
|
|
423
495
|
// NEW POST-CLICK PATTERN:
|
|
424
|
-
// 1. Run post-click diagnostics (waits
|
|
425
|
-
|
|
496
|
+
// 1. Run post-click diagnostics (waits for network requests within 200ms, max 10s timeout)
|
|
497
|
+
let diagnostics;
|
|
498
|
+
try {
|
|
499
|
+
diagnostics = await runPostClickDiagnostics(page, beforeClickTimestamp, {
|
|
500
|
+
skipNetworkWait: validatedArgs.skipNetworkWait,
|
|
501
|
+
networkWaitTimeout: validatedArgs.networkWaitTimeout,
|
|
502
|
+
urlBeforeAction: urlBeforeClick
|
|
503
|
+
});
|
|
504
|
+
} catch (diagError) {
|
|
505
|
+
// Diagnostics may fail if page navigated - create minimal diagnostics
|
|
506
|
+
diagnostics = {
|
|
507
|
+
networkActivity: { trackedRequests: [], bridgeRequests: [], stillPending: 0, pendingRequests: [] },
|
|
508
|
+
navigation: { from: urlBeforeClick, to: page.url(), likelyFormSubmit: true },
|
|
509
|
+
errors: { consoleErrors: [], networkErrors: [], totalErrors: 0 },
|
|
510
|
+
hasErrors: false
|
|
511
|
+
};
|
|
512
|
+
}
|
|
426
513
|
|
|
427
514
|
// 2. Generate AI hints after click
|
|
428
|
-
|
|
515
|
+
let hints;
|
|
516
|
+
try {
|
|
517
|
+
hints = await generateClickHints(page, identifier);
|
|
518
|
+
} catch (hintsError) {
|
|
519
|
+
hints = { modalOpened: false, newElements: [], suggestedNext: [] };
|
|
520
|
+
}
|
|
429
521
|
|
|
430
522
|
// 3. Format output with hints and diagnostics
|
|
431
523
|
let hintsText = '\n\n** AI HINTS **';
|
|
@@ -464,36 +556,60 @@ async function executeToolInternal(name, args) {
|
|
|
464
556
|
if (name === "type") {
|
|
465
557
|
const validatedArgs = schemas.TypeSchema.parse(args);
|
|
466
558
|
const page = await getLastOpenPage();
|
|
559
|
+
const timeout = validatedArgs.timeout || 30000;
|
|
467
560
|
|
|
468
|
-
//
|
|
469
|
-
const
|
|
561
|
+
// Wrap operation in timeout
|
|
562
|
+
const typeOperation = async () => {
|
|
563
|
+
// Get identifier (id or selector)
|
|
564
|
+
const identifier = validatedArgs.id || validatedArgs.selector;
|
|
470
565
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
566
|
+
// Resolve selector (supports both APOM ID and CSS selector)
|
|
567
|
+
const resolved = await resolveSelector(page, identifier);
|
|
568
|
+
if (!resolved.found) {
|
|
569
|
+
throw new Error(`Element not found: ${identifier}${resolved.isPageObjectId ? ' (APOM ID)' : ' (CSS selector)'}`);
|
|
570
|
+
}
|
|
476
571
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
572
|
+
const element = await page.$(resolved.selector);
|
|
573
|
+
if (!element) {
|
|
574
|
+
throw new Error(`Element not found: ${identifier}`);
|
|
575
|
+
}
|
|
481
576
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
delay: validatedArgs.delay !== undefined ? validatedArgs.delay : 30,
|
|
486
|
-
clearFirst: validatedArgs.clearFirst !== undefined ? validatedArgs.clearFirst : true,
|
|
487
|
-
};
|
|
577
|
+
// ALWAYS scroll to element first to ensure it's in viewport
|
|
578
|
+
await element.evaluate(el => el.scrollIntoView({ behavior: 'instant', block: 'center' }));
|
|
579
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
488
580
|
|
|
489
|
-
|
|
490
|
-
|
|
581
|
+
// Use input model to handle the element appropriately
|
|
582
|
+
const model = await getInputModel(element, page);
|
|
583
|
+
const options = {
|
|
584
|
+
delay: validatedArgs.delay !== undefined ? validatedArgs.delay : 30,
|
|
585
|
+
clearFirst: validatedArgs.clearFirst !== undefined ? validatedArgs.clearFirst : true,
|
|
586
|
+
};
|
|
491
587
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
588
|
+
await model.setValue(validatedArgs.text, options);
|
|
589
|
+
const description = model.getActionDescription(validatedArgs.text, identifier);
|
|
590
|
+
|
|
591
|
+
// Capture timestamp AFTER typing finishes - requests should start within 200ms of this
|
|
592
|
+
const afterTypeTimestamp = Date.now();
|
|
593
|
+
|
|
594
|
+
// Run diagnostics with 3s timeout for type (shorter than click's 10s)
|
|
595
|
+
const diagnostics = await runPostClickDiagnostics(page, afterTypeTimestamp, {
|
|
596
|
+
networkWaitTimeout: 3000
|
|
597
|
+
});
|
|
598
|
+
const diagnosticsText = formatDiagnosticsForAI(diagnostics);
|
|
599
|
+
|
|
600
|
+
return {
|
|
601
|
+
content: [
|
|
602
|
+
{ type: "text", text: `${description}${diagnosticsText}` }
|
|
603
|
+
],
|
|
604
|
+
};
|
|
496
605
|
};
|
|
606
|
+
|
|
607
|
+
// Execute with timeout
|
|
608
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
609
|
+
setTimeout(() => reject(new Error(`Type operation timed out after ${timeout}ms`)), timeout)
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
return Promise.race([typeOperation(), timeoutPromise]);
|
|
497
613
|
}
|
|
498
614
|
|
|
499
615
|
if (name === "getComputedCss") {
|
|
@@ -2462,6 +2578,9 @@ Start coding now.`;
|
|
|
2462
2578
|
const extensionConnected = isExtensionConnected();
|
|
2463
2579
|
const debugInfo = getWsDebugInfo();
|
|
2464
2580
|
|
|
2581
|
+
// Always log connection state for debugging
|
|
2582
|
+
console.error(`[chrometools-mcp] enableRecorder check: bridgeConnected=${debugInfo.bridgeConnected}, extensionConnected=${extensionConnected}, wsState=${debugInfo.readyState}`);
|
|
2583
|
+
|
|
2465
2584
|
if (extensionConnected) {
|
|
2466
2585
|
return {
|
|
2467
2586
|
content: [{
|
package/models/TextInputModel.js
CHANGED
|
@@ -7,25 +7,76 @@
|
|
|
7
7
|
|
|
8
8
|
import { BaseInputModel } from './BaseInputModel.js';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Wrap operation with timeout to prevent hanging
|
|
12
|
+
*/
|
|
13
|
+
async function withTimeout(operation, timeoutMs, operationName) {
|
|
14
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
15
|
+
setTimeout(() => reject(new Error(`${operationName} timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
16
|
+
);
|
|
17
|
+
return Promise.race([operation(), timeoutPromise]);
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
export class TextInputModel extends BaseInputModel {
|
|
11
21
|
static get inputTypes() {
|
|
12
22
|
return ['text', 'email', 'tel', 'password', 'search', 'url', 'number', null];
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
/**
|
|
16
|
-
* Type text into the input using keyboard simulation
|
|
26
|
+
* Type text into the input using keyboard simulation with JS fallback
|
|
17
27
|
* @param {string} value - Text to type
|
|
18
28
|
* @param {object} options - { delay, clearFirst }
|
|
19
29
|
*/
|
|
20
30
|
async setValue(value, options = {}) {
|
|
21
31
|
const { delay = 0, clearFirst = true } = options;
|
|
32
|
+
const opTimeout = 5000; // 5s timeout per operation
|
|
33
|
+
|
|
34
|
+
// Method 1: Try Puppeteer typing (works for most cases)
|
|
35
|
+
try {
|
|
36
|
+
// Focus and clear using JS (most reliable)
|
|
37
|
+
await withTimeout(
|
|
38
|
+
() => this.element.evaluate((el, shouldClear) => {
|
|
39
|
+
el.focus();
|
|
40
|
+
el.click();
|
|
41
|
+
if (shouldClear) {
|
|
42
|
+
el.select(); // Select all text
|
|
43
|
+
}
|
|
44
|
+
}, clearFirst),
|
|
45
|
+
opTimeout,
|
|
46
|
+
'focus-and-select'
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Small delay to ensure focus is established
|
|
50
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
51
|
+
|
|
52
|
+
// Type the new value
|
|
53
|
+
const typeTimeout = Math.max(opTimeout, value.length * delay + 5000);
|
|
54
|
+
await withTimeout(
|
|
55
|
+
() => this.element.type(value, { delay }),
|
|
56
|
+
typeTimeout,
|
|
57
|
+
'type'
|
|
58
|
+
);
|
|
22
59
|
|
|
23
|
-
|
|
24
|
-
await this.element.
|
|
25
|
-
|
|
60
|
+
// Verify the value was set
|
|
61
|
+
const actualValue = await this.element.evaluate(el => el.value);
|
|
62
|
+
if (actualValue.includes(value)) {
|
|
63
|
+
return; // Success
|
|
64
|
+
}
|
|
65
|
+
} catch (e) {
|
|
66
|
+
// Fall through to JS method
|
|
26
67
|
}
|
|
27
68
|
|
|
28
|
-
|
|
69
|
+
// Method 2: Fallback to direct JS value setting
|
|
70
|
+
await withTimeout(
|
|
71
|
+
() => this.element.evaluate((el, newValue) => {
|
|
72
|
+
el.focus();
|
|
73
|
+
el.value = newValue;
|
|
74
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
75
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
76
|
+
}, value),
|
|
77
|
+
opTimeout,
|
|
78
|
+
'js-set-value'
|
|
79
|
+
);
|
|
29
80
|
}
|
|
30
81
|
|
|
31
82
|
getActionDescription(value, identifier) {
|
package/models/index.js
CHANGED
|
@@ -38,18 +38,32 @@ const MODEL_REGISTRY = [
|
|
|
38
38
|
TextInputModel, // Default fallback for text-like inputs
|
|
39
39
|
];
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Wrap operation with timeout to prevent hanging
|
|
43
|
+
*/
|
|
44
|
+
async function withTimeout(operation, timeoutMs, operationName) {
|
|
45
|
+
const timeoutPromise = new Promise((_, reject) =>
|
|
46
|
+
setTimeout(() => reject(new Error(`${operationName} timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
47
|
+
);
|
|
48
|
+
return Promise.race([operation(), timeoutPromise]);
|
|
49
|
+
}
|
|
50
|
+
|
|
41
51
|
/**
|
|
42
52
|
* Factory class for creating appropriate input models
|
|
43
53
|
*/
|
|
44
54
|
export class InputModelFactory {
|
|
45
55
|
/**
|
|
46
|
-
* Get element info (tagName, inputType)
|
|
56
|
+
* Get element info (tagName, inputType) with timeout
|
|
47
57
|
*/
|
|
48
|
-
static async getElementInfo(element) {
|
|
49
|
-
return await
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
static async getElementInfo(element, timeoutMs = 5000) {
|
|
59
|
+
return await withTimeout(
|
|
60
|
+
() => element.evaluate(el => ({
|
|
61
|
+
tagName: el.tagName.toLowerCase(),
|
|
62
|
+
inputType: el.type?.toLowerCase() || null,
|
|
63
|
+
})),
|
|
64
|
+
timeoutMs,
|
|
65
|
+
'getElementInfo'
|
|
66
|
+
);
|
|
53
67
|
}
|
|
54
68
|
|
|
55
69
|
/**
|
package/nul
ADDED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrometools-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.6",
|
|
4
4
|
"description": "MCP (Model Context Protocol) server for Chrome automation using Puppeteer. Persistent browser sessions, UI framework detection (MUI, Ant Design, etc.), Page Object support, visual testing, Figma comparison. Works seamlessly in WSL, Linux, macOS, and Windows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -711,12 +711,17 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
711
711
|
// Try to find stable class name (excluding framework-specific dynamic classes)
|
|
712
712
|
const stableClass = getStableClassName(element);
|
|
713
713
|
if (stableClass) {
|
|
714
|
-
const
|
|
714
|
+
const escapedClass = CSS.escape(stableClass);
|
|
715
|
+
const classSelector = `.${escapedClass}`;
|
|
715
716
|
// Verify it's unique within parent context
|
|
716
717
|
if (element.parentElement) {
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
718
|
+
try {
|
|
719
|
+
const matches = element.parentElement.querySelectorAll(classSelector);
|
|
720
|
+
if (matches.length === 1 && matches[0] === element) {
|
|
721
|
+
return classSelector;
|
|
722
|
+
}
|
|
723
|
+
} catch (e) {
|
|
724
|
+
// Invalid selector, continue to path-based approach
|
|
720
725
|
}
|
|
721
726
|
}
|
|
722
727
|
}
|
|
@@ -728,10 +733,10 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
728
733
|
while (current && current !== document.body) {
|
|
729
734
|
let selector = current.tagName.toLowerCase();
|
|
730
735
|
|
|
731
|
-
// Add stable class if available
|
|
736
|
+
// Add stable class if available (escaped for CSS selector safety)
|
|
732
737
|
const stableClass = getStableClassName(current);
|
|
733
738
|
if (stableClass) {
|
|
734
|
-
selector += `.${stableClass}`;
|
|
739
|
+
selector += `.${CSS.escape(stableClass)}`;
|
|
735
740
|
}
|
|
736
741
|
|
|
737
742
|
// Add nth-of-type if needed
|
|
@@ -754,6 +759,7 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
754
759
|
|
|
755
760
|
/**
|
|
756
761
|
* Get stable class name excluding framework-specific dynamic classes
|
|
762
|
+
* and Tailwind CSS utility classes with special characters
|
|
757
763
|
* Returns first stable class or null
|
|
758
764
|
*/
|
|
759
765
|
function getStableClassName(element) {
|
|
@@ -763,8 +769,14 @@ function buildAPOMTree(interactiveOnly = true) {
|
|
|
763
769
|
|
|
764
770
|
const classes = element.className.split(/\s+/).filter(c => c);
|
|
765
771
|
|
|
766
|
-
// Filter out framework-specific classes
|
|
772
|
+
// Filter out framework-specific classes and Tailwind utilities
|
|
767
773
|
const stableClasses = classes.filter(className => {
|
|
774
|
+
// Tailwind CSS: classes with special characters that break CSS selectors
|
|
775
|
+
// Colons for variants (hover:, focus:, md:, etc.)
|
|
776
|
+
// Slashes for fractions (w-1/2)
|
|
777
|
+
// Brackets for arbitrary values (bg-[#1da1f2])
|
|
778
|
+
if (/[:\/\[\]]/.test(className)) return false;
|
|
779
|
+
|
|
768
780
|
// React: CSS Modules, Styled Components, Emotion
|
|
769
781
|
if (/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]{5,}$/.test(className)) return false;
|
|
770
782
|
if (/^css-[a-z0-9]+(-[a-z0-9]+)?$/i.test(className)) return false;
|
package/server/tool-schemas.js
CHANGED
|
@@ -21,6 +21,8 @@ export const ClickSchema = z.object({
|
|
|
21
21
|
waitAfter: z.number().optional().describe("Milliseconds to wait after click (default: 1500)"),
|
|
22
22
|
screenshot: z.boolean().optional().describe("Capture screenshot after click (default: false for performance)"),
|
|
23
23
|
timeout: z.number().optional().describe("Maximum time to wait for operation in ms (default: 30000)"),
|
|
24
|
+
skipNetworkWait: z.boolean().optional().describe("Skip waiting for network requests (default: false). Use for forms with long-polling/WebSockets to avoid timeouts."),
|
|
25
|
+
networkWaitTimeout: z.number().optional().describe("Maximum time to wait for network requests in ms (default: 3000). Only used if skipNetworkWait is false."),
|
|
24
26
|
}).refine(data => (data.id && !data.selector) || (!data.id && data.selector), {
|
|
25
27
|
message: "Either 'id' or 'selector' must be provided, but not both"
|
|
26
28
|
});
|
|
@@ -31,6 +33,7 @@ export const TypeSchema = z.object({
|
|
|
31
33
|
text: z.string().describe("Text to type"),
|
|
32
34
|
delay: z.number().optional().describe("Delay between keystrokes in ms (default: 30)"),
|
|
33
35
|
clearFirst: z.boolean().optional().describe("Clear field before typing (default: true)"),
|
|
36
|
+
timeout: z.number().optional().describe("Maximum time to wait for operation in ms (default: 30000)"),
|
|
34
37
|
}).refine(data => (data.id && !data.selector) || (!data.id && data.selector), {
|
|
35
38
|
message: "Either 'id' or 'selector' must be provided, but not both"
|
|
36
39
|
});
|
package/utils/hints-generator.js
CHANGED
|
@@ -8,6 +8,20 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export function generateNavigationHints(page, url) {
|
|
10
10
|
return page.evaluate(() => {
|
|
11
|
+
// Helper to get safe class selector (filters Tailwind special chars)
|
|
12
|
+
function getSafeClassSelector(element) {
|
|
13
|
+
if (!element.className || typeof element.className !== 'string') return null;
|
|
14
|
+
const classes = element.className.split(' ')
|
|
15
|
+
.filter(c => c && !/[:\/\[\]]/.test(c))
|
|
16
|
+
.slice(0, 1);
|
|
17
|
+
if (classes.length === 0) return null;
|
|
18
|
+
try {
|
|
19
|
+
return `.${CSS.escape(classes[0])}`;
|
|
20
|
+
} catch (e) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
11
25
|
const hints = {
|
|
12
26
|
pageType: 'unknown',
|
|
13
27
|
availableActions: [],
|
|
@@ -64,7 +78,7 @@ export function generateNavigationHints(page, url) {
|
|
|
64
78
|
hints.keyElements.push({
|
|
65
79
|
type: 'primary-button',
|
|
66
80
|
text: mainButton.textContent.trim(),
|
|
67
|
-
selector: mainButton.id ? `#${mainButton.id}` :
|
|
81
|
+
selector: mainButton.id ? `#${CSS.escape(mainButton.id)}` : (getSafeClassSelector(mainButton) || 'button'),
|
|
68
82
|
});
|
|
69
83
|
}
|
|
70
84
|
|
|
@@ -74,7 +88,7 @@ export function generateNavigationHints(page, url) {
|
|
|
74
88
|
hints.keyElements.push({
|
|
75
89
|
type: 'notification',
|
|
76
90
|
text: alert.textContent.trim().substring(0, 100),
|
|
77
|
-
selector: alert
|
|
91
|
+
selector: getSafeClassSelector(alert) || '[role="alert"]',
|
|
78
92
|
});
|
|
79
93
|
}
|
|
80
94
|
});
|
|
@@ -91,6 +105,20 @@ export async function generateClickHints(page, selector) {
|
|
|
91
105
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
92
106
|
|
|
93
107
|
return page.evaluate((clickedSelector) => {
|
|
108
|
+
// Helper to get safe class selector (filters Tailwind special chars)
|
|
109
|
+
function getSafeClassSelector(element) {
|
|
110
|
+
if (!element.className || typeof element.className !== 'string') return null;
|
|
111
|
+
const classes = element.className.split(' ')
|
|
112
|
+
.filter(c => c && !/[:\/\[\]]/.test(c))
|
|
113
|
+
.slice(0, 1);
|
|
114
|
+
if (classes.length === 0) return null;
|
|
115
|
+
try {
|
|
116
|
+
return `.${CSS.escape(classes[0])}`;
|
|
117
|
+
} catch (e) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
94
122
|
const hints = {
|
|
95
123
|
pageChanged: false,
|
|
96
124
|
newElements: [],
|
|
@@ -105,7 +133,7 @@ export async function generateClickHints(page, selector) {
|
|
|
105
133
|
hints.modalOpened = true;
|
|
106
134
|
hints.newElements.push({
|
|
107
135
|
type: 'modal',
|
|
108
|
-
selector: modal
|
|
136
|
+
selector: getSafeClassSelector(modal) || '[role="dialog"]',
|
|
109
137
|
});
|
|
110
138
|
hints.suggestedNext.push('Interact with modal or close it');
|
|
111
139
|
}
|
|
@@ -145,6 +173,20 @@ export async function generateFormSubmitHints(page) {
|
|
|
145
173
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
146
174
|
|
|
147
175
|
return page.evaluate(() => {
|
|
176
|
+
// Helper to get safe class selector (filters Tailwind special chars)
|
|
177
|
+
function getSafeClassSelector(element) {
|
|
178
|
+
if (!element.className || typeof element.className !== 'string') return null;
|
|
179
|
+
const classes = element.className.split(' ')
|
|
180
|
+
.filter(c => c && !/[:\/\[\]]/.test(c))
|
|
181
|
+
.slice(0, 1);
|
|
182
|
+
if (classes.length === 0) return null;
|
|
183
|
+
try {
|
|
184
|
+
return `.${CSS.escape(classes[0])}`;
|
|
185
|
+
} catch (e) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
148
190
|
const hints = {
|
|
149
191
|
success: false,
|
|
150
192
|
errors: [],
|
|
@@ -173,7 +215,7 @@ export async function generateFormSubmitHints(page) {
|
|
|
173
215
|
if (el.offsetWidth > 0) {
|
|
174
216
|
hints.errors.push({
|
|
175
217
|
text: el.textContent.trim().substring(0, 100),
|
|
176
|
-
selector: el
|
|
218
|
+
selector: getSafeClassSelector(el) || '[aria-invalid="true"]',
|
|
177
219
|
});
|
|
178
220
|
}
|
|
179
221
|
});
|