testchimp-runner-core 0.0.33 → 0.0.35

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 (152) hide show
  1. package/dist/execution-service.d.ts +1 -4
  2. package/dist/execution-service.d.ts.map +1 -1
  3. package/dist/execution-service.js +155 -468
  4. package/dist/execution-service.js.map +1 -1
  5. package/dist/index.d.ts +3 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +11 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/llm-facade.d.ts.map +1 -1
  10. package/dist/llm-facade.js +7 -7
  11. package/dist/llm-facade.js.map +1 -1
  12. package/dist/llm-provider.d.ts +9 -0
  13. package/dist/llm-provider.d.ts.map +1 -1
  14. package/dist/model-constants.d.ts +16 -5
  15. package/dist/model-constants.d.ts.map +1 -1
  16. package/dist/model-constants.js +17 -6
  17. package/dist/model-constants.js.map +1 -1
  18. package/dist/orchestrator/decision-parser.d.ts +18 -0
  19. package/dist/orchestrator/decision-parser.d.ts.map +1 -0
  20. package/dist/orchestrator/decision-parser.js +127 -0
  21. package/dist/orchestrator/decision-parser.js.map +1 -0
  22. package/dist/orchestrator/index.d.ts +4 -2
  23. package/dist/orchestrator/index.d.ts.map +1 -1
  24. package/dist/orchestrator/index.js +15 -2
  25. package/dist/orchestrator/index.js.map +1 -1
  26. package/dist/orchestrator/orchestrator-agent.d.ts +17 -22
  27. package/dist/orchestrator/orchestrator-agent.d.ts.map +1 -1
  28. package/dist/orchestrator/orchestrator-agent.js +708 -577
  29. package/dist/orchestrator/orchestrator-agent.js.map +1 -1
  30. package/dist/orchestrator/orchestrator-prompts.d.ts +32 -0
  31. package/dist/orchestrator/orchestrator-prompts.d.ts.map +1 -0
  32. package/dist/orchestrator/orchestrator-prompts.js +737 -0
  33. package/dist/orchestrator/orchestrator-prompts.js.map +1 -0
  34. package/dist/orchestrator/page-som-handler.d.ts +106 -0
  35. package/dist/orchestrator/page-som-handler.d.ts.map +1 -0
  36. package/dist/orchestrator/page-som-handler.js +1353 -0
  37. package/dist/orchestrator/page-som-handler.js.map +1 -0
  38. package/dist/orchestrator/som-types.d.ts +149 -0
  39. package/dist/orchestrator/som-types.d.ts.map +1 -0
  40. package/dist/orchestrator/som-types.js +87 -0
  41. package/dist/orchestrator/som-types.js.map +1 -0
  42. package/dist/orchestrator/tool-registry.d.ts +2 -0
  43. package/dist/orchestrator/tool-registry.d.ts.map +1 -1
  44. package/dist/orchestrator/tool-registry.js.map +1 -1
  45. package/dist/orchestrator/tools/index.d.ts +5 -1
  46. package/dist/orchestrator/tools/index.d.ts.map +1 -1
  47. package/dist/orchestrator/tools/index.js +9 -2
  48. package/dist/orchestrator/tools/index.js.map +1 -1
  49. package/dist/orchestrator/tools/refresh-som-markers.d.ts +12 -0
  50. package/dist/orchestrator/tools/refresh-som-markers.d.ts.map +1 -0
  51. package/dist/orchestrator/tools/refresh-som-markers.js +64 -0
  52. package/dist/orchestrator/tools/refresh-som-markers.js.map +1 -0
  53. package/dist/orchestrator/tools/verify-action-result.d.ts +17 -0
  54. package/dist/orchestrator/tools/verify-action-result.d.ts.map +1 -0
  55. package/dist/orchestrator/tools/verify-action-result.js +140 -0
  56. package/dist/orchestrator/tools/verify-action-result.js.map +1 -0
  57. package/dist/orchestrator/tools/view-previous-screenshot.d.ts +15 -0
  58. package/dist/orchestrator/tools/view-previous-screenshot.d.ts.map +1 -0
  59. package/dist/orchestrator/tools/view-previous-screenshot.js +92 -0
  60. package/dist/orchestrator/tools/view-previous-screenshot.js.map +1 -0
  61. package/dist/orchestrator/types.d.ts +49 -1
  62. package/dist/orchestrator/types.d.ts.map +1 -1
  63. package/dist/orchestrator/types.js +11 -1
  64. package/dist/orchestrator/types.js.map +1 -1
  65. package/dist/prompts.d.ts.map +1 -1
  66. package/dist/prompts.js +40 -34
  67. package/dist/prompts.js.map +1 -1
  68. package/dist/scenario-service.d.ts +5 -0
  69. package/dist/scenario-service.d.ts.map +1 -1
  70. package/dist/scenario-service.js +17 -0
  71. package/dist/scenario-service.js.map +1 -1
  72. package/dist/scenario-worker-class.d.ts +4 -0
  73. package/dist/scenario-worker-class.d.ts.map +1 -1
  74. package/dist/scenario-worker-class.js +21 -3
  75. package/dist/scenario-worker-class.js.map +1 -1
  76. package/dist/testing/agent-tester.d.ts +35 -0
  77. package/dist/testing/agent-tester.d.ts.map +1 -0
  78. package/dist/testing/agent-tester.js +84 -0
  79. package/dist/testing/agent-tester.js.map +1 -0
  80. package/dist/testing/ref-translator-tester.d.ts +44 -0
  81. package/dist/testing/ref-translator-tester.d.ts.map +1 -0
  82. package/dist/testing/ref-translator-tester.js +104 -0
  83. package/dist/testing/ref-translator-tester.js.map +1 -0
  84. package/dist/utils/coordinate-converter.d.ts +32 -0
  85. package/dist/utils/coordinate-converter.d.ts.map +1 -0
  86. package/dist/utils/coordinate-converter.js +130 -0
  87. package/dist/utils/coordinate-converter.js.map +1 -0
  88. package/dist/utils/hierarchical-selector.d.ts +47 -0
  89. package/dist/utils/hierarchical-selector.d.ts.map +1 -0
  90. package/dist/utils/hierarchical-selector.js +212 -0
  91. package/dist/utils/hierarchical-selector.js.map +1 -0
  92. package/dist/utils/page-info-retry.d.ts +14 -0
  93. package/dist/utils/page-info-retry.d.ts.map +1 -0
  94. package/dist/utils/page-info-retry.js +60 -0
  95. package/dist/utils/page-info-retry.js.map +1 -0
  96. package/dist/utils/page-info-utils.d.ts +1 -0
  97. package/dist/utils/page-info-utils.d.ts.map +1 -1
  98. package/dist/utils/page-info-utils.js +46 -18
  99. package/dist/utils/page-info-utils.js.map +1 -1
  100. package/dist/utils/ref-attacher.d.ts +21 -0
  101. package/dist/utils/ref-attacher.d.ts.map +1 -0
  102. package/dist/utils/ref-attacher.js +149 -0
  103. package/dist/utils/ref-attacher.js.map +1 -0
  104. package/dist/utils/ref-translator.d.ts +49 -0
  105. package/dist/utils/ref-translator.d.ts.map +1 -0
  106. package/dist/utils/ref-translator.js +276 -0
  107. package/dist/utils/ref-translator.js.map +1 -0
  108. package/package.json +1 -1
  109. package/plandocs/BEFORE_AFTER_VERIFICATION.md +148 -0
  110. package/plandocs/COORDINATE_MODE_DIAGNOSIS.md +144 -0
  111. package/plandocs/IMPLEMENTATION_STATUS.md +108 -0
  112. package/plandocs/PHASE_1_COMPLETE.md +165 -0
  113. package/plandocs/PHASE_1_SUMMARY.md +184 -0
  114. package/plandocs/PROMPT_OPTIMIZATION_ANALYSIS.md +120 -0
  115. package/plandocs/PROMPT_SANITY_CHECK.md +120 -0
  116. package/plandocs/SESSION_SUMMARY_v0.0.33.md +151 -0
  117. package/plandocs/TROUBLESHOOTING_SESSION.md +72 -0
  118. package/plandocs/VISUAL_AGENT_EVOLUTION_PLAN.md +396 -0
  119. package/plandocs/WHATS_NEW_v0.0.33.md +183 -0
  120. package/plandocs/exploratory-mode-support-v2.plan.md +953 -0
  121. package/plandocs/exploratory-mode-support.plan.md +928 -0
  122. package/plandocs/journey-id-tracking-addendum.md +227 -0
  123. package/src/execution-service.ts +179 -596
  124. package/src/index.ts +10 -0
  125. package/src/llm-facade.ts +8 -8
  126. package/src/llm-provider.ts +11 -1
  127. package/src/model-constants.ts +17 -5
  128. package/src/orchestrator/decision-parser.ts +139 -0
  129. package/src/orchestrator/index.ts +27 -2
  130. package/src/orchestrator/orchestrator-agent.ts +868 -623
  131. package/src/orchestrator/orchestrator-prompts.ts +786 -0
  132. package/src/orchestrator/page-som-handler.ts +1565 -0
  133. package/src/orchestrator/som-types.ts +188 -0
  134. package/src/orchestrator/tool-registry.ts +2 -0
  135. package/src/orchestrator/tools/index.ts +5 -1
  136. package/src/orchestrator/tools/refresh-som-markers.ts +69 -0
  137. package/src/orchestrator/tools/verify-action-result.ts +159 -0
  138. package/src/orchestrator/tools/view-previous-screenshot.ts +103 -0
  139. package/src/orchestrator/types.ts +95 -4
  140. package/src/prompts.ts +40 -34
  141. package/src/scenario-service.ts +20 -0
  142. package/src/scenario-worker-class.ts +30 -4
  143. package/src/utils/coordinate-converter.ts +162 -0
  144. package/src/utils/page-info-retry.ts +65 -0
  145. package/src/utils/page-info-utils.ts +53 -18
  146. package/testchimp-runner-core-0.0.35.tgz +0 -0
  147. /package/{CREDIT_CALLBACK_ARCHITECTURE.md → plandocs/CREDIT_CALLBACK_ARCHITECTURE.md} +0 -0
  148. /package/{INTEGRATION_COMPLETE.md → plandocs/INTEGRATION_COMPLETE.md} +0 -0
  149. /package/{VISION_DIAGNOSTICS_IMPROVEMENTS.md → plandocs/VISION_DIAGNOSTICS_IMPROVEMENTS.md} +0 -0
  150. /package/{RELEASE_0.0.26.md → releasenotes/RELEASE_0.0.26.md} +0 -0
  151. /package/{RELEASE_0.0.27.md → releasenotes/RELEASE_0.0.27.md} +0 -0
  152. /package/{RELEASE_0.0.28.md → releasenotes/RELEASE_0.0.28.md} +0 -0
@@ -0,0 +1,1565 @@
1
+ /**
2
+ * PageSoMHandler - Set-of-Marks Handler for Visual Element Identification
3
+ * Manages SoM markers, canvas overlay, and semantic command execution
4
+ */
5
+
6
+ // @ts-nocheck - DOM APIs used in page.evaluate() browser context
7
+
8
+ import { expect } from '@playwright/test';
9
+ import {
10
+ SomCommand,
11
+ SomElement,
12
+ SemanticCommandResult,
13
+ CommandRunStatus,
14
+ CommandAttempt,
15
+ DomMutation,
16
+ InteractionAction,
17
+ SomVerification,
18
+ VerificationType
19
+ } from './som-types';
20
+ import type { TypedSelector } from './som-types';
21
+
22
+ export class PageSoMHandler {
23
+ private page: any; // Playwright Page
24
+ private somMap: Map<string, SomElement>;
25
+ private canvasInjected: boolean = false;
26
+ private mutationsList: DomMutation[] = [];
27
+ private mutationObserver: any = null; // MutationObserver only exists in browser context
28
+ private logger?: (message: string, level?: 'log' | 'error' | 'warn') => void;
29
+
30
+ constructor(page: any, logger?: (message: string, level?: 'log' | 'error' | 'warn') => void) {
31
+ this.page = page;
32
+ this.somMap = new Map();
33
+ this.logger = logger;
34
+ }
35
+
36
+ setPage(page: any): void {
37
+ this.page = page;
38
+ this.somMap.clear();
39
+ this.canvasInjected = false;
40
+ this.disconnectMutationObserver();
41
+ }
42
+
43
+ private async disconnectMutationObserver(): Promise<void> {
44
+ if (this.mutationObserver || this.page) {
45
+ try {
46
+ // Clean up in page context
47
+ await this.page.evaluate(() => {
48
+ if ((window as any).__tcSomMutationObserver) {
49
+ (window as any).__tcSomMutationObserver.disconnect();
50
+ delete (window as any).__tcSomMutationObserver;
51
+ }
52
+ if ((window as any).__tcSomMutations) {
53
+ delete (window as any).__tcSomMutations;
54
+ }
55
+ });
56
+ } catch (error) {
57
+ // Page may be closed or navigated away
58
+ this.logger?.(`[PageSoMHandler] Failed to disconnect mutation observer: ${error}`, 'warn');
59
+ }
60
+ this.mutationObserver = null;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Update SoM markers - extract interactive elements and draw overlay
66
+ * Note: Coordinate markers from previous iteration are NOT removed here
67
+ * They persist through screenshots so agent can see where coords landed
68
+ */
69
+ async updateSom(): Promise<number> {
70
+ this.logger?.('[PageSoMHandler] Updating SoM markers...', 'log');
71
+
72
+ // Extract interactive elements and assign SoM IDs
73
+ const elements = await this.page.evaluate(() => {
74
+ const doc = document;
75
+ const elements: any[] = [];
76
+ let idCounter = 1;
77
+
78
+ // Query all interactive elements
79
+ const interactiveSelectors = [
80
+ 'button', 'input', 'textarea', 'select', 'a[href]',
81
+ '[role="button"]', '[role="link"]', '[role="textbox"]',
82
+ '[role="checkbox"]', '[role="radio"]', '[role="combobox"]',
83
+ '[role="menu"]', '[role="menuitem"]', '[role="option"]',
84
+ '[aria-haspopup]', '[onclick]', '[type="submit"]',
85
+ '[role="tab"]', '[role="switch"]', '[role="spinbutton"]'
86
+ ];
87
+
88
+ const allInteractive = new Set<HTMLElement>();
89
+ interactiveSelectors.forEach(selector => {
90
+ doc.querySelectorAll(selector).forEach((el: any) => allInteractive.add(el));
91
+ });
92
+
93
+ // Also detect styled-as-interactive elements
94
+ doc.querySelectorAll('div, span, p, li, td').forEach((el: any) => {
95
+ const styles = window.getComputedStyle(el);
96
+ const hasClickHandler = el.onclick || el.getAttribute('onclick') ||
97
+ el.hasAttribute('data-action') || el.hasAttribute('data-click');
98
+
99
+ if (styles.cursor === 'pointer' || hasClickHandler || el.tabIndex >= 0) {
100
+ allInteractive.add(el);
101
+ }
102
+ });
103
+
104
+ // Remove elements that are descendants of "true" interactive elements
105
+ // (e.g., <span> inside <button> should not get separate marker)
106
+ // But keep elements inside generic containers (div, section, etc.)
107
+ const trueInteractiveTags = new Set([
108
+ 'BUTTON', 'A', 'INPUT', 'TEXTAREA', 'SELECT', 'LABEL'
109
+ ]);
110
+
111
+ const topLevelInteractive = new Set<HTMLElement>();
112
+ allInteractive.forEach((el: any) => {
113
+ let hasInteractiveAncestor = false;
114
+ let parent = el.parentElement;
115
+
116
+ // Check if any ancestor is a "true" interactive element
117
+ while (parent) {
118
+ if (allInteractive.has(parent) && trueInteractiveTags.has(parent.tagName)) {
119
+ hasInteractiveAncestor = true;
120
+ break;
121
+ }
122
+ parent = parent.parentElement;
123
+ }
124
+
125
+ if (!hasInteractiveAncestor) {
126
+ topLevelInteractive.add(el);
127
+ }
128
+ });
129
+
130
+ // Filter to visible, non-occluded, enabled elements
131
+ topLevelInteractive.forEach((el: any) => {
132
+ const rect = el.getBoundingClientRect();
133
+
134
+ // Skip invisible elements (zero size)
135
+ if (rect.width === 0 || rect.height === 0) return;
136
+
137
+ // Skip hidden elements (display, visibility, opacity checks)
138
+ const styles = window.getComputedStyle(el);
139
+ if (styles.display === 'none' || styles.visibility === 'hidden' ||
140
+ parseFloat(styles.opacity) === 0) return;
141
+
142
+ // Skip disabled elements (they can't be interacted with)
143
+ const isDisabled = el.disabled ||
144
+ el.hasAttribute('disabled') ||
145
+ el.getAttribute('aria-disabled') === 'true' ||
146
+ el.getAttribute('data-disabled') === 'true' ||
147
+ el.classList?.contains('disabled');
148
+
149
+ if (isDisabled) return;
150
+
151
+ // Z-index occlusion detection: Check if element is fully blocked
152
+ // Sample 5 points: center + 4 corners (slightly inset)
153
+ const centerX = rect.left + rect.width / 2;
154
+ const centerY = rect.top + rect.height / 2;
155
+ // Use smaller inset for small elements (min 1px, max 10% of dimension)
156
+ const inset = Math.max(1, Math.min(rect.width, rect.height) * 0.1);
157
+
158
+ const testPoints = [
159
+ { x: centerX, y: centerY }, // center
160
+ { x: rect.left + inset, y: rect.top + inset }, // top-left
161
+ { x: rect.right - inset, y: rect.top + inset }, // top-right
162
+ { x: rect.left + inset, y: rect.bottom - inset }, // bottom-left
163
+ { x: rect.right - inset, y: rect.bottom - inset } // bottom-right
164
+ ];
165
+
166
+ // Element is visible if at least 1 test point hits it or a descendant
167
+ // (relaxed from 2 to handle small clickable icons better)
168
+ let visiblePoints = 0;
169
+ for (const point of testPoints) {
170
+ const topEl = document.elementFromPoint(point.x, point.y);
171
+ if (topEl && (topEl === el || el.contains(topEl) || topEl.contains(el))) {
172
+ visiblePoints++;
173
+ }
174
+ }
175
+
176
+ // Skip fully occluded elements (no visible points)
177
+ if (visiblePoints < 1) {
178
+ return;
179
+ }
180
+
181
+ // Assign tc-som-id attribute
182
+ const somId = String(idCounter++);
183
+ el.setAttribute('tc-som-id', somId);
184
+
185
+ // Capture element details
186
+ const parent = el.parentElement;
187
+ elements.push({
188
+ somId,
189
+ tag: el.tagName.toLowerCase(),
190
+ role: el.getAttribute('role') || el.tagName.toLowerCase(),
191
+ text: el.textContent?.trim().substring(0, 50) || '',
192
+ ariaLabel: el.getAttribute('aria-label') || '',
193
+ placeholder: el.placeholder || '',
194
+ name: el.getAttribute('name') || '',
195
+ type: el.type || '',
196
+ id: el.id || '',
197
+ className: el.className || '',
198
+ bbox: {
199
+ x: Math.round(rect.x),
200
+ y: Math.round(rect.y),
201
+ width: Math.round(rect.width),
202
+ height: Math.round(rect.height)
203
+ },
204
+ parent: parent ? {
205
+ tag: parent.tagName.toLowerCase(),
206
+ role: parent.getAttribute('role') || '',
207
+ className: parent.className || '',
208
+ text: parent.textContent?.trim().substring(0, 30) || ''
209
+ } : undefined
210
+ });
211
+ });
212
+
213
+ return elements;
214
+ });
215
+
216
+ // Store in somMap
217
+ this.somMap.clear();
218
+ elements.forEach(el => this.somMap.set(el.somId, el));
219
+
220
+ // Inject/update canvas overlay with bounding boxes
221
+ await this.drawSomOverlay(elements);
222
+
223
+ this.logger?.(`[PageSoMHandler] Mapped ${elements.length} interactive elements`, 'log');
224
+ return elements.length;
225
+ }
226
+
227
+ /**
228
+ * Draw canvas overlay with bounding boxes and ID labels
229
+ */
230
+ private async drawSomOverlay(elements: SomElement[]): Promise<void> {
231
+ await this.page.evaluate((els: SomElement[]) => {
232
+ const doc = document;
233
+
234
+ // Create or get canvas
235
+ let canvas = doc.getElementById('tc-som-canvas') as HTMLCanvasElement;
236
+ if (!canvas) {
237
+ canvas = doc.createElement('canvas');
238
+ canvas.id = 'tc-som-canvas';
239
+ canvas.style.cssText = `
240
+ position: fixed;
241
+ top: 0;
242
+ left: 0;
243
+ width: 100%;
244
+ height: 100%;
245
+ z-index: 2147483647;
246
+ pointer-events: none;
247
+ display: none;
248
+ `;
249
+ doc.body.appendChild(canvas);
250
+ }
251
+
252
+ // Set canvas dimensions to viewport size
253
+ canvas.width = window.innerWidth;
254
+ canvas.height = window.innerHeight;
255
+
256
+ const ctx = canvas.getContext('2d')!;
257
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
258
+
259
+ // Make canvas visible for debugging (markers shown on live page)
260
+ canvas.style.display = 'block';
261
+
262
+ // 20 distinct colors with high contrast for readability
263
+ // Each color chosen to ensure the ID label is clearly visible
264
+ const COLOR_PALETTE = [
265
+ { stroke: '#CC0000', fill: '#FF4444', text: 'white' }, // Bright Red
266
+ { stroke: '#00AA00', fill: '#66FF66', text: 'black' }, // Bright Green
267
+ { stroke: '#0066CC', fill: '#3399FF', text: 'white' }, // Bright Blue
268
+ { stroke: '#FFAA00', fill: '#FFDD66', text: 'black' }, // Bright Orange
269
+ { stroke: '#CC00CC', fill: '#FF66FF', text: 'black' }, // Bright Magenta
270
+ { stroke: '#00AAAA', fill: '#66FFFF', text: 'black' }, // Bright Cyan
271
+ { stroke: '#FF6600', fill: '#FFAA66', text: 'black' }, // Orange-Red
272
+ { stroke: '#6600CC', fill: '#9966FF', text: 'white' }, // Purple
273
+ { stroke: '#00CC66', fill: '#66FFAA', text: 'black' }, // Sea Green
274
+ { stroke: '#FF0066', fill: '#FF66AA', text: 'white' }, // Hot Pink
275
+ { stroke: '#66CC00', fill: '#AAFF66', text: 'black' }, // Lime Green
276
+ { stroke: '#CC6600', fill: '#FFAA44', text: 'black' }, // Dark Orange
277
+ { stroke: '#0099FF', fill: '#66CCFF', text: 'black' }, // Sky Blue
278
+ { stroke: '#FF9999', fill: '#FFDDDD', text: 'black' }, // Light Coral
279
+ { stroke: '#AA5500', fill: '#FF8833', text: 'white' }, // Dark Orange/Brown
280
+ { stroke: '#5555AA', fill: '#8888FF', text: 'white' }, // Slate Blue
281
+ { stroke: '#AA0044', fill: '#FF4488', text: 'white' }, // Raspberry
282
+ { stroke: '#00AA88', fill: '#44FFCC', text: 'black' }, // Turquoise
283
+ { stroke: '#AA44AA', fill: '#DD88DD', text: 'black' }, // Orchid
284
+ { stroke: '#AAAA00', fill: '#FFFF66', text: 'black' } // Yellow-Green
285
+ ];
286
+
287
+ // Draw bounding boxes and labels
288
+ els.forEach((el, index) => {
289
+ const { bbox, somId } = el;
290
+
291
+ // Assign color based on element index (cycles through 20 colors)
292
+ const colors = COLOR_PALETTE[index % COLOR_PALETTE.length];
293
+
294
+ // Draw bounding box
295
+ ctx.strokeStyle = colors.stroke;
296
+ ctx.lineWidth = 3;
297
+ ctx.strokeRect(bbox.x, bbox.y, bbox.width, bbox.height);
298
+
299
+ // Draw ID label at TOP-RIGHT corner, OUTSIDE above the bounding box
300
+ // This prevents obscuring element content (text, icons, etc.)
301
+ const labelText = somId;
302
+ ctx.font = 'bold 14px Arial';
303
+ const textMetrics = ctx.measureText(labelText);
304
+ const textWidth = textMetrics.width;
305
+ const textHeight = 14;
306
+ const padding = 3;
307
+
308
+ const labelBoxWidth = textWidth + padding * 2;
309
+ const labelBoxHeight = textHeight + padding;
310
+
311
+ // Position label at top-right corner, ABOVE the bounding box
312
+ // Label is right-aligned with bbox, bottom of label touches top of bbox
313
+ const labelX = bbox.x + bbox.width - labelBoxWidth;
314
+ let labelY = bbox.y - labelBoxHeight;
315
+
316
+ // If no room above, place below the bounding box instead
317
+ if (labelY < 0) {
318
+ labelY = bbox.y + bbox.height;
319
+ }
320
+
321
+ // Draw label background
322
+ ctx.fillStyle = colors.fill;
323
+ ctx.fillRect(labelX, labelY, labelBoxWidth, labelBoxHeight);
324
+
325
+ // Draw label text
326
+ ctx.fillStyle = colors.text;
327
+ ctx.textBaseline = 'top';
328
+ ctx.fillText(labelText, labelX + padding, labelY + padding / 2);
329
+ ctx.textBaseline = 'alphabetic'; // Reset
330
+ });
331
+ }, elements);
332
+
333
+ this.canvasInjected = true;
334
+ }
335
+
336
+ /**
337
+ * Get formatted SoM element map for agent context
338
+ * Returns concise, truncated element details to help disambiguate similar elements
339
+ */
340
+ getSomElementMap(): string {
341
+ if (this.somMap.size === 0) {
342
+ return 'No SoM elements mapped yet.';
343
+ }
344
+
345
+ const lines: string[] = [];
346
+
347
+ // Sort by somId (numeric)
348
+ const sortedEntries = Array.from(this.somMap.entries()).sort((a, b) => {
349
+ return parseInt(a[0]) - parseInt(b[0]);
350
+ });
351
+
352
+ for (const [somId, element] of sortedEntries) {
353
+ const parts: string[] = [];
354
+
355
+ // Tag (always present)
356
+ parts.push(element.tag);
357
+
358
+ // Text (truncate to 40 chars)
359
+ if (element.text && element.text.trim()) {
360
+ const truncated = element.text.trim().substring(0, 40);
361
+ parts.push(`"${truncated}${element.text.length > 40 ? '...' : ''}"`);
362
+ }
363
+
364
+ // Build attributes string
365
+ const attrs: string[] = [];
366
+
367
+ if (element.ariaLabel) {
368
+ attrs.push(`aria: "${element.ariaLabel.substring(0, 30)}"`);
369
+ }
370
+ if (element.placeholder) {
371
+ attrs.push(`placeholder: "${element.placeholder.substring(0, 30)}"`);
372
+ }
373
+ if (element.type && element.type !== 'text') {
374
+ attrs.push(`type: "${element.type}"`);
375
+ }
376
+ if (element.role && element.role !== 'generic') {
377
+ attrs.push(`role: "${element.role}"`);
378
+ }
379
+ if (element.name) {
380
+ attrs.push(`name: "${element.name.substring(0, 20)}"`);
381
+ }
382
+
383
+ // Combine: [1]: button "Submit" (aria: "submit-form", type: "submit")
384
+ const attrStr = attrs.length > 0 ? ` (${attrs.join(', ')})` : '';
385
+ lines.push(`[${somId}]: ${parts.join(' ')}${attrStr}`);
386
+ }
387
+
388
+ return lines.join('\n');
389
+ }
390
+
391
+ /**
392
+ * Get screenshot with optional SoM markers
393
+ */
394
+ async getScreenshot(
395
+ includeSomMarkers: boolean,
396
+ fullPage: boolean = false,
397
+ quality: number = 60
398
+ ): Promise<string> {
399
+ // Show/hide canvas overlay
400
+ await this.page.evaluate((show: boolean) => {
401
+ const canvas = document.getElementById('tc-som-canvas');
402
+ if (canvas) canvas.style.display = show ? 'block' : 'none';
403
+ }, includeSomMarkers);
404
+
405
+ // Capture screenshot
406
+ const buffer = await this.page.screenshot({
407
+ fullPage,
408
+ type: 'jpeg',
409
+ quality
410
+ });
411
+
412
+ // Keep markers visible for debugging - don't hide them
413
+ // Only hide if specifically requested (includeSomMarkers = false)
414
+ if (!includeSomMarkers) {
415
+ await this.page.evaluate(() => {
416
+ const canvas = document.getElementById('tc-som-canvas');
417
+ if (canvas) canvas.style.display = 'none';
418
+ });
419
+ }
420
+
421
+ return `data:image/jpeg;base64,${buffer.toString('base64')}`;
422
+ }
423
+
424
+ /**
425
+ * Run command with semantic selector generation and coordinate fallback
426
+ */
427
+ async runCommand(
428
+ command: SomCommand,
429
+ useSomIdBasedCommands: boolean = false
430
+ ): Promise<SemanticCommandResult> {
431
+ const failedAttempts: CommandAttempt[] = [];
432
+ const maxAttempts = 7; // Error budget per command
433
+
434
+ // Handle navigation actions (no element/coord required)
435
+ if (command.action === InteractionAction.NAVIGATE ||
436
+ command.action === InteractionAction.GO_BACK ||
437
+ command.action === InteractionAction.GO_FORWARD ||
438
+ command.action === InteractionAction.RELOAD) {
439
+ this.logger?.(`[PageSoMHandler] Executing navigation action: ${command.action}`, 'log');
440
+
441
+ try {
442
+ let playwrightCommand = '';
443
+ switch (command.action) {
444
+ case InteractionAction.NAVIGATE:
445
+ if (!command.value) {
446
+ throw new Error('NAVIGATE action requires URL in value field');
447
+ }
448
+ await this.page.goto(command.value, { waitUntil: 'networkidle', timeout: 30000 });
449
+ playwrightCommand = `await page.goto('${command.value}')`;
450
+ break;
451
+ case InteractionAction.GO_BACK:
452
+ await this.page.goBack({ waitUntil: 'networkidle', timeout: 30000 });
453
+ playwrightCommand = 'await page.goBack()';
454
+ break;
455
+ case InteractionAction.GO_FORWARD:
456
+ await this.page.goForward({ waitUntil: 'networkidle', timeout: 30000 });
457
+ playwrightCommand = 'await page.goForward()';
458
+ break;
459
+ case InteractionAction.RELOAD:
460
+ await this.page.reload({ waitUntil: 'networkidle', timeout: 30000 });
461
+ playwrightCommand = 'await page.reload()';
462
+ break;
463
+ }
464
+
465
+ return {
466
+ failedAttempts: [],
467
+ successAttempt: {
468
+ command: playwrightCommand,
469
+ status: CommandRunStatus.SUCCESS
470
+ },
471
+ status: CommandRunStatus.SUCCESS
472
+ };
473
+ } catch (error: any) {
474
+ return {
475
+ failedAttempts: [{
476
+ command: `Navigation action: ${command.action}`,
477
+ status: CommandRunStatus.FAILURE,
478
+ error: error.message
479
+ }],
480
+ error: error.message,
481
+ status: CommandRunStatus.FAILURE
482
+ };
483
+ }
484
+ }
485
+
486
+ // Handle direct percentage-based coordinate commands
487
+ if (command.coord && !command.elementRef) {
488
+ this.logger?.(`[PageSoMHandler] Executing ${command.action} at percentage coords (${command.coord.x}%, ${command.coord.y}%)`, 'log');
489
+
490
+ try {
491
+ const result = await this.executePercentageCoordinateAction(command);
492
+ return {
493
+ failedAttempts: [],
494
+ successAttempt: {
495
+ command: `Coordinate action: ${command.action} at (${command.coord.x}%, ${command.coord.y}%)`,
496
+ status: CommandRunStatus.SUCCESS
497
+ },
498
+ status: CommandRunStatus.SUCCESS
499
+ };
500
+ } catch (error: any) {
501
+ return {
502
+ failedAttempts: [{
503
+ command: `Coordinate action: ${command.action} at (${command.coord.x}%, ${command.coord.y}%)`,
504
+ status: CommandRunStatus.FAILURE,
505
+ error: error.message
506
+ }],
507
+ error: error.message,
508
+ status: CommandRunStatus.FAILURE
509
+ };
510
+ }
511
+ }
512
+
513
+ // Lookup element in SoM map
514
+ if (!command.elementRef) {
515
+ return {
516
+ failedAttempts: [],
517
+ error: 'Command must have either elementRef or coord specified',
518
+ status: CommandRunStatus.FAILURE
519
+ };
520
+ }
521
+
522
+ const element = this.somMap.get(command.elementRef);
523
+ if (!element) {
524
+ return {
525
+ failedAttempts: [],
526
+ error: `Element with SoM ID "${command.elementRef}" not found in map`,
527
+ status: CommandRunStatus.FAILURE
528
+ };
529
+ }
530
+
531
+ this.logger?.(`[PageSoMHandler] Executing ${command.action} on element ${command.elementRef}`, 'log');
532
+
533
+ // Clear mutations list before command execution
534
+ this.mutationsList = [];
535
+
536
+ // Setup mutation observer for hover/focus actions
537
+ if (command.action === InteractionAction.HOVER || command.action === InteractionAction.FOCUS) {
538
+ await this.setupMutationObserver();
539
+ }
540
+
541
+ // Try semantic selectors first (unless useSomIdBasedCommands=true)
542
+ if (!useSomIdBasedCommands) {
543
+ const selectors = this.generateSemanticSelectors(element);
544
+
545
+ for (let i = 0; i < Math.min(selectors.length, maxAttempts); i++) {
546
+ const selector = selectors[i];
547
+ const result = await this.tryExecuteAction(selector, command, element);
548
+
549
+ if (result.status === CommandRunStatus.SUCCESS) {
550
+ // Wait for mutations if hover/focus
551
+ if (command.action === InteractionAction.HOVER || command.action === InteractionAction.FOCUS) {
552
+ await this.page.waitForTimeout(500); // Wait for mutations to settle
553
+ result.mutations = await this.filterRelevantMutations();
554
+ }
555
+
556
+ await this.disconnectMutationObserver();
557
+ return result;
558
+ }
559
+
560
+ if (result.failedAttempts.length > 0) {
561
+ failedAttempts.push(result.failedAttempts[0]);
562
+ }
563
+ }
564
+ } else {
565
+ // Use tc-som-id attribute directly
566
+ const somIdSelector: TypedSelector = {
567
+ type: 'locator',
568
+ value: `[tc-som-id="${command.elementRef}"]`
569
+ };
570
+ const result = await this.tryExecuteAction(somIdSelector, command, element);
571
+ if (result.status === CommandRunStatus.SUCCESS) {
572
+ await this.disconnectMutationObserver();
573
+ return result;
574
+ }
575
+ if (result.failedAttempts.length > 0) {
576
+ failedAttempts.push(result.failedAttempts[0]);
577
+ }
578
+ }
579
+
580
+ // Fallback to coordinates (more replayable)
581
+ this.logger?.(`[PageSoMHandler] Semantic selectors exhausted (${failedAttempts.length} attempts), falling back to coordinates`, 'warn');
582
+ const coordResult = await this.executeCoordinateAction(command, element);
583
+
584
+ await this.disconnectMutationObserver();
585
+
586
+ // Add failed attempts to coordinate result
587
+ if (coordResult.status === CommandRunStatus.SUCCESS) {
588
+ coordResult.failedAttempts = failedAttempts;
589
+ }
590
+
591
+ return coordResult;
592
+ }
593
+
594
+ /**
595
+ * Execute verification command and generate Playwright expect assertion
596
+ * Uses semantic selectors (reuses generateSemanticSelectors) for script portability
597
+ */
598
+ async executeVerification(verification: SomVerification): Promise<{
599
+ success: boolean;
600
+ playwrightCommand: string;
601
+ error?: string;
602
+ }> {
603
+ try {
604
+ let locatorStr = '';
605
+ let locator: any;
606
+ let element: SomElement | undefined;
607
+
608
+ // Build locator using semantic selectors (like runCommand does)
609
+ if (verification.elementRef) {
610
+ element = this.somMap.get(verification.elementRef);
611
+ if (!element) {
612
+ return {
613
+ success: false,
614
+ playwrightCommand: '',
615
+ error: `Element with SoM ID ${verification.elementRef} not found`
616
+ };
617
+ }
618
+
619
+ // Generate semantic selectors (reuse existing logic)
620
+ const semanticSelectors = this.generateSemanticSelectors(element);
621
+
622
+ if (semanticSelectors.length === 0) {
623
+ return {
624
+ success: false,
625
+ playwrightCommand: '',
626
+ error: `No semantic selectors available for element ${verification.elementRef}`
627
+ };
628
+ }
629
+
630
+ // Try selectors in priority order to find working one
631
+ let workingSelector: TypedSelector | null = null;
632
+ for (const typedSelector of semanticSelectors) {
633
+ try {
634
+ const testLocator = this.buildLocatorFromTypedSelector(typedSelector);
635
+ await testLocator.first().waitFor({ state: 'attached', timeout: 1000 });
636
+ workingSelector = typedSelector;
637
+ break;
638
+ } catch {
639
+ continue;
640
+ }
641
+ }
642
+
643
+ if (!workingSelector) {
644
+ // Fall back to first selector even if not confirmed working
645
+ workingSelector = semanticSelectors[0];
646
+ this.logger?.(`[PageSoMHandler] No confirmed working selector, using first: ${this.formatSelector(workingSelector)}`, 'warn');
647
+ }
648
+
649
+ locator = this.buildLocatorFromTypedSelector(workingSelector);
650
+ locatorStr = this.formatSelector(workingSelector);
651
+
652
+ } else if (verification.selector) {
653
+ // Direct CSS selector (for count verifications on non-SoM elements)
654
+ locatorStr = `page.locator('${verification.selector}')`;
655
+ locator = this.page.locator(verification.selector);
656
+ } else {
657
+ return {
658
+ success: false,
659
+ playwrightCommand: '',
660
+ error: 'Either elementRef or selector required for verification'
661
+ };
662
+ }
663
+
664
+ let expectCommand = '';
665
+ let verificationPassed = true;
666
+
667
+ // Generate Playwright command string FIRST (before executing, so we have it even if assertion fails)
668
+ switch (verification.verificationType) {
669
+ case VerificationType.TEXT_CONTAINS:
670
+ expectCommand = `await expect(${locatorStr}).toContainText('${verification.expected}')`;
671
+ break;
672
+
673
+ case VerificationType.TEXT_EQUALS:
674
+ expectCommand = `await expect(${locatorStr}).toHaveText('${verification.expected}')`;
675
+ break;
676
+
677
+ case VerificationType.VALUE_EQUALS:
678
+ expectCommand = `await expect(${locatorStr}).toHaveValue('${verification.expected}')`;
679
+ break;
680
+
681
+ case VerificationType.VALUE_EMPTY:
682
+ expectCommand = `await expect(${locatorStr}).toHaveValue('')`;
683
+ break;
684
+
685
+ case VerificationType.IS_VISIBLE:
686
+ expectCommand = `await expect(${locatorStr}).toBeVisible()`;
687
+ break;
688
+
689
+ case VerificationType.IS_HIDDEN:
690
+ expectCommand = `await expect(${locatorStr}).toBeHidden()`;
691
+ break;
692
+
693
+ case VerificationType.IS_ENABLED:
694
+ expectCommand = `await expect(${locatorStr}).toBeEnabled()`;
695
+ break;
696
+
697
+ case VerificationType.IS_DISABLED:
698
+ expectCommand = `await expect(${locatorStr}).toBeDisabled()`;
699
+ break;
700
+
701
+ case VerificationType.IS_CHECKED:
702
+ expectCommand = `await expect(${locatorStr}).toBeChecked()`;
703
+ break;
704
+
705
+ case VerificationType.IS_UNCHECKED:
706
+ expectCommand = `await expect(${locatorStr}).not.toBeChecked()`;
707
+ break;
708
+
709
+ case VerificationType.COUNT_EQUALS:
710
+ expectCommand = `await expect(${locatorStr}).toHaveCount(${verification.expected})`;
711
+ break;
712
+
713
+ case VerificationType.COUNT_GREATER_THAN:
714
+ expectCommand = `expect(await ${locatorStr}.count()).toBeGreaterThan(${verification.expected})`;
715
+ break;
716
+
717
+ case VerificationType.COUNT_LESS_THAN:
718
+ expectCommand = `expect(await ${locatorStr}.count()).toBeLessThan(${verification.expected})`;
719
+ break;
720
+
721
+ case VerificationType.HAS_CLASS:
722
+ expectCommand = `await expect(${locatorStr}).toHaveClass(/${verification.expected}/)`;
723
+ break;
724
+
725
+ case VerificationType.HAS_ATTRIBUTE:
726
+ // Format: "attr:value" or just "attr" (use colon to avoid = in values)
727
+ const parts = (verification.expected as string).split(':', 2);
728
+ const attr = parts[0];
729
+ const value = parts[1];
730
+ if (value) {
731
+ expectCommand = `await expect(${locatorStr}).toHaveAttribute('${attr}', '${value}')`;
732
+ } else {
733
+ expectCommand = `await expect(${locatorStr}).toHaveAttribute('${attr}')`;
734
+ }
735
+ break;
736
+
737
+ default:
738
+ return {
739
+ success: false,
740
+ playwrightCommand: '',
741
+ error: `Unknown verification type: ${verification.verificationType}`
742
+ };
743
+ }
744
+
745
+ // Now execute the assertion (may fail, but we already have the command string)
746
+ try {
747
+ switch (verification.verificationType) {
748
+ case VerificationType.TEXT_CONTAINS:
749
+ await expect(locator).toContainText(verification.expected as string, { timeout: 5000 });
750
+ break;
751
+
752
+ case VerificationType.TEXT_EQUALS:
753
+ await expect(locator).toHaveText(verification.expected as string, { timeout: 5000 });
754
+ break;
755
+
756
+ case VerificationType.VALUE_EQUALS:
757
+ await expect(locator).toHaveValue(verification.expected as string, { timeout: 5000 });
758
+ break;
759
+
760
+ case VerificationType.VALUE_EMPTY:
761
+ await expect(locator).toHaveValue('', { timeout: 5000 });
762
+ break;
763
+
764
+ case VerificationType.IS_VISIBLE:
765
+ await expect(locator).toBeVisible({ timeout: 5000 });
766
+ break;
767
+
768
+ case VerificationType.IS_HIDDEN:
769
+ await expect(locator).toBeHidden({ timeout: 5000 });
770
+ break;
771
+
772
+ case VerificationType.IS_ENABLED:
773
+ await expect(locator).toBeEnabled({ timeout: 5000 });
774
+ break;
775
+
776
+ case VerificationType.IS_DISABLED:
777
+ await expect(locator).toBeDisabled({ timeout: 5000 });
778
+ break;
779
+
780
+ case VerificationType.IS_CHECKED:
781
+ await expect(locator).toBeChecked({ timeout: 5000 });
782
+ break;
783
+
784
+ case VerificationType.IS_UNCHECKED:
785
+ await expect(locator).not.toBeChecked({ timeout: 5000 });
786
+ break;
787
+
788
+ case VerificationType.COUNT_EQUALS:
789
+ await expect(locator).toHaveCount(verification.expected as number, { timeout: 5000 });
790
+ break;
791
+
792
+ case VerificationType.COUNT_GREATER_THAN:
793
+ const countGT = await locator.count();
794
+ expect(countGT).toBeGreaterThan(verification.expected as number);
795
+ break;
796
+
797
+ case VerificationType.COUNT_LESS_THAN:
798
+ const countLT = await locator.count();
799
+ expect(countLT).toBeLessThan(verification.expected as number);
800
+ break;
801
+
802
+ case VerificationType.HAS_CLASS:
803
+ await expect(locator).toHaveClass(new RegExp(verification.expected as string), { timeout: 5000 });
804
+ break;
805
+
806
+ case VerificationType.HAS_ATTRIBUTE:
807
+ const parts = (verification.expected as string).split(':', 2);
808
+ const attr = parts[0];
809
+ const value = parts[1];
810
+ if (value) {
811
+ await expect(locator).toHaveAttribute(attr, value, { timeout: 5000 });
812
+ } else {
813
+ await expect(locator).toHaveAttribute(attr, { timeout: 5000 });
814
+ }
815
+ break;
816
+ }
817
+ } catch (assertionError: any) {
818
+ // Assertion failed but we still want the command in the script
819
+ verificationPassed = false;
820
+ this.logger?.(`[PageSoMHandler] Verification assertion failed (non-fatal): ${assertionError.message}`, 'warn');
821
+ }
822
+
823
+ // Always return the command (even if assertion failed) - scripts need the expect
824
+ return {
825
+ success: verificationPassed,
826
+ playwrightCommand: expectCommand,
827
+ error: verificationPassed ? undefined : 'Assertion failed during generation'
828
+ };
829
+
830
+ } catch (error: any) {
831
+ // Could not build command at all (selector not found, etc.)
832
+ return {
833
+ success: false,
834
+ playwrightCommand: '',
835
+ error: error.message
836
+ };
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Generate typed semantic selectors from element details (no string parsing needed)
842
+ */
843
+ private generateSemanticSelectors(element: SomElement): TypedSelector[] {
844
+ const selectors: TypedSelector[] = [];
845
+
846
+ // Priority 1: getByTestId (would need to capture in updateSom)
847
+ // Skipped for now - can add data-testid capture later
848
+
849
+ // Priority 2: Stable ID
850
+ if (element.id && !element.id.match(/^(rc_|:r[0-9]+:|__)/) && !element.id.includes('«')) {
851
+ selectors.push({ type: 'id', value: element.id });
852
+ }
853
+
854
+ // Priority 3: getByLabel
855
+ if (element.ariaLabel) {
856
+ selectors.push({ type: 'label', value: element.ariaLabel });
857
+ }
858
+
859
+ // Priority 4: getByRole
860
+ if (element.role && element.text) {
861
+ selectors.push({ type: 'role', value: element.role, roleOptions: { name: element.text } });
862
+ }
863
+
864
+ // Priority 5: getByPlaceholder
865
+ if (element.placeholder) {
866
+ selectors.push({ type: 'placeholder', value: element.placeholder });
867
+ }
868
+
869
+ // Priority 6: input[name]
870
+ if (element.name && ['input', 'textarea', 'select'].includes(element.tag)) {
871
+ selectors.push({ type: 'name', value: element.name });
872
+ }
873
+
874
+ // Priority 7: getByText
875
+ if (element.text) {
876
+ selectors.push({ type: 'text', value: element.text });
877
+ }
878
+
879
+ // Priority 8: Parent-scoped locator (generic Playwright selector)
880
+ if (element.parent?.className) {
881
+ const parentClass = element.parent.className.split(' ')[0];
882
+ if (parentClass) {
883
+ selectors.push({ type: 'locator', value: `.${parentClass} ${element.tag}` });
884
+ }
885
+ }
886
+
887
+ return selectors;
888
+ }
889
+
890
+ /**
891
+ * Try executing action with a typed selector (no string parsing)
892
+ */
893
+ private async tryExecuteAction(
894
+ typedSelector: TypedSelector,
895
+ command: SomCommand,
896
+ element: SomElement
897
+ ): Promise<SemanticCommandResult> {
898
+ // Format selector description for both success and error paths
899
+ const selectorDesc = this.formatSelector(typedSelector);
900
+
901
+ try {
902
+ // Build locator with optional parent chaining
903
+ const locator = this.buildLocatorFromTypedSelector(typedSelector);
904
+
905
+ // Execute action based on type
906
+ const playwrightCommand = await this.executeActionOnLocator(locator, command, selectorDesc);
907
+
908
+ return {
909
+ failedAttempts: [],
910
+ successAttempt: {
911
+ command: playwrightCommand,
912
+ status: CommandRunStatus.SUCCESS
913
+ },
914
+ status: CommandRunStatus.SUCCESS
915
+ };
916
+
917
+ } catch (error: any) {
918
+ this.logger?.(`[PageSoMHandler] Selector "${selectorDesc}" failed: ${error.message}`, 'warn');
919
+
920
+ // Apply error-specific heuristics
921
+ return await this.applyHeuristicsAndRetry(typedSelector, command, element, error);
922
+ }
923
+ }
924
+
925
+ /**
926
+ * Apply intelligent heuristics based on error type and retry
927
+ */
928
+ private async applyHeuristicsAndRetry(
929
+ typedSelector: TypedSelector,
930
+ command: SomCommand,
931
+ element: SomElement,
932
+ error: any
933
+ ): Promise<SemanticCommandResult> {
934
+ const errorMsg = error.message || String(error);
935
+ const selectorDesc = this.formatSelector(typedSelector);
936
+
937
+ // Heuristic 1: Strict mode violation → Try parent scoping
938
+ if (errorMsg.includes('strict mode violation')) {
939
+ if (typedSelector.type === 'locator' && element.parent?.className) {
940
+ this.logger?.(`[PageSoMHandler] Heuristic: Strict mode → trying parent scoping`, 'log');
941
+ return await this.tryRefinedSelector(typedSelector, command, element, error);
942
+ }
943
+ // Could add nth() selector here based on bbox position
944
+ }
945
+
946
+ // Heuristic 2: Timeout → Quick retry with scroll (once only, don't get stuck)
947
+ if (errorMsg.includes('Timeout') && !errorMsg.includes('not enabled')) {
948
+ this.logger?.(`[PageSoMHandler] Heuristic: Timeout → quick scroll + retry (once)`, 'log');
949
+ try {
950
+ const locator = this.buildLocatorFromTypedSelector(typedSelector);
951
+ // Try scroll (if element exists but not in viewport)
952
+ await locator.scrollIntoViewIfNeeded({ timeout: 1000 }).catch(() => {});
953
+ await this.page.waitForTimeout(500); // Reduced wait
954
+ const playwrightCommand = await this.executeActionOnLocator(locator, command, selectorDesc);
955
+ return {
956
+ failedAttempts: [{
957
+ command: selectorDesc,
958
+ status: CommandRunStatus.FAILURE,
959
+ error: 'Timeout (first attempt)'
960
+ }],
961
+ successAttempt: {
962
+ command: `${playwrightCommand} (after scroll)`,
963
+ status: CommandRunStatus.SUCCESS
964
+ },
965
+ status: CommandRunStatus.SUCCESS
966
+ };
967
+ } catch (retryError: any) {
968
+ // Element likely doesn't exist or selector is wrong - move on to next selector
969
+ this.logger?.(`[PageSoMHandler] Scroll retry failed, moving to next selector`, 'log');
970
+ }
971
+ }
972
+
973
+ // Heuristic 3: Not enabled/not actionable → Try force flag
974
+ if (errorMsg.includes('not enabled') || errorMsg.includes('not editable') || errorMsg.includes('not actionable')) {
975
+ this.logger?.(`[PageSoMHandler] Heuristic: Not actionable → trying force flag`, 'log');
976
+ try {
977
+ const locator = this.buildLocatorFromTypedSelector(typedSelector);
978
+ const modifiedCommand = { ...command, force: true };
979
+ const playwrightCommand = await this.executeActionOnLocator(locator, modifiedCommand, selectorDesc);
980
+ return {
981
+ failedAttempts: [{
982
+ command: selectorDesc,
983
+ status: CommandRunStatus.FAILURE,
984
+ error: errorMsg
985
+ }],
986
+ successAttempt: {
987
+ command: `${playwrightCommand} (with force)`,
988
+ status: CommandRunStatus.SUCCESS
989
+ },
990
+ status: CommandRunStatus.SUCCESS
991
+ };
992
+ } catch (forceError: any) {
993
+ // Force didn't work
994
+ }
995
+ }
996
+
997
+ // Heuristic 4: Element moving/detached → Wait for stability
998
+ if (errorMsg.includes('detached') || errorMsg.includes('moving')) {
999
+ this.logger?.(`[PageSoMHandler] Heuristic: Element unstable → waiting for stability`, 'log');
1000
+ try {
1001
+ await this.page.waitForLoadState('domcontentloaded');
1002
+ await this.page.waitForTimeout(1000);
1003
+ const locator = this.buildLocatorFromTypedSelector(typedSelector);
1004
+ const playwrightCommand = await this.executeActionOnLocator(locator, command, selectorDesc);
1005
+ return {
1006
+ failedAttempts: [{
1007
+ command: selectorDesc,
1008
+ status: CommandRunStatus.FAILURE,
1009
+ error: errorMsg
1010
+ }],
1011
+ successAttempt: {
1012
+ command: `${playwrightCommand} (after stability wait)`,
1013
+ status: CommandRunStatus.SUCCESS
1014
+ },
1015
+ status: CommandRunStatus.SUCCESS
1016
+ };
1017
+ } catch (stabilityError: any) {
1018
+ // Stability wait didn't help
1019
+ }
1020
+ }
1021
+
1022
+ // No heuristics worked - return failure
1023
+ return {
1024
+ failedAttempts: [{
1025
+ command: selectorDesc,
1026
+ status: CommandRunStatus.FAILURE,
1027
+ error: errorMsg
1028
+ }],
1029
+ status: CommandRunStatus.FAILURE
1030
+ };
1031
+ }
1032
+
1033
+ /**
1034
+ * Build Playwright locator from typed selector (supports chaining)
1035
+ */
1036
+ private buildLocatorFromTypedSelector(typedSelector: TypedSelector): any {
1037
+ // Get base locator (page or parent)
1038
+ const base = typedSelector.parent
1039
+ ? this.buildLocatorFromTypedSelector(typedSelector.parent)
1040
+ : this.page;
1041
+
1042
+ // Build locator from base
1043
+ switch (typedSelector.type) {
1044
+ case 'id':
1045
+ return base.locator(`#${typedSelector.value}`);
1046
+ case 'testId':
1047
+ return base.getByTestId(typedSelector.value);
1048
+ case 'label':
1049
+ return base.getByLabel(typedSelector.value);
1050
+ case 'role':
1051
+ return base.getByRole(typedSelector.value, typedSelector.roleOptions);
1052
+ case 'placeholder':
1053
+ return base.getByPlaceholder(typedSelector.value);
1054
+ case 'text':
1055
+ return base.getByText(typedSelector.value);
1056
+ case 'title':
1057
+ return base.getByTitle(typedSelector.value);
1058
+ case 'altText':
1059
+ return base.getByAltText(typedSelector.value);
1060
+ case 'name':
1061
+ return base.locator(`[name="${typedSelector.value}"]`);
1062
+ case 'locator':
1063
+ // Generic locator - supports CSS, text=, has-text=, chaining, etc.
1064
+ return base.locator(typedSelector.value);
1065
+ default:
1066
+ throw new Error(`Unknown selector type: ${typedSelector.type}`);
1067
+ }
1068
+ }
1069
+
1070
+ /**
1071
+ * Format typed selector for logging (supports chaining)
1072
+ */
1073
+ private formatSelector(sel: TypedSelector): string {
1074
+ let formatted: string;
1075
+
1076
+ switch (sel.type) {
1077
+ case 'id': formatted = `#${sel.value}`; break;
1078
+ case 'testId': formatted = `getByTestId('${sel.value}')`; break;
1079
+ case 'label': formatted = `getByLabel('${sel.value}')`; break;
1080
+ case 'role': formatted = `getByRole('${sel.value}', {name: '${sel.roleOptions?.name}'})`; break;
1081
+ case 'placeholder': formatted = `getByPlaceholder('${sel.value}')`; break;
1082
+ case 'text': formatted = `getByText('${sel.value}')`; break;
1083
+ case 'title': formatted = `getByTitle('${sel.value}')`; break;
1084
+ case 'altText': formatted = `getByAltText('${sel.value}')`; break;
1085
+ case 'name': formatted = `[name="${sel.value}"]`; break;
1086
+ case 'locator': formatted = sel.value; break;
1087
+ default: formatted = String(sel.value);
1088
+ }
1089
+
1090
+ // Add parent chain if exists
1091
+ if (sel.parent) {
1092
+ return `${this.formatSelector(sel.parent)}.locator(${formatted})`;
1093
+ }
1094
+
1095
+ return formatted;
1096
+ }
1097
+
1098
+ /**
1099
+ * Execute action on locator based on command type
1100
+ */
1101
+ private async executeActionOnLocator(locator: any, command: SomCommand, selectorDesc?: string): Promise<string> {
1102
+ const { action, value, force, scrollAmount } = command;
1103
+ const selector = selectorDesc || 'locator';
1104
+
1105
+ switch (action) {
1106
+ case InteractionAction.CLICK:
1107
+ await locator.click({ force });
1108
+ return `await ${selector}.click()`;
1109
+
1110
+ case InteractionAction.DOUBLE_CLICK:
1111
+ await locator.dblclick({ force });
1112
+ return `await ${selector}.dblclick()`;
1113
+
1114
+ case InteractionAction.RIGHT_CLICK:
1115
+ await locator.click({ button: 'right', force });
1116
+ return `await ${selector}.click({ button: 'right' })`;
1117
+
1118
+ case InteractionAction.FILL:
1119
+ await locator.fill(value || '', { force });
1120
+ return `await ${selector}.fill('${value}')`;
1121
+
1122
+ case InteractionAction.TYPE:
1123
+ await locator.pressSequentially(value || '', { delay: command.delay || 50 });
1124
+ return `await ${selector}.pressSequentially('${value}')`;
1125
+
1126
+ case InteractionAction.CLEAR:
1127
+ await locator.clear({ force });
1128
+ return `await ${selector}.clear()`;
1129
+
1130
+ case InteractionAction.PRESS:
1131
+ await locator.press(value || 'Enter');
1132
+ return `await ${selector}.press('${value}')`;
1133
+
1134
+ case InteractionAction.SELECT:
1135
+ await locator.selectOption(value || '');
1136
+ return `await ${selector}.selectOption('${value}')`;
1137
+
1138
+ case InteractionAction.CHECK:
1139
+ await locator.check({ force });
1140
+ return `await ${selector}.check()`;
1141
+
1142
+ case InteractionAction.UNCHECK:
1143
+ await locator.uncheck({ force });
1144
+ return `await ${selector}.uncheck()`;
1145
+
1146
+ case InteractionAction.HOVER:
1147
+ await locator.hover({ force });
1148
+ return `await ${selector}.hover()`;
1149
+
1150
+ case InteractionAction.FOCUS:
1151
+ await locator.focus();
1152
+ return `await ${selector}.focus()`;
1153
+
1154
+ case InteractionAction.BLUR:
1155
+ await locator.blur();
1156
+ return `await ${selector}.blur()`;
1157
+
1158
+ case InteractionAction.SCROLL_INTO_VIEW:
1159
+ await locator.scrollIntoViewIfNeeded();
1160
+ return `await ${selector}.scrollIntoViewIfNeeded()`;
1161
+
1162
+ case InteractionAction.SCROLL:
1163
+ if (scrollAmount) {
1164
+ await locator.evaluate((el: any, amount: number) => {
1165
+ el.scrollBy(0, amount);
1166
+ }, scrollAmount);
1167
+ return `await ${selector}.evaluate(el => el.scrollBy(0, ${scrollAmount}))`;
1168
+ }
1169
+ break;
1170
+
1171
+ case InteractionAction.DRAG:
1172
+ if (command.toCoord) {
1173
+ await locator.dragTo(this.page.locator('body'), {
1174
+ targetPosition: { x: command.toCoord.x, y: command.toCoord.y }
1175
+ });
1176
+ return `await ${selector}.dragTo(target)`;
1177
+ }
1178
+ break;
1179
+
1180
+ default:
1181
+ throw new Error(`Unsupported action: ${action}`);
1182
+ }
1183
+
1184
+ return `await ${selector}.${action}()`;
1185
+ }
1186
+
1187
+ /**
1188
+ * Execute coordinate-based action as fallback
1189
+ */
1190
+ /**
1191
+ * Draw a visual marker at coordinate position (for debugging in headed mode)
1192
+ * Marker persists through screenshots so agent can see where coords landed
1193
+ */
1194
+ private async drawCoordinateMarker(pixelX: number, pixelY: number): Promise<void> {
1195
+ await this.page.evaluate(({ x, y }: { x: number; y: number }) => {
1196
+ // Remove any existing coordinate marker and labels
1197
+ const existing = document.getElementById('tc-coord-marker');
1198
+ if (existing) existing.remove();
1199
+
1200
+ const existingLabels = Array.from(document.querySelectorAll('div')).filter(
1201
+ el => el.textContent === 'clicked' && el.style.position === 'fixed'
1202
+ );
1203
+ existingLabels.forEach(label => label.remove());
1204
+
1205
+ // Create marker element
1206
+ const marker = document.createElement('div');
1207
+ marker.id = 'tc-coord-marker';
1208
+ marker.style.cssText = `
1209
+ position: fixed;
1210
+ left: ${x}px;
1211
+ top: ${y}px;
1212
+ width: 24px;
1213
+ height: 24px;
1214
+ margin-left: -12px;
1215
+ margin-top: -12px;
1216
+ border-radius: 50%;
1217
+ background: rgba(255, 0, 255, 0.8);
1218
+ border: 3px solid rgba(255, 255, 0, 0.95);
1219
+ z-index: 2147483647;
1220
+ pointer-events: none;
1221
+ box-shadow: 0 0 10px rgba(255, 0, 255, 0.6);
1222
+ animation: tc-pulse 0.5s ease-out;
1223
+ `;
1224
+
1225
+ // Add label showing this was a coord click
1226
+ const label = document.createElement('div');
1227
+ label.style.cssText = `
1228
+ position: fixed;
1229
+ left: ${x + 15}px;
1230
+ top: ${y - 20}px;
1231
+ background: rgba(255, 0, 255, 0.9);
1232
+ color: white;
1233
+ padding: 2px 6px;
1234
+ border-radius: 3px;
1235
+ font-size: 11px;
1236
+ font-weight: bold;
1237
+ font-family: Arial;
1238
+ z-index: 2147483647;
1239
+ pointer-events: none;
1240
+ white-space: nowrap;
1241
+ `;
1242
+ label.textContent = 'clicked';
1243
+
1244
+ // Add pulsing animation
1245
+ const style = document.createElement('style');
1246
+ style.textContent = `
1247
+ @keyframes tc-pulse {
1248
+ 0% { transform: scale(0); opacity: 0; }
1249
+ 50% { transform: scale(1.5); opacity: 1; }
1250
+ 100% { transform: scale(1); opacity: 1; }
1251
+ }
1252
+ `;
1253
+ document.head.appendChild(style);
1254
+ document.body.appendChild(marker);
1255
+ document.body.appendChild(label);
1256
+ }, { x: pixelX, y: pixelY });
1257
+
1258
+ // Brief pause for animation to complete, then marker stays visible
1259
+ await this.page.waitForTimeout(300);
1260
+ }
1261
+
1262
+ /**
1263
+ * Remove coordinate marker from page (used when cleaning up or replacing)
1264
+ */
1265
+ private async removeCoordinateMarker(): Promise<void> {
1266
+ await this.page.evaluate(() => {
1267
+ // Remove marker circle
1268
+ const marker = document.getElementById('tc-coord-marker');
1269
+ if (marker) marker.remove();
1270
+
1271
+ // Remove all "clicked" labels (in case there are multiple)
1272
+ const labels = Array.from(document.querySelectorAll('div')).filter(
1273
+ el => el.textContent === 'clicked' && el.style.position === 'fixed' && el.style.background?.includes('255, 0, 255')
1274
+ );
1275
+ labels.forEach(label => label.remove());
1276
+ });
1277
+ }
1278
+
1279
+ /**
1280
+ * Execute action using percentage-based coordinates
1281
+ */
1282
+ private async executePercentageCoordinateAction(
1283
+ command: SomCommand
1284
+ ): Promise<SemanticCommandResult> {
1285
+ if (!command.coord) {
1286
+ throw new Error('Coordinate is required for percentage-based commands');
1287
+ }
1288
+
1289
+ // Get viewport dimensions
1290
+ const viewport = this.page.viewportSize();
1291
+ if (!viewport) {
1292
+ throw new Error('Could not determine viewport size');
1293
+ }
1294
+
1295
+ // Convert percentage to pixels
1296
+ const pixelX = Math.round((command.coord.x / 100) * viewport.width);
1297
+ const pixelY = Math.round((command.coord.y / 100) * viewport.height);
1298
+
1299
+ this.logger?.(`[PageSoMHandler] Using percentage coords: (${command.coord.x}%, ${command.coord.y}%) -> pixels (${pixelX}, ${pixelY})`, 'log');
1300
+
1301
+ // Draw visual marker at target position (for headed mode debugging)
1302
+ await this.drawCoordinateMarker(pixelX, pixelY);
1303
+
1304
+ const { action, value } = command;
1305
+ let playwrightCommand = '';
1306
+
1307
+ try {
1308
+ switch (action) {
1309
+ case InteractionAction.CLICK:
1310
+ case InteractionAction.DOUBLE_CLICK:
1311
+ case InteractionAction.RIGHT_CLICK:
1312
+ const clickCount = action === InteractionAction.DOUBLE_CLICK ? 2 : 1;
1313
+ const button = action === InteractionAction.RIGHT_CLICK ? 'right' :
1314
+ command.button || 'left';
1315
+ await this.page.mouse.click(pixelX, pixelY, {
1316
+ button,
1317
+ clickCount,
1318
+ delay: command.delay
1319
+ });
1320
+ playwrightCommand = `await page.mouse.click(${pixelX}, ${pixelY}${button !== 'left' ? `, { button: '${button}' }` : ''})`;
1321
+ break;
1322
+
1323
+ case InteractionAction.FILL:
1324
+ case InteractionAction.TYPE:
1325
+ // Click to focus, then type
1326
+ await this.page.mouse.click(pixelX, pixelY);
1327
+ await this.page.waitForTimeout(100); // Brief wait for focus
1328
+ if (action === InteractionAction.FILL) {
1329
+ await this.page.keyboard.type(value || '');
1330
+ } else {
1331
+ await this.page.keyboard.type(value || '', { delay: command.delay || 50 });
1332
+ }
1333
+ playwrightCommand = `await page.mouse.click(${pixelX}, ${pixelY}); await page.keyboard.type('${value}')`;
1334
+ break;
1335
+
1336
+ case InteractionAction.HOVER:
1337
+ await this.page.mouse.move(pixelX, pixelY);
1338
+ playwrightCommand = `await page.mouse.move(${pixelX}, ${pixelY})`;
1339
+ break;
1340
+
1341
+ case InteractionAction.DRAG:
1342
+ if (!command.toCoord) {
1343
+ throw new Error('toCoord is required for drag action');
1344
+ }
1345
+ const toPixelX = Math.round((command.toCoord.x / 100) * viewport.width);
1346
+ const toPixelY = Math.round((command.toCoord.y / 100) * viewport.height);
1347
+
1348
+ await this.page.mouse.move(pixelX, pixelY);
1349
+ await this.page.mouse.down();
1350
+ await this.page.mouse.move(toPixelX, toPixelY);
1351
+ await this.page.mouse.up();
1352
+ playwrightCommand = `await page.mouse.move(${pixelX}, ${pixelY}); await page.mouse.down(); await page.mouse.move(${toPixelX}, ${toPixelY}); await page.mouse.up()`;
1353
+ break;
1354
+
1355
+ default:
1356
+ throw new Error(`Coordinate-based execution not supported for action: ${action}`);
1357
+ }
1358
+
1359
+ return {
1360
+ failedAttempts: [],
1361
+ successAttempt: {
1362
+ command: playwrightCommand,
1363
+ status: CommandRunStatus.SUCCESS
1364
+ },
1365
+ status: CommandRunStatus.SUCCESS
1366
+ };
1367
+
1368
+ } catch (error: any) {
1369
+ throw new Error(`Failed to execute percentage coordinate action: ${error.message}`);
1370
+ }
1371
+ }
1372
+
1373
+ private async executeCoordinateAction(
1374
+ command: SomCommand,
1375
+ element: SomElement
1376
+ ): Promise<SemanticCommandResult> {
1377
+ try {
1378
+ // Calculate center of bounding box
1379
+ const centerX = element.bbox.x + element.bbox.width / 2;
1380
+ const centerY = element.bbox.y + element.bbox.height / 2;
1381
+
1382
+ this.logger?.(`[PageSoMHandler] Using coordinates (${centerX}, ${centerY})`, 'log');
1383
+
1384
+ const { action, value } = command;
1385
+ let playwrightCommand = '';
1386
+
1387
+ switch (action) {
1388
+ case InteractionAction.CLICK:
1389
+ case InteractionAction.DOUBLE_CLICK:
1390
+ case InteractionAction.RIGHT_CLICK:
1391
+ const clickCount = action === InteractionAction.DOUBLE_CLICK ? 2 : 1;
1392
+ const button = action === InteractionAction.RIGHT_CLICK ? 'right' : 'left';
1393
+ await this.page.mouse.click(centerX, centerY, { button, clickCount });
1394
+ playwrightCommand = `await page.mouse.click(${centerX}, ${centerY}${button !== 'left' ? `, { button: '${button}' }` : ''})`;
1395
+ break;
1396
+
1397
+ case InteractionAction.FILL:
1398
+ case InteractionAction.TYPE:
1399
+ // Click first, then type
1400
+ await this.page.mouse.click(centerX, centerY);
1401
+ if (action === InteractionAction.FILL) {
1402
+ await this.page.keyboard.type(value || '');
1403
+ } else {
1404
+ await this.page.keyboard.type(value || '', { delay: command.delay || 50 });
1405
+ }
1406
+ playwrightCommand = `await page.mouse.click(${centerX}, ${centerY}); await page.keyboard.type('${value}')`;
1407
+ break;
1408
+
1409
+ case InteractionAction.HOVER:
1410
+ await this.page.mouse.move(centerX, centerY);
1411
+ playwrightCommand = `await page.mouse.move(${centerX}, ${centerY})`;
1412
+ break;
1413
+
1414
+ default:
1415
+ throw new Error(`Coordinate fallback not supported for action: ${action}`);
1416
+ }
1417
+
1418
+ return {
1419
+ failedAttempts: [],
1420
+ successAttempt: {
1421
+ command: playwrightCommand,
1422
+ status: CommandRunStatus.SUCCESS
1423
+ },
1424
+ status: CommandRunStatus.SUCCESS
1425
+ };
1426
+
1427
+ } catch (error: any) {
1428
+ return {
1429
+ failedAttempts: [{
1430
+ command: `coordinate-based ${command.action}`,
1431
+ status: CommandRunStatus.FAILURE,
1432
+ error: error.message
1433
+ }],
1434
+ error: `Coordinate fallback failed: ${error.message}`,
1435
+ status: CommandRunStatus.FAILURE
1436
+ };
1437
+ }
1438
+ }
1439
+
1440
+ /**
1441
+ * Setup mutation observer to track DOM changes
1442
+ */
1443
+ private async setupMutationObserver(): Promise<void> {
1444
+ await this.page.evaluate(() => {
1445
+ const mutations: any[] = [];
1446
+
1447
+ const observer = new MutationObserver((mutationsList) => {
1448
+ for (const mutation of mutationsList) {
1449
+ if (mutation.type === 'childList') {
1450
+ mutation.addedNodes.forEach((node: any) => {
1451
+ if (node.nodeType === 1) { // Element node
1452
+ const el = node as HTMLElement;
1453
+ const styles = window.getComputedStyle(el);
1454
+
1455
+ // Only track visible additions
1456
+ if (styles.display !== 'none' && styles.visibility !== 'hidden') {
1457
+ mutations.push({
1458
+ type: 'added',
1459
+ elementDescription: `${el.tagName.toLowerCase()}${el.className ? '.' + el.className.split(' ')[0] : ''}`,
1460
+ timestamp: Date.now()
1461
+ });
1462
+ }
1463
+ }
1464
+ });
1465
+ }
1466
+ }
1467
+ });
1468
+
1469
+ observer.observe(document.body, {
1470
+ childList: true,
1471
+ subtree: true,
1472
+ attributes: true
1473
+ });
1474
+
1475
+ (window as any).__tcSomMutationObserver = observer;
1476
+ (window as any).__tcSomMutations = mutations;
1477
+ });
1478
+ }
1479
+
1480
+ /**
1481
+ * Filter relevant mutations (exclude mass updates)
1482
+ */
1483
+ private async filterRelevantMutations(): Promise<DomMutation[]> {
1484
+ // Get mutations from page
1485
+ const mutations = await this.page.evaluate(() => {
1486
+ return (window as any).__tcSomMutations || [];
1487
+ });
1488
+
1489
+ // Filter: If too many mutations (>10), likely page rebuild - don't report
1490
+ if (mutations.length > 10) {
1491
+ this.logger?.(`[PageSoMHandler] ${mutations.length} mutations detected - too many, filtering out`, 'warn');
1492
+ return [];
1493
+ }
1494
+
1495
+ // Return relevant mutations (tooltips, notices, message boxes)
1496
+ return mutations;
1497
+ }
1498
+
1499
+ /**
1500
+ * Escape selector text
1501
+ */
1502
+ private escapeSelector(text: string): string {
1503
+ return text.replace(/'/g, "\\'").trim().substring(0, 50);
1504
+ }
1505
+
1506
+ /**
1507
+ * Try refined selector with parent scoping
1508
+ */
1509
+ private async tryRefinedSelector(
1510
+ typedSelector: TypedSelector,
1511
+ command: SomCommand,
1512
+ element: SomElement,
1513
+ originalError: Error
1514
+ ): Promise<SemanticCommandResult> {
1515
+ // Try parent scoping for generic locators (but avoid if already scoped)
1516
+ if (typedSelector.type === 'locator' && element.parent?.className) {
1517
+ const parentClass = element.parent.className.split(' ')[0];
1518
+
1519
+ // Guard: Don't add parent if selector already includes it (prevent infinite recursion)
1520
+ if (parentClass && !typedSelector.value.includes(parentClass)) {
1521
+ const refinedSelector: TypedSelector = {
1522
+ type: 'locator',
1523
+ value: `.${parentClass} ${typedSelector.value}`
1524
+ };
1525
+ const selectorDesc = this.formatSelector(refinedSelector);
1526
+ this.logger?.(`[PageSoMHandler] Trying refined selector with parent: ${selectorDesc}`, 'log');
1527
+
1528
+ // Important: Don't call tryExecuteAction (would recurse), execute directly
1529
+ try {
1530
+ const locator = this.buildLocatorFromTypedSelector(refinedSelector);
1531
+ const refinedDesc = this.formatSelector(refinedSelector);
1532
+ const playwrightCommand = await this.executeActionOnLocator(locator, command, refinedDesc);
1533
+ return {
1534
+ failedAttempts: [{
1535
+ command: this.formatSelector(typedSelector),
1536
+ status: CommandRunStatus.FAILURE,
1537
+ error: originalError.message
1538
+ }],
1539
+ successAttempt: {
1540
+ command: playwrightCommand,
1541
+ status: CommandRunStatus.SUCCESS
1542
+ },
1543
+ status: CommandRunStatus.SUCCESS
1544
+ };
1545
+ } catch (refinedError: any) {
1546
+ // Parent scoping didn't help, fall through to failure
1547
+ this.logger?.(`[PageSoMHandler] Refined selector also failed: ${refinedError.message}`, 'warn');
1548
+ }
1549
+ }
1550
+ }
1551
+
1552
+ // Could implement nth() based on bbox position here
1553
+
1554
+ const selectorDesc = this.formatSelector(typedSelector);
1555
+ return {
1556
+ failedAttempts: [{
1557
+ command: selectorDesc,
1558
+ status: CommandRunStatus.FAILURE,
1559
+ error: originalError.message
1560
+ }],
1561
+ status: CommandRunStatus.FAILURE
1562
+ };
1563
+ }
1564
+ }
1565
+