chrometools-mcp 2.5.0 → 3.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +420 -0
- package/COMPONENT_MAPPING_SPEC.md +1217 -0
- package/README.md +406 -38
- package/bridge/bridge-client.js +472 -0
- package/bridge/bridge-service.js +399 -0
- package/bridge/install.js +241 -0
- package/browser/browser-manager.js +107 -2
- package/browser/page-manager.js +226 -69
- package/docs/CHROME_EXTENSION.md +219 -0
- package/docs/PAGE_OBJECT_MODEL_CONCEPT.md +1756 -0
- package/extension/background.js +643 -0
- package/extension/content.js +715 -0
- package/extension/icons/create-icons.js +164 -0
- package/extension/icons/icon128.png +0 -0
- package/extension/icons/icon16.png +0 -0
- package/extension/icons/icon48.png +0 -0
- package/extension/manifest.json +58 -0
- package/extension/popup/popup.css +437 -0
- package/extension/popup/popup.html +102 -0
- package/extension/popup/popup.js +415 -0
- package/extension/recorder-overlay.css +93 -0
- package/index.js +3347 -2901
- package/models/BaseInputModel.js +93 -0
- package/models/CheckboxGroupModel.js +199 -0
- package/models/CheckboxModel.js +103 -0
- package/models/ColorInputModel.js +53 -0
- package/models/DateInputModel.js +67 -0
- package/models/RadioGroupModel.js +126 -0
- package/models/RangeInputModel.js +60 -0
- package/models/SelectModel.js +97 -0
- package/models/TextInputModel.js +34 -0
- package/models/TextareaModel.js +59 -0
- package/models/TimeInputModel.js +49 -0
- package/models/index.js +122 -0
- package/package.json +3 -2
- package/pom/apom-converter.js +267 -0
- package/pom/apom-tree-converter.js +515 -0
- package/pom/element-id-generator.js +175 -0
- package/recorder/page-object-generator.js +16 -0
- package/recorder/scenario-executor.js +80 -2
- package/server/tool-definitions.js +839 -713
- package/server/tool-groups.js +1 -1
- package/server/tool-schemas.js +367 -326
- package/server/websocket-bridge.js +447 -0
- package/utils/selector-resolver.js +186 -0
- package/utils/ui-framework-detector.js +392 -0
- package/RELEASE_NOTES_v2.5.0.md +0 -109
- package/npm_publish_output.txt +0 -0
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pom/apom-tree-converter.js
|
|
3
|
+
*
|
|
4
|
+
* Converts DOM to APOM Tree format with positioning information
|
|
5
|
+
* APOM v2: Tree-based structure with parent-child relationships
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build DOM tree starting from root element
|
|
10
|
+
* Runs in browser context via page.evaluate()
|
|
11
|
+
*
|
|
12
|
+
* @param {boolean} interactiveOnly - Only include interactive elements and their parents
|
|
13
|
+
* @returns {Object} APOM tree structure
|
|
14
|
+
*/
|
|
15
|
+
function buildAPOMTree(interactiveOnly = true) {
|
|
16
|
+
const pageId = `page_${btoa(window.location.href).replace(/[^a-zA-Z0-9]/g, '').substring(0, 20)}_${Date.now()}`;
|
|
17
|
+
|
|
18
|
+
const result = {
|
|
19
|
+
pageId,
|
|
20
|
+
url: window.location.href,
|
|
21
|
+
title: document.title,
|
|
22
|
+
timestamp: Date.now(),
|
|
23
|
+
tree: null,
|
|
24
|
+
metadata: {
|
|
25
|
+
totalElements: 0,
|
|
26
|
+
interactiveCount: 0,
|
|
27
|
+
formCount: 0,
|
|
28
|
+
modalCount: 0,
|
|
29
|
+
maxDepth: 0
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Element ID counter
|
|
34
|
+
let idCounter = 0;
|
|
35
|
+
const elementIds = new WeakMap();
|
|
36
|
+
const interactiveElements = new WeakSet();
|
|
37
|
+
|
|
38
|
+
// First pass: mark all interactive elements
|
|
39
|
+
if (interactiveOnly) {
|
|
40
|
+
markInteractiveElements(document.body);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Build tree from body
|
|
44
|
+
result.tree = buildNode(document.body, null, 0, []);
|
|
45
|
+
|
|
46
|
+
// Collect radio and checkbox groups for easier agent access
|
|
47
|
+
result.groups = collectInputGroups(result.tree);
|
|
48
|
+
|
|
49
|
+
return result;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Collect radio and checkbox groups from the tree
|
|
53
|
+
*/
|
|
54
|
+
function collectInputGroups(tree) {
|
|
55
|
+
const radioGroups = {};
|
|
56
|
+
const checkboxGroups = {};
|
|
57
|
+
|
|
58
|
+
function traverse(node) {
|
|
59
|
+
if (!node) return;
|
|
60
|
+
|
|
61
|
+
// Check if this is a radio or checkbox input
|
|
62
|
+
if (node.type === 'input' && node.metadata) {
|
|
63
|
+
const { inputType, name, value, label, checked } = node.metadata;
|
|
64
|
+
|
|
65
|
+
if (inputType === 'radio' && name) {
|
|
66
|
+
if (!radioGroups[name]) {
|
|
67
|
+
radioGroups[name] = { type: 'radio', options: [] };
|
|
68
|
+
}
|
|
69
|
+
radioGroups[name].options.push({
|
|
70
|
+
id: node.id,
|
|
71
|
+
value: value || '',
|
|
72
|
+
label: label || value || '',
|
|
73
|
+
checked: checked || false
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (inputType === 'checkbox' && name) {
|
|
78
|
+
if (!checkboxGroups[name]) {
|
|
79
|
+
checkboxGroups[name] = { type: 'checkbox', options: [] };
|
|
80
|
+
}
|
|
81
|
+
checkboxGroups[name].options.push({
|
|
82
|
+
id: node.id,
|
|
83
|
+
value: value || '',
|
|
84
|
+
label: label || value || '',
|
|
85
|
+
checked: checked || false
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Traverse children
|
|
91
|
+
if (node.children) {
|
|
92
|
+
node.children.forEach(child => traverse(child));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
traverse(tree);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
radio: Object.keys(radioGroups).length > 0 ? radioGroups : undefined,
|
|
100
|
+
checkbox: Object.keys(checkboxGroups).length > 0 ? checkboxGroups : undefined
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Mark interactive elements and their ancestors
|
|
106
|
+
*/
|
|
107
|
+
function markInteractiveElements(root) {
|
|
108
|
+
const interactiveTags = new Set([
|
|
109
|
+
'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL', 'FORM'
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
const interactiveRoles = new Set([
|
|
113
|
+
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'listbox',
|
|
114
|
+
'menuitem', 'tab', 'switch', 'slider', 'searchbox'
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
// Find all interactive elements
|
|
118
|
+
const elements = root.querySelectorAll('*');
|
|
119
|
+
const interactiveList = [];
|
|
120
|
+
|
|
121
|
+
elements.forEach(el => {
|
|
122
|
+
const isInteractive =
|
|
123
|
+
interactiveTags.has(el.tagName) ||
|
|
124
|
+
interactiveRoles.has(el.getAttribute('role')) ||
|
|
125
|
+
el.hasAttribute('onclick') ||
|
|
126
|
+
el.hasAttribute('tabindex') && el.getAttribute('tabindex') !== '-1' ||
|
|
127
|
+
(el.tagName === 'DIV' && el.getAttribute('contenteditable') === 'true');
|
|
128
|
+
|
|
129
|
+
if (isInteractive && isVisible(el)) {
|
|
130
|
+
interactiveList.push(el);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Mark interactive elements and all their ancestors
|
|
135
|
+
interactiveList.forEach(el => {
|
|
136
|
+
let current = el;
|
|
137
|
+
while (current && current !== document.body) {
|
|
138
|
+
interactiveElements.add(current);
|
|
139
|
+
current = current.parentElement;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// Always include body
|
|
144
|
+
interactiveElements.add(document.body);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check if element is visible
|
|
149
|
+
*/
|
|
150
|
+
function isVisible(el) {
|
|
151
|
+
if (!el.offsetParent && el !== document.body) return false;
|
|
152
|
+
const style = window.getComputedStyle(el);
|
|
153
|
+
return style.display !== 'none' &&
|
|
154
|
+
style.visibility !== 'hidden' &&
|
|
155
|
+
style.opacity !== '0';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Build node recursively
|
|
160
|
+
*/
|
|
161
|
+
function buildNode(element, parentId, depth, path) {
|
|
162
|
+
// Skip if in interactive-only mode and element is not marked
|
|
163
|
+
if (interactiveOnly && !interactiveElements.has(element)) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Skip hidden elements
|
|
168
|
+
if (!isVisible(element)) {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Generate unique ID
|
|
173
|
+
const id = generateElementId(element);
|
|
174
|
+
elementIds.set(element, id);
|
|
175
|
+
|
|
176
|
+
const currentPath = [...path, id];
|
|
177
|
+
|
|
178
|
+
// Get positioning info
|
|
179
|
+
const position = getPositionInfo(element);
|
|
180
|
+
|
|
181
|
+
// Determine element type
|
|
182
|
+
const elementType = determineElementType(element);
|
|
183
|
+
|
|
184
|
+
// Build node - minimize non-interactive parents
|
|
185
|
+
const isInteractive = elementType.isInteractive;
|
|
186
|
+
|
|
187
|
+
const node = {
|
|
188
|
+
id,
|
|
189
|
+
tag: element.tagName.toLowerCase(),
|
|
190
|
+
selector: generateSelector(element),
|
|
191
|
+
position,
|
|
192
|
+
children: []
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Add full info only for interactive elements
|
|
196
|
+
if (isInteractive) {
|
|
197
|
+
node.type = elementType.type;
|
|
198
|
+
node.bounds = getBounds(element);
|
|
199
|
+
|
|
200
|
+
// Add metadata based on element type
|
|
201
|
+
if (elementType.metadata) {
|
|
202
|
+
node.metadata = elementType.metadata;
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
// For containers (parents), keep it minimal
|
|
206
|
+
node.type = elementType.type;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Update metadata counters
|
|
210
|
+
result.metadata.totalElements++;
|
|
211
|
+
if (elementType.isInteractive) {
|
|
212
|
+
result.metadata.interactiveCount++;
|
|
213
|
+
}
|
|
214
|
+
if (elementType.type === 'form') {
|
|
215
|
+
result.metadata.formCount++;
|
|
216
|
+
}
|
|
217
|
+
if (position.type === 'fixed' || position.type === 'absolute') {
|
|
218
|
+
if (position.zIndex >= 100) {
|
|
219
|
+
result.metadata.modalCount++;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (depth > result.metadata.maxDepth) {
|
|
223
|
+
result.metadata.maxDepth = depth;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Process children
|
|
227
|
+
for (const child of element.children) {
|
|
228
|
+
const childNode = buildNode(child, id, depth + 1, currentPath);
|
|
229
|
+
if (childNode) {
|
|
230
|
+
node.children.push(childNode);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return node;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Generate element ID
|
|
239
|
+
*/
|
|
240
|
+
function generateElementId(element) {
|
|
241
|
+
const type = determineElementType(element).type;
|
|
242
|
+
const index = idCounter++;
|
|
243
|
+
return `${type}_${index}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Get positioning information
|
|
248
|
+
*/
|
|
249
|
+
function getPositionInfo(element) {
|
|
250
|
+
const style = window.getComputedStyle(element);
|
|
251
|
+
const position = style.position;
|
|
252
|
+
const zIndex = style.zIndex === 'auto' ? 'auto' : parseInt(style.zIndex, 10);
|
|
253
|
+
|
|
254
|
+
// Check if creates stacking context
|
|
255
|
+
const isStacking =
|
|
256
|
+
position === 'fixed' ||
|
|
257
|
+
position === 'sticky' ||
|
|
258
|
+
(position === 'absolute' && zIndex !== 'auto') ||
|
|
259
|
+
(position === 'relative' && zIndex !== 'auto') ||
|
|
260
|
+
parseFloat(style.opacity) < 1 ||
|
|
261
|
+
style.transform !== 'none' ||
|
|
262
|
+
style.filter !== 'none' ||
|
|
263
|
+
style.perspective !== 'none' ||
|
|
264
|
+
style.clipPath !== 'none' ||
|
|
265
|
+
style.mask !== 'none' ||
|
|
266
|
+
style.mixBlendMode !== 'normal' ||
|
|
267
|
+
style.isolation === 'isolate';
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
type: position,
|
|
271
|
+
zIndex: zIndex,
|
|
272
|
+
isStacking: isStacking,
|
|
273
|
+
// Additional positioning properties for modals/overlays detection
|
|
274
|
+
hasBackdrop: style.backgroundColor !== 'rgba(0, 0, 0, 0)' &&
|
|
275
|
+
(position === 'fixed' || position === 'absolute'),
|
|
276
|
+
isFullscreen: element.offsetWidth >= window.innerWidth * 0.9 &&
|
|
277
|
+
element.offsetHeight >= window.innerHeight * 0.9
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Get element bounds
|
|
283
|
+
*/
|
|
284
|
+
function getBounds(element) {
|
|
285
|
+
const rect = element.getBoundingClientRect();
|
|
286
|
+
return {
|
|
287
|
+
x: Math.round(rect.left),
|
|
288
|
+
y: Math.round(rect.top),
|
|
289
|
+
width: Math.round(rect.width),
|
|
290
|
+
height: Math.round(rect.height)
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Determine element type and metadata
|
|
296
|
+
*/
|
|
297
|
+
function determineElementType(element) {
|
|
298
|
+
const tag = element.tagName.toLowerCase();
|
|
299
|
+
const type = element.type?.toLowerCase();
|
|
300
|
+
const role = element.getAttribute('role');
|
|
301
|
+
|
|
302
|
+
// Form
|
|
303
|
+
if (tag === 'form') {
|
|
304
|
+
return {
|
|
305
|
+
type: 'form',
|
|
306
|
+
isInteractive: true,
|
|
307
|
+
metadata: {
|
|
308
|
+
method: element.method?.toUpperCase() || 'GET',
|
|
309
|
+
action: element.action || '',
|
|
310
|
+
name: element.name || null
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Input fields
|
|
316
|
+
if (tag === 'input') {
|
|
317
|
+
const inputType = type || 'text';
|
|
318
|
+
|
|
319
|
+
// Get label text for radio/checkbox inputs
|
|
320
|
+
let labelText = null;
|
|
321
|
+
if (inputType === 'radio' || inputType === 'checkbox') {
|
|
322
|
+
// Try to find label by: 1) wrapping label, 2) label[for=id], 3) aria-label
|
|
323
|
+
const parentLabel = element.closest('label');
|
|
324
|
+
if (parentLabel) {
|
|
325
|
+
// Get text content excluding the input itself
|
|
326
|
+
labelText = parentLabel.textContent?.trim() || null;
|
|
327
|
+
} else if (element.id) {
|
|
328
|
+
const labelFor = document.querySelector(`label[for="${element.id}"]`);
|
|
329
|
+
if (labelFor) {
|
|
330
|
+
labelText = labelFor.textContent?.trim() || null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (!labelText) {
|
|
334
|
+
labelText = element.getAttribute('aria-label') || null;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
type: inputType === 'submit' || inputType === 'button' ? 'button' : 'input',
|
|
340
|
+
isInteractive: true,
|
|
341
|
+
metadata: {
|
|
342
|
+
inputType,
|
|
343
|
+
name: element.name || null,
|
|
344
|
+
placeholder: element.placeholder || null,
|
|
345
|
+
required: element.required || false,
|
|
346
|
+
disabled: element.disabled || false,
|
|
347
|
+
value: element.value || '',
|
|
348
|
+
checked: element.checked || undefined,
|
|
349
|
+
label: labelText,
|
|
350
|
+
min: element.min || undefined,
|
|
351
|
+
max: element.max || undefined,
|
|
352
|
+
pattern: element.pattern || undefined
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Textarea
|
|
358
|
+
if (tag === 'textarea') {
|
|
359
|
+
return {
|
|
360
|
+
type: 'textarea',
|
|
361
|
+
isInteractive: true,
|
|
362
|
+
metadata: {
|
|
363
|
+
name: element.name || null,
|
|
364
|
+
placeholder: element.placeholder || null,
|
|
365
|
+
required: element.required || false,
|
|
366
|
+
disabled: element.disabled || false,
|
|
367
|
+
rows: element.rows || undefined,
|
|
368
|
+
cols: element.cols || undefined,
|
|
369
|
+
maxLength: element.maxLength > 0 ? element.maxLength : undefined
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Select
|
|
375
|
+
if (tag === 'select') {
|
|
376
|
+
const options = Array.from(element.options).map(opt => ({
|
|
377
|
+
value: opt.value,
|
|
378
|
+
text: opt.textContent.trim(),
|
|
379
|
+
selected: opt.selected
|
|
380
|
+
}));
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
type: 'select',
|
|
384
|
+
isInteractive: true,
|
|
385
|
+
metadata: {
|
|
386
|
+
name: element.name || null,
|
|
387
|
+
required: element.required || false,
|
|
388
|
+
disabled: element.disabled || false,
|
|
389
|
+
multiple: element.multiple || false,
|
|
390
|
+
size: element.size || undefined,
|
|
391
|
+
options,
|
|
392
|
+
selectedIndex: element.selectedIndex,
|
|
393
|
+
selectedValue: element.value || null
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Button
|
|
399
|
+
if (tag === 'button' || role === 'button') {
|
|
400
|
+
return {
|
|
401
|
+
type: 'button',
|
|
402
|
+
isInteractive: true,
|
|
403
|
+
metadata: {
|
|
404
|
+
buttonType: type || 'button',
|
|
405
|
+
text: element.textContent?.trim() || '',
|
|
406
|
+
disabled: element.disabled || false,
|
|
407
|
+
ariaLabel: element.getAttribute('aria-label') || null
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Link
|
|
413
|
+
if (tag === 'a') {
|
|
414
|
+
return {
|
|
415
|
+
type: 'link',
|
|
416
|
+
isInteractive: true,
|
|
417
|
+
metadata: {
|
|
418
|
+
href: element.href || null,
|
|
419
|
+
text: element.textContent?.trim() || '',
|
|
420
|
+
target: element.target || null,
|
|
421
|
+
rel: element.rel || null
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Label
|
|
427
|
+
if (tag === 'label') {
|
|
428
|
+
return {
|
|
429
|
+
type: 'label',
|
|
430
|
+
isInteractive: false,
|
|
431
|
+
metadata: {
|
|
432
|
+
for: element.htmlFor || null,
|
|
433
|
+
text: element.textContent?.trim() || ''
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Modal/Dialog
|
|
439
|
+
if (role === 'dialog' || role === 'alertdialog' || element.hasAttribute('aria-modal')) {
|
|
440
|
+
return {
|
|
441
|
+
type: 'modal',
|
|
442
|
+
isInteractive: false,
|
|
443
|
+
metadata: {
|
|
444
|
+
ariaModal: element.getAttribute('aria-modal') === 'true',
|
|
445
|
+
ariaLabel: element.getAttribute('aria-label') || null,
|
|
446
|
+
role: role
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Container with semantic role
|
|
452
|
+
if (role) {
|
|
453
|
+
return {
|
|
454
|
+
type: role,
|
|
455
|
+
isInteractive: false,
|
|
456
|
+
metadata: {
|
|
457
|
+
ariaLabel: element.getAttribute('aria-label') || null
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Generic container
|
|
463
|
+
return {
|
|
464
|
+
type: 'container',
|
|
465
|
+
isInteractive: false,
|
|
466
|
+
metadata: null
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Generate unique CSS selector
|
|
472
|
+
*/
|
|
473
|
+
function generateSelector(element) {
|
|
474
|
+
// Use ID if available and unique
|
|
475
|
+
if (element.id && document.querySelectorAll(`#${element.id}`).length === 1) {
|
|
476
|
+
return `#${element.id}`;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Build path from parent
|
|
480
|
+
const path = [];
|
|
481
|
+
let current = element;
|
|
482
|
+
|
|
483
|
+
while (current && current !== document.body) {
|
|
484
|
+
let selector = current.tagName.toLowerCase();
|
|
485
|
+
|
|
486
|
+
// Add nth-of-type if needed
|
|
487
|
+
if (current.parentElement) {
|
|
488
|
+
const siblings = Array.from(current.parentElement.children).filter(
|
|
489
|
+
el => el.tagName === current.tagName
|
|
490
|
+
);
|
|
491
|
+
if (siblings.length > 1) {
|
|
492
|
+
const index = siblings.indexOf(current) + 1;
|
|
493
|
+
selector += `:nth-of-type(${index})`;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
path.unshift(selector);
|
|
498
|
+
current = current.parentElement;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return path.join(' > ');
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Export for use in both Node.js and browser context
|
|
506
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
507
|
+
module.exports = {
|
|
508
|
+
buildAPOMTree
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Make available globally in browser context
|
|
513
|
+
if (typeof window !== 'undefined') {
|
|
514
|
+
window.buildAPOMTree = buildAPOMTree;
|
|
515
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pom/element-id-generator.js
|
|
3
|
+
*
|
|
4
|
+
* Генерация уникальных ID для элементов в Page Object Model
|
|
5
|
+
* Используется для создания стабильных идентификаторов элементов
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate unique element ID based on priority:
|
|
10
|
+
* 1. data-testid attribute
|
|
11
|
+
* 2. id attribute
|
|
12
|
+
* 3. Semantic path (type + context + index)
|
|
13
|
+
*
|
|
14
|
+
* @param {Element} element - DOM element
|
|
15
|
+
* @param {string} type - Element type (input, button, select, etc.)
|
|
16
|
+
* @param {number} index - Element index among same type
|
|
17
|
+
* @returns {string} Unique element ID
|
|
18
|
+
*/
|
|
19
|
+
function generateElementId(element, type, index) {
|
|
20
|
+
// Priority 1: data-testid
|
|
21
|
+
const testId = element.getAttribute('data-testid') || element.getAttribute('data-test');
|
|
22
|
+
if (testId) {
|
|
23
|
+
return `testid_${sanitizeId(testId)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Priority 2: id attribute
|
|
27
|
+
if (element.id) {
|
|
28
|
+
return `id_${sanitizeId(element.id)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Priority 3: Semantic path
|
|
32
|
+
const semanticPath = getSemanticPath(element);
|
|
33
|
+
const elementType = getElementType(element, type);
|
|
34
|
+
|
|
35
|
+
return `${elementType}_${semanticPath}_${index}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get semantic path for element (form context, section, etc.)
|
|
40
|
+
*
|
|
41
|
+
* @param {Element} element - DOM element
|
|
42
|
+
* @returns {string} Semantic path
|
|
43
|
+
*/
|
|
44
|
+
function getSemanticPath(element) {
|
|
45
|
+
const parts = [];
|
|
46
|
+
|
|
47
|
+
// Check if inside form
|
|
48
|
+
const form = element.closest('form');
|
|
49
|
+
if (form) {
|
|
50
|
+
const formId = form.id || form.name || 'form';
|
|
51
|
+
parts.push(sanitizeId(formId));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Check if inside section/article/nav/header/footer
|
|
55
|
+
const section = element.closest('section, article, nav, header, footer, aside, main');
|
|
56
|
+
if (section && !form) {
|
|
57
|
+
const sectionTag = section.tagName.toLowerCase();
|
|
58
|
+
const sectionId = section.id || section.className.split(' ')[0] || sectionTag;
|
|
59
|
+
parts.push(sanitizeId(sectionId));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// If no context found, use 'page'
|
|
63
|
+
if (parts.length === 0) {
|
|
64
|
+
parts.push('page');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return parts.join('_');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Get element type string
|
|
72
|
+
*
|
|
73
|
+
* @param {Element} element - DOM element
|
|
74
|
+
* @param {string} providedType - Type provided by caller (optional)
|
|
75
|
+
* @returns {string} Element type
|
|
76
|
+
*/
|
|
77
|
+
function getElementType(element, providedType) {
|
|
78
|
+
if (providedType) {
|
|
79
|
+
return providedType;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const tagName = element.tagName.toLowerCase();
|
|
83
|
+
|
|
84
|
+
// Handle input types
|
|
85
|
+
if (tagName === 'input') {
|
|
86
|
+
const inputType = element.type || 'text';
|
|
87
|
+
// Normalize checkbox/radio
|
|
88
|
+
if (inputType === 'checkbox') return 'checkbox';
|
|
89
|
+
if (inputType === 'radio') return 'radio';
|
|
90
|
+
if (inputType === 'submit' || inputType === 'button') return 'button';
|
|
91
|
+
return 'input';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Handle other tags
|
|
95
|
+
if (tagName === 'textarea') return 'textarea';
|
|
96
|
+
if (tagName === 'select') return 'select';
|
|
97
|
+
if (tagName === 'button') return 'button';
|
|
98
|
+
if (tagName === 'a') return 'link';
|
|
99
|
+
if (tagName === 'form') return 'form';
|
|
100
|
+
|
|
101
|
+
// Role-based detection
|
|
102
|
+
const role = element.getAttribute('role');
|
|
103
|
+
if (role === 'button') return 'button';
|
|
104
|
+
if (role === 'link') return 'link';
|
|
105
|
+
|
|
106
|
+
return 'element';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Sanitize string for use in ID
|
|
111
|
+
*
|
|
112
|
+
* @param {string} str - String to sanitize
|
|
113
|
+
* @returns {string} Sanitized string
|
|
114
|
+
*/
|
|
115
|
+
function sanitizeId(str) {
|
|
116
|
+
return str
|
|
117
|
+
.toLowerCase()
|
|
118
|
+
.replace(/[^a-z0-9_-]/g, '_') // Replace non-alphanumeric with underscore
|
|
119
|
+
.replace(/_+/g, '_') // Collapse multiple underscores
|
|
120
|
+
.replace(/^_|_$/g, ''); // Remove leading/trailing underscores
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Generate element name from label, placeholder, or text
|
|
125
|
+
* Used for more descriptive IDs
|
|
126
|
+
*
|
|
127
|
+
* @param {Element} element - DOM element
|
|
128
|
+
* @returns {string} Element name
|
|
129
|
+
*/
|
|
130
|
+
function generateElementName(element) {
|
|
131
|
+
// Try label
|
|
132
|
+
const label = element.labels && element.labels[0];
|
|
133
|
+
if (label) {
|
|
134
|
+
const labelText = label.textContent.trim();
|
|
135
|
+
if (labelText) {
|
|
136
|
+
return sanitizeId(labelText.substring(0, 30));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Try placeholder
|
|
141
|
+
const placeholder = element.placeholder;
|
|
142
|
+
if (placeholder) {
|
|
143
|
+
return sanitizeId(placeholder.substring(0, 30));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Try aria-label
|
|
147
|
+
const ariaLabel = element.getAttribute('aria-label');
|
|
148
|
+
if (ariaLabel) {
|
|
149
|
+
return sanitizeId(ariaLabel.substring(0, 30));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Try name attribute
|
|
153
|
+
if (element.name) {
|
|
154
|
+
return sanitizeId(element.name);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Try text content for buttons/links
|
|
158
|
+
const text = element.textContent?.trim();
|
|
159
|
+
if (text) {
|
|
160
|
+
return sanitizeId(text.substring(0, 30));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return '';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Export for use in browser context (will be injected via eval)
|
|
167
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
168
|
+
module.exports = {
|
|
169
|
+
generateElementId,
|
|
170
|
+
getSemanticPath,
|
|
171
|
+
getElementType,
|
|
172
|
+
sanitizeId,
|
|
173
|
+
generateElementName
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -192,6 +192,7 @@ async function analyzePage(page) {
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
const elementInfo = {
|
|
195
|
+
id: `${generateElementName(el)}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, // Unique ID for Page Object
|
|
195
196
|
tag: el.tagName.toLowerCase(),
|
|
196
197
|
type: el.type || 'element',
|
|
197
198
|
selector: generateSelector(el),
|
|
@@ -215,6 +216,21 @@ async function analyzePage(page) {
|
|
|
215
216
|
}
|
|
216
217
|
};
|
|
217
218
|
|
|
219
|
+
// Add select options if element is a select
|
|
220
|
+
if (el.tagName === 'SELECT') {
|
|
221
|
+
elementInfo.options = Array.from(el.options).map((opt, idx) => ({
|
|
222
|
+
value: opt.value,
|
|
223
|
+
text: opt.textContent.trim(),
|
|
224
|
+
index: idx,
|
|
225
|
+
selected: opt.selected,
|
|
226
|
+
disabled: opt.disabled,
|
|
227
|
+
group: opt.parentElement.tagName === 'OPTGROUP' ? opt.parentElement.label : null
|
|
228
|
+
}));
|
|
229
|
+
elementInfo.selectedIndex = el.selectedIndex;
|
|
230
|
+
elementInfo.selectedValue = el.value;
|
|
231
|
+
elementInfo.selectedText = el.options[el.selectedIndex]?.textContent.trim() || null;
|
|
232
|
+
}
|
|
233
|
+
|
|
218
234
|
elements.push(elementInfo);
|
|
219
235
|
});
|
|
220
236
|
|