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