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/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
- return await page.evaluate((id, selectorResolverCode) => {
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 error filtering
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
- // Try multiple click methods for better reliability
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 500ms, checks pending requests, collects errors)
425
- const diagnostics = await runPostClickDiagnostics(page, beforeClickTimestamp);
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
- const hints = await generateClickHints(page, identifier);
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
- // Get identifier (id or selector)
469
- const identifier = validatedArgs.id || validatedArgs.selector;
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
- // Resolve selector (supports both APOM ID and CSS selector)
472
- const resolved = await resolveSelector(page, identifier);
473
- if (!resolved.found) {
474
- throw new Error(`Element not found: ${identifier}${resolved.isPageObjectId ? ' (APOM ID)' : ' (CSS selector)'}`);
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
- const element = await page.$(resolved.selector);
478
- if (!element) {
479
- throw new Error(`Element not found: ${identifier}`);
480
- }
572
+ const element = await page.$(resolved.selector);
573
+ if (!element) {
574
+ throw new Error(`Element not found: ${identifier}`);
575
+ }
481
576
 
482
- // Use input model to handle the element appropriately
483
- const model = await getInputModel(element, page);
484
- const options = {
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
- await model.setValue(validatedArgs.text, options);
490
- const description = model.getActionDescription(validatedArgs.text, identifier);
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
- return {
493
- content: [
494
- { type: "text", text: description }
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: [{
@@ -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
- if (clearFirst) {
24
- await this.element.click({ clickCount: 3 });
25
- await this.page.keyboard.press('Backspace');
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
- await this.element.type(value, { delay });
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 element.evaluate(el => ({
50
- tagName: el.tagName.toLowerCase(),
51
- inputType: el.type?.toLowerCase() || null,
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.2.10",
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 classSelector = `.${stableClass}`;
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
- const matches = element.parentElement.querySelectorAll(classSelector);
718
- if (matches.length === 1 && matches[0] === element) {
719
- return classSelector;
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;
@@ -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
  });
@@ -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}` : `.${mainButton.className.split(' ')[0]}`,
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.className ? `.${alert.className.split(' ')[0]}` : 'notification',
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.className ? `.${modal.className.split(' ')[0]}` : '[role="dialog"]',
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.className ? `.${el.className.split(' ')[0]}` : 'error-element',
218
+ selector: getSafeClassSelector(el) || '[aria-invalid="true"]',
177
219
  });
178
220
  }
179
221
  });