testchimp-runner-core 0.0.40 → 0.0.41

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 (77) hide show
  1. package/dist/execution-service.d.ts.map +1 -1
  2. package/dist/execution-service.js +1 -3
  3. package/dist/execution-service.js.map +1 -1
  4. package/dist/index.d.ts +7 -6
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +4 -4
  7. package/dist/index.js.map +1 -1
  8. package/dist/orchestrator/decision-parser.d.ts.map +1 -1
  9. package/dist/orchestrator/decision-parser.js +16 -0
  10. package/dist/orchestrator/decision-parser.js.map +1 -1
  11. package/dist/orchestrator/index.d.ts +3 -1
  12. package/dist/orchestrator/index.d.ts.map +1 -1
  13. package/dist/orchestrator/index.js +8 -1
  14. package/dist/orchestrator/index.js.map +1 -1
  15. package/dist/orchestrator/orchestrator-agent.d.ts +10 -4
  16. package/dist/orchestrator/orchestrator-agent.d.ts.map +1 -1
  17. package/dist/orchestrator/orchestrator-agent.js +347 -93
  18. package/dist/orchestrator/orchestrator-agent.js.map +1 -1
  19. package/dist/orchestrator/orchestrator-prompts.d.ts.map +1 -1
  20. package/dist/orchestrator/orchestrator-prompts.js +355 -415
  21. package/dist/orchestrator/orchestrator-prompts.js.map +1 -1
  22. package/dist/orchestrator/page-loading-utils.d.ts +15 -0
  23. package/dist/orchestrator/page-loading-utils.d.ts.map +1 -0
  24. package/dist/orchestrator/page-loading-utils.js +115 -0
  25. package/dist/orchestrator/page-loading-utils.js.map +1 -0
  26. package/dist/orchestrator/page-som-handler.d.ts +2 -1
  27. package/dist/orchestrator/page-som-handler.d.ts.map +1 -1
  28. package/dist/orchestrator/page-som-handler.js +250 -33
  29. package/dist/orchestrator/page-som-handler.js.map +1 -1
  30. package/dist/orchestrator/site-learnings-utils.d.ts +31 -0
  31. package/dist/orchestrator/site-learnings-utils.d.ts.map +1 -0
  32. package/dist/orchestrator/site-learnings-utils.js +175 -0
  33. package/dist/orchestrator/site-learnings-utils.js.map +1 -0
  34. package/dist/orchestrator/som-types.d.ts +2 -0
  35. package/dist/orchestrator/som-types.d.ts.map +1 -1
  36. package/dist/orchestrator/som-types.js.map +1 -1
  37. package/dist/orchestrator/tools/take-screenshot.d.ts.map +1 -1
  38. package/dist/orchestrator/tools/take-screenshot.js +10 -1
  39. package/dist/orchestrator/tools/take-screenshot.js.map +1 -1
  40. package/dist/orchestrator/types.d.ts +54 -9
  41. package/dist/orchestrator/types.d.ts.map +1 -1
  42. package/dist/orchestrator/types.js.map +1 -1
  43. package/dist/progress-reporter.d.ts +23 -2
  44. package/dist/progress-reporter.d.ts.map +1 -1
  45. package/dist/progress-reporter.js.map +1 -1
  46. package/dist/scenario-service.d.ts +3 -3
  47. package/dist/scenario-service.d.ts.map +1 -1
  48. package/dist/scenario-service.js +6 -5
  49. package/dist/scenario-service.js.map +1 -1
  50. package/dist/scenario-worker-class.d.ts +7 -3
  51. package/dist/scenario-worker-class.d.ts.map +1 -1
  52. package/dist/scenario-worker-class.js +62 -9
  53. package/dist/scenario-worker-class.js.map +1 -1
  54. package/dist/types.d.ts +4 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js.map +1 -1
  57. package/package.json +1 -1
  58. package/dist/testing/agent-tester.d.ts +0 -35
  59. package/dist/testing/agent-tester.d.ts.map +0 -1
  60. package/dist/testing/agent-tester.js +0 -84
  61. package/dist/testing/agent-tester.js.map +0 -1
  62. package/dist/testing/ref-translator-tester.d.ts +0 -44
  63. package/dist/testing/ref-translator-tester.d.ts.map +0 -1
  64. package/dist/testing/ref-translator-tester.js +0 -104
  65. package/dist/testing/ref-translator-tester.js.map +0 -1
  66. package/dist/utils/hierarchical-selector.d.ts +0 -47
  67. package/dist/utils/hierarchical-selector.d.ts.map +0 -1
  68. package/dist/utils/hierarchical-selector.js +0 -212
  69. package/dist/utils/hierarchical-selector.js.map +0 -1
  70. package/dist/utils/ref-attacher.d.ts +0 -21
  71. package/dist/utils/ref-attacher.d.ts.map +0 -1
  72. package/dist/utils/ref-attacher.js +0 -149
  73. package/dist/utils/ref-attacher.js.map +0 -1
  74. package/dist/utils/ref-translator.d.ts +0 -49
  75. package/dist/utils/ref-translator.d.ts.map +0 -1
  76. package/dist/utils/ref-translator.js +0 -276
  77. package/dist/utils/ref-translator.js.map +0 -1
@@ -47,31 +47,116 @@ class PageSoMHandler {
47
47
  /**
48
48
  * Update SoM markers - extract interactive elements and draw overlay
49
49
  * Clears coordinate markers from previous iteration (agent can view via previous_screenshot tool)
50
+ * @param includeOffscreen - If true, marks ALL elements including those below viewport (for full-page screenshots)
50
51
  */
51
- async updateSom() {
52
- this.logger?.('[PageSoMHandler] Updating SoM markers...', 'log');
52
+ async updateSom(includeOffscreen = false) {
53
+ this.logger?.(`[PageSoMHandler] Updating SoM markers${includeOffscreen ? ' (including offscreen elements)' : ''}...`, 'log');
53
54
  // Clear any coordinate markers from previous iteration
54
55
  await this.removeCoordinateMarker();
55
56
  // Extract interactive elements and assign SoM IDs
56
- const elements = await this.page.evaluate(() => {
57
+ const elements = await this.page.evaluate((includeOffscreen) => {
57
58
  const doc = document;
58
59
  const elements = [];
59
60
  let idCounter = 1;
60
- // Query all interactive elements
61
+ // Helper: Find all shadow roots in the document (recursive, but only called once)
62
+ function getAllShadowRoots(root = document) {
63
+ const shadowRoots = [];
64
+ // Use TreeWalker for efficient DOM traversal (faster than querySelectorAll('*'))
65
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, null);
66
+ let node = walker.currentNode;
67
+ while (node) {
68
+ if (node.shadowRoot) {
69
+ try {
70
+ const shadowRoot = node.shadowRoot;
71
+ shadowRoots.push(shadowRoot);
72
+ // Recursively find shadow roots inside this shadow root
73
+ const nestedShadowRoots = getAllShadowRoots(shadowRoot);
74
+ shadowRoots.push(...nestedShadowRoots);
75
+ }
76
+ catch (e) {
77
+ // Closed shadow root - skip it
78
+ }
79
+ }
80
+ node = walker.nextNode();
81
+ }
82
+ return shadowRoots;
83
+ }
84
+ // Helper: Query selector in document + all shadow roots
85
+ // Much more efficient than recursive approach - finds shadow roots once, then queries each
86
+ function querySelectorAllDeep(selector) {
87
+ const results = [];
88
+ // Query main document
89
+ doc.querySelectorAll(selector).forEach((el) => results.push(el));
90
+ // Query each shadow root
91
+ shadowRoots.forEach(shadowRoot => {
92
+ try {
93
+ shadowRoot.querySelectorAll(selector).forEach((el) => results.push(el));
94
+ }
95
+ catch (e) {
96
+ // Selector might not be valid in shadow context - skip
97
+ }
98
+ });
99
+ return results;
100
+ }
101
+ // Find all shadow roots upfront (only once, not per selector!)
102
+ const shadowRoots = getAllShadowRoots(doc);
103
+ // Query all interactive elements (including shadow DOM)
61
104
  const interactiveSelectors = [
62
105
  'button', 'input', 'textarea', 'select', 'a[href]',
63
106
  '[role="button"]', '[role="link"]', '[role="textbox"]',
64
107
  '[role="checkbox"]', '[role="radio"]', '[role="combobox"]',
65
108
  '[role="menu"]', '[role="menuitem"]', '[role="option"]',
66
- '[aria-haspopup]', '[onclick]', '[type="submit"]',
109
+ '[onclick]', '[type="submit"]',
67
110
  '[role="tab"]', '[role="switch"]', '[role="spinbutton"]'
68
111
  ];
69
112
  const allInteractive = new Set();
70
113
  interactiveSelectors.forEach(selector => {
71
- doc.querySelectorAll(selector).forEach((el) => allInteractive.add(el));
114
+ querySelectorAllDeep(selector).forEach((el) => allInteractive.add(el));
115
+ });
116
+ // Special handling for containers with ARIA attributes that might not be directly clickable
117
+ // Pattern 1: [aria-haspopup] - dropdowns, menus, dialogs, date pickers
118
+ // Pattern 2: [aria-expanded] - accordions, collapses, expandable sections
119
+ // Pattern 3: [role="combobox"] - custom selects without aria-haspopup
120
+ const ariaContainerSelectors = ['[aria-haspopup]', '[aria-expanded]', '[role="combobox"]'];
121
+ ariaContainerSelectors.forEach(selector => {
122
+ querySelectorAllDeep(selector).forEach((container) => {
123
+ // Skip if already marked by other selectors (e.g., button with aria-expanded)
124
+ if (allInteractive.has(container))
125
+ return;
126
+ const styles = window.getComputedStyle(container);
127
+ const isContainerClickable = styles.cursor === 'pointer' ||
128
+ container.onclick ||
129
+ container.getAttribute('onclick') ||
130
+ container.tagName === 'BUTTON' ||
131
+ container.tagName === 'A';
132
+ if (!isContainerClickable) {
133
+ // Check for clickable children (buttons, icons, inputs)
134
+ const clickableChild = Array.from(container.children).find((child) => {
135
+ const childStyles = window.getComputedStyle(child);
136
+ return childStyles.cursor === 'pointer' ||
137
+ child.onclick ||
138
+ child.getAttribute('onclick') ||
139
+ child.tagName === 'BUTTON' ||
140
+ child.tagName === 'A' ||
141
+ child.tagName === 'INPUT';
142
+ });
143
+ if (clickableChild) {
144
+ // Mark the child, not the container
145
+ allInteractive.add(clickableChild);
146
+ }
147
+ else {
148
+ // No clickable child found, mark the container as fallback
149
+ allInteractive.add(container);
150
+ }
151
+ }
152
+ else {
153
+ // Container is clickable, mark it
154
+ allInteractive.add(container);
155
+ }
156
+ });
72
157
  });
73
158
  // Also detect styled-as-interactive elements
74
- doc.querySelectorAll('div, span, p, li, td').forEach((el) => {
159
+ querySelectorAllDeep('div, span, p, li, td').forEach((el) => {
75
160
  const styles = window.getComputedStyle(el);
76
161
  const hasClickHandler = el.onclick || el.getAttribute('onclick') ||
77
162
  el.hasAttribute('data-action') || el.hasAttribute('data-click');
@@ -79,6 +164,22 @@ class PageSoMHandler {
79
164
  allInteractive.add(el);
80
165
  }
81
166
  });
167
+ // Special handling for labels wrapping hidden inputs (custom checkbox/radio patterns)
168
+ // If a label wraps a hidden input and has visible content, mark the label, not the input
169
+ querySelectorAllDeep('label').forEach((label) => {
170
+ const input = label.querySelector('input[type="checkbox"], input[type="radio"]');
171
+ if (input) {
172
+ const inputStyles = window.getComputedStyle(input);
173
+ const isInputHidden = inputStyles.display === 'none' ||
174
+ inputStyles.visibility === 'hidden' ||
175
+ parseFloat(inputStyles.opacity) === 0 ||
176
+ (input.getBoundingClientRect().width === 0);
177
+ if (isInputHidden) {
178
+ // Hidden input with styled label - mark the label instead
179
+ allInteractive.add(label);
180
+ }
181
+ }
182
+ });
82
183
  // Remove elements that are descendants of "true" interactive elements
83
184
  // (e.g., <span> inside <button> should not get separate marker)
84
185
  // But keep elements inside generic containers (div, section, etc.)
@@ -109,8 +210,18 @@ class PageSoMHandler {
109
210
  return;
110
211
  // Skip hidden elements (display, visibility, opacity checks)
111
212
  const styles = window.getComputedStyle(el);
112
- if (styles.display === 'none' || styles.visibility === 'hidden' ||
113
- parseFloat(styles.opacity) === 0)
213
+ const isHidden = styles.display === 'none' ||
214
+ (styles.visibility === 'hidden' && parseFloat(styles.opacity) === 0);
215
+ // Special case: Check for visible pseudo-elements (::before, ::after)
216
+ // Some sites hide the main element but show content via pseudo-elements
217
+ let hasVisiblePseudo = false;
218
+ if (styles.visibility === 'hidden' || parseFloat(styles.opacity) === 0) {
219
+ const before = window.getComputedStyle(el, '::before');
220
+ const after = window.getComputedStyle(el, '::after');
221
+ hasVisiblePseudo = (before.content !== 'none' && before.visibility === 'visible' && before.display !== 'none') ||
222
+ (after.content !== 'none' && after.visibility === 'visible' && after.display !== 'none');
223
+ }
224
+ if (isHidden && !hasVisiblePseudo)
114
225
  return;
115
226
  // Skip disabled elements (they can't be interacted with)
116
227
  const isDisabled = el.disabled ||
@@ -133,17 +244,30 @@ class PageSoMHandler {
133
244
  { x: rect.left + inset, y: rect.bottom - inset }, // bottom-left
134
245
  { x: rect.right - inset, y: rect.bottom - inset } // bottom-right
135
246
  ];
136
- // Element is visible if at least 1 test point hits it or a descendant
137
- // (relaxed from 2 to handle small clickable icons better)
138
- let visiblePoints = 0;
139
- for (const point of testPoints) {
140
- const topEl = document.elementFromPoint(point.x, point.y);
141
- if (topEl && (topEl === el || el.contains(topEl) || topEl.contains(el))) {
142
- visiblePoints++;
247
+ // Occlusion detection - only for elements in viewport
248
+ // For offscreen elements (below fold), skip this check since elementFromPoint doesn't work
249
+ const isInViewport = rect.top < window.innerHeight && rect.bottom > 0 &&
250
+ rect.left < window.innerWidth && rect.right > 0;
251
+ if (!includeOffscreen && isInViewport) {
252
+ // Element is in viewport - check if it's occluded
253
+ let visiblePoints = 0;
254
+ for (const point of testPoints) {
255
+ const topEl = document.elementFromPoint(point.x, point.y);
256
+ if (topEl && (topEl === el || el.contains(topEl) || topEl.contains(el))) {
257
+ visiblePoints++;
258
+ }
143
259
  }
260
+ // Skip fully occluded elements (no visible points)
261
+ if (visiblePoints < 1) {
262
+ return;
263
+ }
264
+ }
265
+ else if (includeOffscreen && !isInViewport) {
266
+ // Element is offscreen - include it without occlusion check
267
+ // (elementFromPoint doesn't work for offscreen elements)
144
268
  }
145
- // Skip fully occluded elements (no visible points)
146
- if (visiblePoints < 1) {
269
+ else if (!includeOffscreen && !isInViewport) {
270
+ // Viewport-only mode and element not in viewport - skip it
147
271
  return;
148
272
  }
149
273
  // Assign tc-som-id attribute
@@ -151,12 +275,56 @@ class PageSoMHandler {
151
275
  el.setAttribute('tc-som-id', somId);
152
276
  // Capture element details
153
277
  const parent = el.parentElement;
278
+ // Extract text - if main element is hidden, try to get text from pseudo-elements
279
+ let displayText = el.textContent?.trim().substring(0, 50) || '';
280
+ if (hasVisiblePseudo && (!displayText || styles.visibility === 'hidden')) {
281
+ const before = window.getComputedStyle(el, '::before');
282
+ const after = window.getComputedStyle(el, '::after');
283
+ if (before.content && before.content !== 'none') {
284
+ // Remove surrounding quotes from content
285
+ displayText = before.content.replace(/^["']|["']$/g, '');
286
+ }
287
+ else if (after.content && after.content !== 'none') {
288
+ displayText = after.content.replace(/^["']|["']$/g, '');
289
+ }
290
+ }
291
+ // For images, the accessible name comes from alt attribute
292
+ // For other elements, use aria-label if present
293
+ let accessibleName = el.getAttribute('aria-label') || '';
294
+ if (el.tagName.toLowerCase() === 'img' && !accessibleName) {
295
+ accessibleName = el.getAttribute('alt') || '';
296
+ }
297
+ // Detect associated <label> element for inputs/textareas/selects
298
+ // This enables getByLabel() selector generation
299
+ let labelText = '';
300
+ const tagLower = el.tagName.toLowerCase();
301
+ if (['input', 'textarea', 'select'].includes(tagLower)) {
302
+ // Method 1: Label with for="id"
303
+ if (el.id) {
304
+ const label = doc.querySelector(`label[for="${el.id}"]`);
305
+ if (label) {
306
+ labelText = label.textContent?.trim() || '';
307
+ }
308
+ }
309
+ // Method 2: Label wrapping the input
310
+ if (!labelText) {
311
+ let parent = el.parentElement;
312
+ while (parent && parent !== doc.body) {
313
+ if (parent.tagName.toLowerCase() === 'label') {
314
+ labelText = parent.textContent?.trim() || '';
315
+ break;
316
+ }
317
+ parent = parent.parentElement;
318
+ }
319
+ }
320
+ }
154
321
  elements.push({
155
322
  somId,
156
323
  tag: el.tagName.toLowerCase(),
157
324
  role: el.getAttribute('role') || el.tagName.toLowerCase(),
158
- text: el.textContent?.trim().substring(0, 50) || '',
159
- ariaLabel: el.getAttribute('aria-label') || '',
325
+ text: displayText,
326
+ ariaLabel: accessibleName,
327
+ labelText: labelText, // Associated <label> text for getByLabel()
160
328
  placeholder: el.placeholder || '',
161
329
  name: el.getAttribute('name') || '',
162
330
  type: el.type || '',
@@ -168,6 +336,7 @@ class PageSoMHandler {
168
336
  width: Math.round(rect.width),
169
337
  height: Math.round(rect.height)
170
338
  },
339
+ hasVisiblePseudoElement: hasVisiblePseudo,
171
340
  parent: parent ? {
172
341
  tag: parent.tagName.toLowerCase(),
173
342
  role: parent.getAttribute('role') || '',
@@ -177,7 +346,7 @@ class PageSoMHandler {
177
346
  });
178
347
  });
179
348
  return elements;
180
- });
349
+ }, includeOffscreen);
181
350
  // Store in somMap
182
351
  this.somMap.clear();
183
352
  elements.forEach(el => this.somMap.set(el.somId, el));
@@ -457,11 +626,20 @@ class PageSoMHandler {
457
626
  await this.setupMutationObserver();
458
627
  }
459
628
  // Try semantic selectors first (unless useSomIdBasedCommands=true)
629
+ // For pseudo-element buttons, generateSemanticSelectors will use CSS selectors instead of getByRole/getByText
460
630
  if (!useSomIdBasedCommands) {
461
631
  const selectors = this.generateSemanticSelectors(element);
462
632
  for (let i = 0; i < Math.min(selectors.length, maxAttempts); i++) {
463
633
  const selector = selectors[i];
464
- const result = await this.tryExecuteAction(selector, command, element);
634
+ // Force click/hover for pseudo-element buttons (they have visibility:hidden)
635
+ const modifiedCommand = element?.hasVisiblePseudoElement &&
636
+ (command.action === som_types_1.InteractionAction.CLICK ||
637
+ command.action === som_types_1.InteractionAction.DOUBLE_CLICK ||
638
+ command.action === som_types_1.InteractionAction.RIGHT_CLICK ||
639
+ command.action === som_types_1.InteractionAction.HOVER)
640
+ ? { ...command, force: true }
641
+ : command;
642
+ const result = await this.tryExecuteAction(selector, modifiedCommand, element);
465
643
  if (result.status === som_types_1.CommandRunStatus.SUCCESS) {
466
644
  // Wait for mutations if hover/focus
467
645
  if (command.action === som_types_1.InteractionAction.HOVER || command.action === som_types_1.InteractionAction.FOCUS) {
@@ -482,7 +660,15 @@ class PageSoMHandler {
482
660
  type: 'locator',
483
661
  value: `[tc-som-id="${command.elementRef}"]`
484
662
  };
485
- const result = await this.tryExecuteAction(somIdSelector, command, element);
663
+ // Force click/interactions for elements with pseudo-elements (they may have visibility:hidden)
664
+ const modifiedCommand = element?.hasVisiblePseudoElement &&
665
+ (command.action === som_types_1.InteractionAction.CLICK ||
666
+ command.action === som_types_1.InteractionAction.DOUBLE_CLICK ||
667
+ command.action === som_types_1.InteractionAction.RIGHT_CLICK ||
668
+ command.action === som_types_1.InteractionAction.HOVER)
669
+ ? { ...command, force: true }
670
+ : command;
671
+ const result = await this.tryExecuteAction(somIdSelector, modifiedCommand, element);
486
672
  if (result.status === som_types_1.CommandRunStatus.SUCCESS) {
487
673
  await this.disconnectMutationObserver();
488
674
  return result;
@@ -716,12 +902,18 @@ class PageSoMHandler {
716
902
  // Priority 1: getByTestId (would need to capture in updateSom)
717
903
  // Skipped for now - can add data-testid capture later
718
904
  // Priority 2: Stable ID
719
- if (element.id && !element.id.match(/^(rc_|:r[0-9]+:|__)/) && !element.id.includes('«')) {
905
+ // Filter unstable IDs:
906
+ // - Contains colons (React/MUI: :r123:, :r1r: - need CSS escaping which is fragile)
907
+ // - Auto-generated prefixes (rc_, __)
908
+ // - Dynamic markers (contains «)
909
+ if (element.id && !element.id.includes(':') && !element.id.match(/^(rc_|__)/) && !element.id.includes('«')) {
720
910
  selectors.push({ type: 'id', value: element.id });
721
911
  }
722
- // Priority 3: getByLabel (highly specific for form inputs)
723
- if (element.ariaLabel) {
724
- selectors.push({ type: 'label', value: element.ariaLabel });
912
+ // Priority 3: getByLabel (ONLY when there's an actual <label> element)
913
+ // getByLabel doesn't work with aria-label/alt - it needs <label for="id">
914
+ // Now properly detected: we check for associated <label> during updateSom()
915
+ if (element.labelText) {
916
+ selectors.push({ type: 'label', value: element.labelText });
725
917
  }
726
918
  // Priority 4: input[name] (backend contract - very stable)
727
919
  if (element.name && ['input', 'textarea', 'select'].includes(element.tag)) {
@@ -731,15 +923,36 @@ class PageSoMHandler {
731
923
  if (element.placeholder) {
732
924
  selectors.push({ type: 'placeholder', value: element.placeholder });
733
925
  }
734
- // Priority 6: getByRole (broader, can match multiple elements)
735
- if (element.role && element.text) {
736
- selectors.push({ type: 'role', value: element.role, roleOptions: { name: element.text } });
926
+ // Priority 6: CSS selector for pseudo-element buttons (BEFORE role/text)
927
+ // For buttons with visibility:hidden + ::before/::after, getByRole/getByText won't work
928
+ if (element.hasVisiblePseudoElement && element.tag === 'button') {
929
+ // Try button[type="submit"] first (most common for forms)
930
+ if (element.type === 'submit') {
931
+ selectors.push({ type: 'locator', value: 'button[type="submit"]' });
932
+ }
933
+ // Try class-based selector if available
934
+ if (element.className) {
935
+ const firstClass = element.className.split(' ')[0];
936
+ if (firstClass && !firstClass.match(/^(css-|MuiButton-|ant-|btn-)/)) {
937
+ selectors.push({ type: 'locator', value: `button.${firstClass}` });
938
+ }
939
+ }
940
+ }
941
+ // Priority 7: getByRole (broader, can match multiple elements)
942
+ // Skip for pseudo-element buttons (they have visibility:hidden → not in a11y tree)
943
+ // For images/buttons with accessible names, prefer ariaLabel over text
944
+ if (!element.hasVisiblePseudoElement && element.role) {
945
+ const accessibleName = element.ariaLabel || element.text;
946
+ if (accessibleName) {
947
+ selectors.push({ type: 'role', value: element.role, roleOptions: { name: accessibleName } });
948
+ }
737
949
  }
738
- // Priority 7: getByText (last semantic option)
739
- if (element.text) {
950
+ // Priority 8: getByText (last semantic option)
951
+ // Skip for pseudo-element buttons (getByText will match the heading instead!)
952
+ if (!element.hasVisiblePseudoElement && element.text) {
740
953
  selectors.push({ type: 'text', value: element.text });
741
954
  }
742
- // Priority 8: Parent-scoped locator (generic fallback)
955
+ // Priority 9: Parent-scoped locator (generic fallback)
743
956
  if (element.parent?.className) {
744
957
  const parentClass = element.parent.className.split(' ')[0];
745
958
  if (parentClass) {
@@ -1194,6 +1407,10 @@ class PageSoMHandler {
1194
1407
  await this.page.mouse.up();
1195
1408
  playwrightCommand = `await page.mouse.move(${pixelX}, ${pixelY}); await page.mouse.down(); await page.mouse.move(${toPixelX}, ${toPixelY}); await page.mouse.up()`;
1196
1409
  break;
1410
+ case som_types_1.InteractionAction.PRESS:
1411
+ case som_types_1.InteractionAction.PRESS_SEQUENTIALLY:
1412
+ // Keyboard press is page-level - coordinates don't make sense
1413
+ throw new Error(`PRESS action cannot use coordinates. To scroll: use SCROLL action with scrollDirection/scrollAmount. To press keys: use PRESS without coord.`);
1197
1414
  default:
1198
1415
  throw new Error(`Coordinate-based execution not supported for action: ${action}`);
1199
1416
  }