@wdio/mcp 2.0.0 → 2.2.0
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/README.md +15 -6
- package/lib/server.js +611 -302
- package/lib/server.js.map +1 -1
- package/lib/snapshot.d.ts +123 -0
- package/lib/snapshot.js +1178 -0
- package/lib/snapshot.js.map +1 -0
- package/package.json +12 -1
package/lib/snapshot.js
ADDED
|
@@ -0,0 +1,1178 @@
|
|
|
1
|
+
// src/scripts/get-browser-accessibility-tree.ts
|
|
2
|
+
function flattenAccessibilityTree(node, result = []) {
|
|
3
|
+
if (!node) return result;
|
|
4
|
+
if (node.role !== "WebArea" || node.name) {
|
|
5
|
+
const entry = {
|
|
6
|
+
role: node.role || "",
|
|
7
|
+
name: node.name || "",
|
|
8
|
+
value: node.value ?? "",
|
|
9
|
+
description: node.description || "",
|
|
10
|
+
disabled: node.disabled ? "true" : "",
|
|
11
|
+
focused: node.focused ? "true" : "",
|
|
12
|
+
selected: node.selected ? "true" : "",
|
|
13
|
+
checked: node.checked === true ? "true" : node.checked === false ? "false" : node.checked === "mixed" ? "mixed" : "",
|
|
14
|
+
expanded: node.expanded === true ? "true" : node.expanded === false ? "false" : "",
|
|
15
|
+
pressed: node.pressed === true ? "true" : node.pressed === false ? "false" : node.pressed === "mixed" ? "mixed" : "",
|
|
16
|
+
readonly: node.readonly ? "true" : "",
|
|
17
|
+
required: node.required ? "true" : "",
|
|
18
|
+
level: node.level ?? "",
|
|
19
|
+
valuemin: node.valuemin ?? "",
|
|
20
|
+
valuemax: node.valuemax ?? "",
|
|
21
|
+
autocomplete: node.autocomplete || "",
|
|
22
|
+
haspopup: node.haspopup || "",
|
|
23
|
+
invalid: node.invalid ? "true" : "",
|
|
24
|
+
modal: node.modal ? "true" : "",
|
|
25
|
+
multiline: node.multiline ? "true" : "",
|
|
26
|
+
multiselectable: node.multiselectable ? "true" : "",
|
|
27
|
+
orientation: node.orientation || "",
|
|
28
|
+
keyshortcuts: node.keyshortcuts || "",
|
|
29
|
+
roledescription: node.roledescription || "",
|
|
30
|
+
valuetext: node.valuetext || ""
|
|
31
|
+
};
|
|
32
|
+
result.push(entry);
|
|
33
|
+
}
|
|
34
|
+
if (node.children && Array.isArray(node.children)) {
|
|
35
|
+
for (const child of node.children) {
|
|
36
|
+
flattenAccessibilityTree(child, result);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
async function getBrowserAccessibilityTree(browser) {
|
|
42
|
+
const puppeteer = await browser.getPuppeteer();
|
|
43
|
+
const pages = await puppeteer.pages();
|
|
44
|
+
if (pages.length === 0) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
const page = pages[0];
|
|
48
|
+
const snapshot = await page.accessibility.snapshot({
|
|
49
|
+
interestingOnly: true
|
|
50
|
+
});
|
|
51
|
+
if (!snapshot) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
return flattenAccessibilityTree(snapshot);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/scripts/get-interactable-browser-elements.ts
|
|
58
|
+
var elementsScript = (elementType = "interactable") => (function() {
|
|
59
|
+
const interactableSelectors = [
|
|
60
|
+
"a[href]",
|
|
61
|
+
// Links with href
|
|
62
|
+
"button",
|
|
63
|
+
// Buttons
|
|
64
|
+
'input:not([type="hidden"])',
|
|
65
|
+
// Input fields (except hidden)
|
|
66
|
+
"select",
|
|
67
|
+
// Select dropdowns
|
|
68
|
+
"textarea",
|
|
69
|
+
// Text areas
|
|
70
|
+
'[role="button"]',
|
|
71
|
+
// Elements with button role
|
|
72
|
+
'[role="link"]',
|
|
73
|
+
// Elements with link role
|
|
74
|
+
'[role="checkbox"]',
|
|
75
|
+
// Elements with checkbox role
|
|
76
|
+
'[role="radio"]',
|
|
77
|
+
// Elements with radio role
|
|
78
|
+
'[role="tab"]',
|
|
79
|
+
// Elements with tab role
|
|
80
|
+
'[role="menuitem"]',
|
|
81
|
+
// Elements with menuitem role
|
|
82
|
+
'[role="combobox"]',
|
|
83
|
+
// Elements with combobox role
|
|
84
|
+
'[role="option"]',
|
|
85
|
+
// Elements with option role
|
|
86
|
+
'[role="switch"]',
|
|
87
|
+
// Elements with switch role
|
|
88
|
+
'[role="slider"]',
|
|
89
|
+
// Elements with slider role
|
|
90
|
+
'[role="textbox"]',
|
|
91
|
+
// Elements with textbox role
|
|
92
|
+
'[role="searchbox"]',
|
|
93
|
+
// Elements with searchbox role
|
|
94
|
+
'[contenteditable="true"]',
|
|
95
|
+
// Editable content
|
|
96
|
+
'[tabindex]:not([tabindex="-1"])'
|
|
97
|
+
// Elements with tabindex
|
|
98
|
+
];
|
|
99
|
+
const visualSelectors = [
|
|
100
|
+
"img",
|
|
101
|
+
// Images
|
|
102
|
+
"picture",
|
|
103
|
+
// Picture elements
|
|
104
|
+
"svg",
|
|
105
|
+
// SVG graphics
|
|
106
|
+
"video",
|
|
107
|
+
// Video elements
|
|
108
|
+
"canvas",
|
|
109
|
+
// Canvas elements
|
|
110
|
+
'[style*="background-image"]'
|
|
111
|
+
// Elements with background images
|
|
112
|
+
];
|
|
113
|
+
function isVisible(element) {
|
|
114
|
+
if (typeof element.checkVisibility === "function") {
|
|
115
|
+
return element.checkVisibility({
|
|
116
|
+
opacityProperty: true,
|
|
117
|
+
visibilityProperty: true,
|
|
118
|
+
contentVisibilityAuto: true
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
const style = window.getComputedStyle(element);
|
|
122
|
+
return style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0" && element.offsetWidth > 0 && element.offsetHeight > 0;
|
|
123
|
+
}
|
|
124
|
+
function getCssSelector(element) {
|
|
125
|
+
if (element.id) {
|
|
126
|
+
return `#${CSS.escape(element.id)}`;
|
|
127
|
+
}
|
|
128
|
+
if (element.className && typeof element.className === "string") {
|
|
129
|
+
const classes = element.className.trim().split(/\s+/).filter(Boolean);
|
|
130
|
+
if (classes.length > 0) {
|
|
131
|
+
const classSelector = classes.slice(0, 2).map((c) => `.${CSS.escape(c)}`).join("");
|
|
132
|
+
const tagWithClass = `${element.tagName.toLowerCase()}${classSelector}`;
|
|
133
|
+
if (document.querySelectorAll(tagWithClass).length === 1) {
|
|
134
|
+
return tagWithClass;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
let current = element;
|
|
139
|
+
const path = [];
|
|
140
|
+
while (current && current !== document.documentElement) {
|
|
141
|
+
let selector = current.tagName.toLowerCase();
|
|
142
|
+
if (current.id) {
|
|
143
|
+
selector = `#${CSS.escape(current.id)}`;
|
|
144
|
+
path.unshift(selector);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
const parent = current.parentElement;
|
|
148
|
+
if (parent) {
|
|
149
|
+
const siblings = Array.from(parent.children).filter(
|
|
150
|
+
(child) => child.tagName === current.tagName
|
|
151
|
+
);
|
|
152
|
+
if (siblings.length > 1) {
|
|
153
|
+
const index = siblings.indexOf(current) + 1;
|
|
154
|
+
selector += `:nth-child(${index})`;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
path.unshift(selector);
|
|
158
|
+
current = current.parentElement;
|
|
159
|
+
if (path.length >= 4) {
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return path.join(" > ");
|
|
164
|
+
}
|
|
165
|
+
function getElements() {
|
|
166
|
+
const selectors = [];
|
|
167
|
+
if (elementType === "interactable" || elementType === "all") {
|
|
168
|
+
selectors.push(...interactableSelectors);
|
|
169
|
+
}
|
|
170
|
+
if (elementType === "visual" || elementType === "all") {
|
|
171
|
+
selectors.push(...visualSelectors);
|
|
172
|
+
}
|
|
173
|
+
const allElements = [];
|
|
174
|
+
selectors.forEach((selector) => {
|
|
175
|
+
const elements = document.querySelectorAll(selector);
|
|
176
|
+
elements.forEach((element) => {
|
|
177
|
+
if (!allElements.includes(element)) {
|
|
178
|
+
allElements.push(element);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
const elementInfos = allElements.filter((element) => isVisible(element) && !element.disabled).map((element) => {
|
|
183
|
+
const el = element;
|
|
184
|
+
const inputEl = element;
|
|
185
|
+
const rect = el.getBoundingClientRect();
|
|
186
|
+
const isInViewport = rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth);
|
|
187
|
+
const info = {
|
|
188
|
+
tagName: el.tagName.toLowerCase(),
|
|
189
|
+
type: el.getAttribute("type") || "",
|
|
190
|
+
id: el.id || "",
|
|
191
|
+
className: (typeof el.className === "string" ? el.className : "") || "",
|
|
192
|
+
textContent: el.textContent?.trim() || "",
|
|
193
|
+
value: inputEl.value || "",
|
|
194
|
+
placeholder: inputEl.placeholder || "",
|
|
195
|
+
href: el.getAttribute("href") || "",
|
|
196
|
+
ariaLabel: el.getAttribute("aria-label") || "",
|
|
197
|
+
role: el.getAttribute("role") || "",
|
|
198
|
+
src: el.getAttribute("src") || "",
|
|
199
|
+
alt: el.getAttribute("alt") || "",
|
|
200
|
+
cssSelector: getCssSelector(el),
|
|
201
|
+
isInViewport
|
|
202
|
+
};
|
|
203
|
+
return info;
|
|
204
|
+
});
|
|
205
|
+
return elementInfos;
|
|
206
|
+
}
|
|
207
|
+
return getElements();
|
|
208
|
+
})();
|
|
209
|
+
async function getBrowserInteractableElements(browser, options = {}) {
|
|
210
|
+
const { elementType = "interactable" } = options;
|
|
211
|
+
return browser.execute(elementsScript, elementType);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// src/locators/constants.ts
|
|
215
|
+
var ANDROID_INTERACTABLE_TAGS = [
|
|
216
|
+
// Input elements
|
|
217
|
+
"android.widget.EditText",
|
|
218
|
+
"android.widget.AutoCompleteTextView",
|
|
219
|
+
"android.widget.MultiAutoCompleteTextView",
|
|
220
|
+
"android.widget.SearchView",
|
|
221
|
+
// Button-like elements
|
|
222
|
+
"android.widget.Button",
|
|
223
|
+
"android.widget.ImageButton",
|
|
224
|
+
"android.widget.ToggleButton",
|
|
225
|
+
"android.widget.CompoundButton",
|
|
226
|
+
"android.widget.RadioButton",
|
|
227
|
+
"android.widget.CheckBox",
|
|
228
|
+
"android.widget.Switch",
|
|
229
|
+
"android.widget.FloatingActionButton",
|
|
230
|
+
"com.google.android.material.button.MaterialButton",
|
|
231
|
+
"com.google.android.material.floatingactionbutton.FloatingActionButton",
|
|
232
|
+
// Text elements (often tappable)
|
|
233
|
+
"android.widget.TextView",
|
|
234
|
+
"android.widget.CheckedTextView",
|
|
235
|
+
// Image elements (often tappable)
|
|
236
|
+
"android.widget.ImageView",
|
|
237
|
+
"android.widget.QuickContactBadge",
|
|
238
|
+
// Selection elements
|
|
239
|
+
"android.widget.Spinner",
|
|
240
|
+
"android.widget.SeekBar",
|
|
241
|
+
"android.widget.RatingBar",
|
|
242
|
+
"android.widget.ProgressBar",
|
|
243
|
+
"android.widget.DatePicker",
|
|
244
|
+
"android.widget.TimePicker",
|
|
245
|
+
"android.widget.NumberPicker",
|
|
246
|
+
// List/grid items
|
|
247
|
+
"android.widget.AdapterView"
|
|
248
|
+
];
|
|
249
|
+
var ANDROID_LAYOUT_CONTAINERS = [
|
|
250
|
+
// Core ViewGroup classes
|
|
251
|
+
"android.view.ViewGroup",
|
|
252
|
+
"android.view.View",
|
|
253
|
+
"android.widget.FrameLayout",
|
|
254
|
+
"android.widget.LinearLayout",
|
|
255
|
+
"android.widget.RelativeLayout",
|
|
256
|
+
"android.widget.GridLayout",
|
|
257
|
+
"android.widget.TableLayout",
|
|
258
|
+
"android.widget.TableRow",
|
|
259
|
+
"android.widget.AbsoluteLayout",
|
|
260
|
+
// AndroidX layout classes
|
|
261
|
+
"androidx.constraintlayout.widget.ConstraintLayout",
|
|
262
|
+
"androidx.coordinatorlayout.widget.CoordinatorLayout",
|
|
263
|
+
"androidx.appcompat.widget.LinearLayoutCompat",
|
|
264
|
+
"androidx.cardview.widget.CardView",
|
|
265
|
+
"androidx.appcompat.widget.ContentFrameLayout",
|
|
266
|
+
"androidx.appcompat.widget.FitWindowsFrameLayout",
|
|
267
|
+
// Scrolling containers
|
|
268
|
+
"android.widget.ScrollView",
|
|
269
|
+
"android.widget.HorizontalScrollView",
|
|
270
|
+
"android.widget.NestedScrollView",
|
|
271
|
+
"androidx.core.widget.NestedScrollView",
|
|
272
|
+
"androidx.recyclerview.widget.RecyclerView",
|
|
273
|
+
"android.widget.ListView",
|
|
274
|
+
"android.widget.GridView",
|
|
275
|
+
"android.widget.AbsListView",
|
|
276
|
+
// App chrome / system elements
|
|
277
|
+
"android.widget.ActionBarContainer",
|
|
278
|
+
"android.widget.ActionBarOverlayLayout",
|
|
279
|
+
"android.view.ViewStub",
|
|
280
|
+
"androidx.appcompat.widget.ActionBarContainer",
|
|
281
|
+
"androidx.appcompat.widget.ActionBarContextView",
|
|
282
|
+
"androidx.appcompat.widget.ActionBarOverlayLayout",
|
|
283
|
+
// Decor views
|
|
284
|
+
"com.android.internal.policy.DecorView",
|
|
285
|
+
"android.widget.DecorView"
|
|
286
|
+
];
|
|
287
|
+
var IOS_INTERACTABLE_TAGS = [
|
|
288
|
+
// Input elements
|
|
289
|
+
"XCUIElementTypeTextField",
|
|
290
|
+
"XCUIElementTypeSecureTextField",
|
|
291
|
+
"XCUIElementTypeTextView",
|
|
292
|
+
"XCUIElementTypeSearchField",
|
|
293
|
+
// Button-like elements
|
|
294
|
+
"XCUIElementTypeButton",
|
|
295
|
+
"XCUIElementTypeLink",
|
|
296
|
+
// Text elements (often tappable)
|
|
297
|
+
"XCUIElementTypeStaticText",
|
|
298
|
+
// Image elements
|
|
299
|
+
"XCUIElementTypeImage",
|
|
300
|
+
"XCUIElementTypeIcon",
|
|
301
|
+
// Selection elements
|
|
302
|
+
"XCUIElementTypeSwitch",
|
|
303
|
+
"XCUIElementTypeSlider",
|
|
304
|
+
"XCUIElementTypeStepper",
|
|
305
|
+
"XCUIElementTypeSegmentedControl",
|
|
306
|
+
"XCUIElementTypePicker",
|
|
307
|
+
"XCUIElementTypePickerWheel",
|
|
308
|
+
"XCUIElementTypeDatePicker",
|
|
309
|
+
"XCUIElementTypePageIndicator",
|
|
310
|
+
// Table/list items
|
|
311
|
+
"XCUIElementTypeCell",
|
|
312
|
+
"XCUIElementTypeMenuItem",
|
|
313
|
+
"XCUIElementTypeMenuBarItem",
|
|
314
|
+
// Toggle elements
|
|
315
|
+
"XCUIElementTypeCheckBox",
|
|
316
|
+
"XCUIElementTypeRadioButton",
|
|
317
|
+
"XCUIElementTypeToggle",
|
|
318
|
+
// Other interactive
|
|
319
|
+
"XCUIElementTypeKey",
|
|
320
|
+
"XCUIElementTypeKeyboard",
|
|
321
|
+
"XCUIElementTypeAlert",
|
|
322
|
+
"XCUIElementTypeSheet"
|
|
323
|
+
];
|
|
324
|
+
var IOS_LAYOUT_CONTAINERS = [
|
|
325
|
+
// Generic containers
|
|
326
|
+
"XCUIElementTypeOther",
|
|
327
|
+
"XCUIElementTypeGroup",
|
|
328
|
+
"XCUIElementTypeLayoutItem",
|
|
329
|
+
// Scroll containers
|
|
330
|
+
"XCUIElementTypeScrollView",
|
|
331
|
+
"XCUIElementTypeTable",
|
|
332
|
+
"XCUIElementTypeCollectionView",
|
|
333
|
+
"XCUIElementTypeScrollBar",
|
|
334
|
+
// Navigation chrome
|
|
335
|
+
"XCUIElementTypeNavigationBar",
|
|
336
|
+
"XCUIElementTypeTabBar",
|
|
337
|
+
"XCUIElementTypeToolbar",
|
|
338
|
+
"XCUIElementTypeStatusBar",
|
|
339
|
+
"XCUIElementTypeMenuBar",
|
|
340
|
+
// Windows and views
|
|
341
|
+
"XCUIElementTypeWindow",
|
|
342
|
+
"XCUIElementTypeSheet",
|
|
343
|
+
"XCUIElementTypeDrawer",
|
|
344
|
+
"XCUIElementTypeDialog",
|
|
345
|
+
"XCUIElementTypePopover",
|
|
346
|
+
"XCUIElementTypePopUpButton",
|
|
347
|
+
// Outline elements
|
|
348
|
+
"XCUIElementTypeOutline",
|
|
349
|
+
"XCUIElementTypeOutlineRow",
|
|
350
|
+
"XCUIElementTypeBrowser",
|
|
351
|
+
"XCUIElementTypeSplitGroup",
|
|
352
|
+
"XCUIElementTypeSplitter",
|
|
353
|
+
// Application root
|
|
354
|
+
"XCUIElementTypeApplication"
|
|
355
|
+
];
|
|
356
|
+
|
|
357
|
+
// src/locators/xml-parsing.ts
|
|
358
|
+
import { DOMParser } from "@xmldom/xmldom";
|
|
359
|
+
import xpath from "xpath";
|
|
360
|
+
function childNodesOf(node) {
|
|
361
|
+
const children = [];
|
|
362
|
+
if (node.childNodes) {
|
|
363
|
+
for (let i = 0; i < node.childNodes.length; i++) {
|
|
364
|
+
const child = node.childNodes.item(i);
|
|
365
|
+
if (child?.nodeType === 1) {
|
|
366
|
+
children.push(child);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return children;
|
|
371
|
+
}
|
|
372
|
+
function translateRecursively(domNode, parentPath = "", index = null) {
|
|
373
|
+
const attributes = {};
|
|
374
|
+
const element = domNode;
|
|
375
|
+
if (element.attributes) {
|
|
376
|
+
for (let attrIdx = 0; attrIdx < element.attributes.length; attrIdx++) {
|
|
377
|
+
const attr = element.attributes.item(attrIdx);
|
|
378
|
+
if (attr) {
|
|
379
|
+
attributes[attr.name] = attr.value.replace(/(\n)/gm, "\\n");
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
const path = index === null ? "" : `${parentPath ? parentPath + "." : ""}${index}`;
|
|
384
|
+
return {
|
|
385
|
+
children: childNodesOf(domNode).map(
|
|
386
|
+
(childNode, childIndex) => translateRecursively(childNode, path, childIndex)
|
|
387
|
+
),
|
|
388
|
+
tagName: domNode.nodeName,
|
|
389
|
+
attributes,
|
|
390
|
+
path
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function isSameElement(node1, node2) {
|
|
394
|
+
if (node1.nodeType !== 1 || node2.nodeType !== 1) return false;
|
|
395
|
+
const el1 = node1;
|
|
396
|
+
const el2 = node2;
|
|
397
|
+
if (el1.nodeName !== el2.nodeName) return false;
|
|
398
|
+
const bounds1 = el1.getAttribute("bounds");
|
|
399
|
+
const bounds2 = el2.getAttribute("bounds");
|
|
400
|
+
if (bounds1 && bounds2) {
|
|
401
|
+
return bounds1 === bounds2;
|
|
402
|
+
}
|
|
403
|
+
const x1 = el1.getAttribute("x");
|
|
404
|
+
const y1 = el1.getAttribute("y");
|
|
405
|
+
const x2 = el2.getAttribute("x");
|
|
406
|
+
const y2 = el2.getAttribute("y");
|
|
407
|
+
if (x1 && y1 && x2 && y2) {
|
|
408
|
+
return x1 === x2 && y1 === y2 && el1.getAttribute("width") === el2.getAttribute("width") && el1.getAttribute("height") === el2.getAttribute("height");
|
|
409
|
+
}
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
function xmlToJSON(sourceXML) {
|
|
413
|
+
try {
|
|
414
|
+
const parser = new DOMParser();
|
|
415
|
+
const sourceDoc = parser.parseFromString(sourceXML, "text/xml");
|
|
416
|
+
const parseErrors = sourceDoc.getElementsByTagName("parsererror");
|
|
417
|
+
if (parseErrors.length > 0) {
|
|
418
|
+
console.error("[xmlToJSON] XML parsing error:", parseErrors[0].textContent);
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
const children = childNodesOf(sourceDoc);
|
|
422
|
+
const firstChild = children[0] || (sourceDoc.documentElement ? childNodesOf(sourceDoc.documentElement)[0] : null);
|
|
423
|
+
return firstChild ? translateRecursively(firstChild) : { children: [], tagName: "", attributes: {}, path: "" };
|
|
424
|
+
} catch (e) {
|
|
425
|
+
console.error("[xmlToJSON] Failed to parse XML:", e);
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
function xmlToDOM(sourceXML) {
|
|
430
|
+
try {
|
|
431
|
+
const parser = new DOMParser();
|
|
432
|
+
const doc = parser.parseFromString(sourceXML, "text/xml");
|
|
433
|
+
const parseErrors = doc.getElementsByTagName("parsererror");
|
|
434
|
+
if (parseErrors.length > 0) {
|
|
435
|
+
console.error("[xmlToDOM] XML parsing error:", parseErrors[0].textContent);
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
return doc;
|
|
439
|
+
} catch (e) {
|
|
440
|
+
console.error("[xmlToDOM] Failed to parse XML:", e);
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
function evaluateXPath(doc, xpathExpr) {
|
|
445
|
+
try {
|
|
446
|
+
const nodes = xpath.select(xpathExpr, doc);
|
|
447
|
+
if (Array.isArray(nodes)) {
|
|
448
|
+
return nodes;
|
|
449
|
+
}
|
|
450
|
+
return [];
|
|
451
|
+
} catch (e) {
|
|
452
|
+
console.error(`[evaluateXPath] Failed to evaluate "${xpathExpr}":`, e);
|
|
453
|
+
return [];
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function checkXPathUniqueness(doc, xpathExpr, targetNode) {
|
|
457
|
+
try {
|
|
458
|
+
const nodes = evaluateXPath(doc, xpathExpr);
|
|
459
|
+
const totalMatches = nodes.length;
|
|
460
|
+
if (totalMatches === 0) {
|
|
461
|
+
return { isUnique: false };
|
|
462
|
+
}
|
|
463
|
+
if (totalMatches === 1) {
|
|
464
|
+
return { isUnique: true };
|
|
465
|
+
}
|
|
466
|
+
if (targetNode) {
|
|
467
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
468
|
+
if (nodes[i].isSameNode(targetNode) || isSameElement(nodes[i], targetNode)) {
|
|
469
|
+
return {
|
|
470
|
+
isUnique: false,
|
|
471
|
+
index: i + 1,
|
|
472
|
+
// 1-based index for XPath
|
|
473
|
+
totalMatches
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return { isUnique: false, totalMatches };
|
|
479
|
+
} catch (e) {
|
|
480
|
+
console.error(`[checkXPathUniqueness] Error checking "${xpathExpr}":`, e);
|
|
481
|
+
return { isUnique: false };
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function findDOMNodeByPath(doc, path) {
|
|
485
|
+
if (!path) return doc.documentElement;
|
|
486
|
+
const indices = path.split(".").map(Number);
|
|
487
|
+
let current = doc.documentElement;
|
|
488
|
+
for (const index of indices) {
|
|
489
|
+
if (!current) return null;
|
|
490
|
+
const children = [];
|
|
491
|
+
if (current.childNodes) {
|
|
492
|
+
for (let i = 0; i < current.childNodes.length; i++) {
|
|
493
|
+
const child = current.childNodes.item(i);
|
|
494
|
+
if (child?.nodeType === 1) {
|
|
495
|
+
children.push(child);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
current = children[index] || null;
|
|
500
|
+
}
|
|
501
|
+
return current;
|
|
502
|
+
}
|
|
503
|
+
function parseAndroidBounds(bounds) {
|
|
504
|
+
const match = bounds.match(/\[(\d+),(\d+)\]\[(\d+),(\d+)\]/);
|
|
505
|
+
if (!match) {
|
|
506
|
+
return { x: 0, y: 0, width: 0, height: 0 };
|
|
507
|
+
}
|
|
508
|
+
const x1 = parseInt(match[1], 10);
|
|
509
|
+
const y1 = parseInt(match[2], 10);
|
|
510
|
+
const x2 = parseInt(match[3], 10);
|
|
511
|
+
const y2 = parseInt(match[4], 10);
|
|
512
|
+
return {
|
|
513
|
+
x: x1,
|
|
514
|
+
y: y1,
|
|
515
|
+
width: x2 - x1,
|
|
516
|
+
height: y2 - y1
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
function parseIOSBounds(attributes) {
|
|
520
|
+
return {
|
|
521
|
+
x: parseInt(attributes.x || "0", 10),
|
|
522
|
+
y: parseInt(attributes.y || "0", 10),
|
|
523
|
+
width: parseInt(attributes.width || "0", 10),
|
|
524
|
+
height: parseInt(attributes.height || "0", 10)
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
function countAttributeOccurrences(sourceXML, attribute, value) {
|
|
528
|
+
const escapedValue = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
529
|
+
const pattern = new RegExp(`${attribute}=["']${escapedValue}["']`, "g");
|
|
530
|
+
const matches = sourceXML.match(pattern);
|
|
531
|
+
return matches ? matches.length : 0;
|
|
532
|
+
}
|
|
533
|
+
function isAttributeUnique(sourceXML, attribute, value) {
|
|
534
|
+
return countAttributeOccurrences(sourceXML, attribute, value) === 1;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// src/locators/element-filter.ts
|
|
538
|
+
function matchesTagList(tagName, tagList) {
|
|
539
|
+
if (tagList.includes(tagName)) {
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
for (const tag of tagList) {
|
|
543
|
+
if (tagName.endsWith(tag) || tagName.includes(tag)) {
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
function matchesTagFilters(element, includeTagNames, excludeTagNames) {
|
|
550
|
+
if (includeTagNames.length > 0 && !matchesTagList(element.tagName, includeTagNames)) {
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
if (matchesTagList(element.tagName, excludeTagNames)) {
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
return true;
|
|
557
|
+
}
|
|
558
|
+
function matchesAttributeFilters(element, requireAttributes, minAttributeCount) {
|
|
559
|
+
if (requireAttributes.length > 0) {
|
|
560
|
+
const hasRequiredAttr = requireAttributes.some((attr) => element.attributes?.[attr]);
|
|
561
|
+
if (!hasRequiredAttr) return false;
|
|
562
|
+
}
|
|
563
|
+
if (element.attributes && minAttributeCount > 0) {
|
|
564
|
+
const attrCount = Object.values(element.attributes).filter(
|
|
565
|
+
(v) => v !== void 0 && v !== null && v !== ""
|
|
566
|
+
).length;
|
|
567
|
+
if (attrCount < minAttributeCount) {
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
function isInteractableElement(element, isNative, automationName) {
|
|
574
|
+
const isAndroid = automationName.toLowerCase().includes("uiautomator");
|
|
575
|
+
const interactableTags = isAndroid ? ANDROID_INTERACTABLE_TAGS : IOS_INTERACTABLE_TAGS;
|
|
576
|
+
if (matchesTagList(element.tagName, interactableTags)) {
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
if (isAndroid) {
|
|
580
|
+
if (element.attributes?.clickable === "true" || element.attributes?.focusable === "true" || element.attributes?.checkable === "true" || element.attributes?.["long-clickable"] === "true") {
|
|
581
|
+
return true;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (!isAndroid) {
|
|
585
|
+
if (element.attributes?.accessible === "true") {
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
function isLayoutContainer(element, platform) {
|
|
592
|
+
const containerList = platform === "android" ? ANDROID_LAYOUT_CONTAINERS : IOS_LAYOUT_CONTAINERS;
|
|
593
|
+
return matchesTagList(element.tagName, containerList);
|
|
594
|
+
}
|
|
595
|
+
function hasMeaningfulContent(element, platform) {
|
|
596
|
+
const attrs = element.attributes;
|
|
597
|
+
if (attrs.text && attrs.text.trim() !== "" && attrs.text !== "null") {
|
|
598
|
+
return true;
|
|
599
|
+
}
|
|
600
|
+
if (platform === "android") {
|
|
601
|
+
if (attrs["content-desc"] && attrs["content-desc"].trim() !== "" && attrs["content-desc"] !== "null") {
|
|
602
|
+
return true;
|
|
603
|
+
}
|
|
604
|
+
} else {
|
|
605
|
+
if (attrs.label && attrs.label.trim() !== "" && attrs.label !== "null") {
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
if (attrs.name && attrs.name.trim() !== "" && attrs.name !== "null") {
|
|
609
|
+
return true;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
function shouldIncludeElement(element, filters, isNative, automationName) {
|
|
615
|
+
const {
|
|
616
|
+
includeTagNames = [],
|
|
617
|
+
excludeTagNames = ["hierarchy"],
|
|
618
|
+
requireAttributes = [],
|
|
619
|
+
minAttributeCount = 0,
|
|
620
|
+
fetchableOnly = false,
|
|
621
|
+
clickableOnly = false,
|
|
622
|
+
visibleOnly = true
|
|
623
|
+
} = filters;
|
|
624
|
+
if (!matchesTagFilters(element, includeTagNames, excludeTagNames)) {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
if (!matchesAttributeFilters(element, requireAttributes, minAttributeCount)) {
|
|
628
|
+
return false;
|
|
629
|
+
}
|
|
630
|
+
if (clickableOnly && element.attributes?.clickable !== "true") {
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
if (visibleOnly) {
|
|
634
|
+
const isAndroid = automationName.toLowerCase().includes("uiautomator");
|
|
635
|
+
if (isAndroid && element.attributes?.displayed === "false") {
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
if (!isAndroid && element.attributes?.visible === "false") {
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (fetchableOnly && !isInteractableElement(element, isNative, automationName)) {
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
return true;
|
|
646
|
+
}
|
|
647
|
+
function getDefaultFilters(platform, includeContainers = false) {
|
|
648
|
+
const layoutContainers = platform === "android" ? ANDROID_LAYOUT_CONTAINERS : IOS_LAYOUT_CONTAINERS;
|
|
649
|
+
return {
|
|
650
|
+
excludeTagNames: includeContainers ? ["hierarchy"] : ["hierarchy", ...layoutContainers],
|
|
651
|
+
fetchableOnly: !includeContainers,
|
|
652
|
+
visibleOnly: true,
|
|
653
|
+
clickableOnly: false
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// src/locators/locator-generation.ts
|
|
658
|
+
function isValidValue(value) {
|
|
659
|
+
return value !== void 0 && value !== null && value !== "null" && value.trim() !== "";
|
|
660
|
+
}
|
|
661
|
+
function escapeText(text) {
|
|
662
|
+
return text.replace(/"/g, '\\"').replace(/\n/g, "\\n");
|
|
663
|
+
}
|
|
664
|
+
function escapeXPathValue(value) {
|
|
665
|
+
if (!value.includes("'")) {
|
|
666
|
+
return `'${value}'`;
|
|
667
|
+
}
|
|
668
|
+
if (!value.includes('"')) {
|
|
669
|
+
return `"${value}"`;
|
|
670
|
+
}
|
|
671
|
+
const parts = [];
|
|
672
|
+
let current = "";
|
|
673
|
+
for (const char of value) {
|
|
674
|
+
if (char === "'") {
|
|
675
|
+
if (current) parts.push(`'${current}'`);
|
|
676
|
+
parts.push(`"'"`);
|
|
677
|
+
current = "";
|
|
678
|
+
} else {
|
|
679
|
+
current += char;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
if (current) parts.push(`'${current}'`);
|
|
683
|
+
return `concat(${parts.join(",")})`;
|
|
684
|
+
}
|
|
685
|
+
function generateIndexedXPath(baseXPath, index) {
|
|
686
|
+
return `(${baseXPath})[${index}]`;
|
|
687
|
+
}
|
|
688
|
+
function generateIndexedUiAutomator(baseSelector, index) {
|
|
689
|
+
return `${baseSelector}.instance(${index - 1})`;
|
|
690
|
+
}
|
|
691
|
+
function checkUniqueness(ctx, xpath2, targetNode) {
|
|
692
|
+
if (ctx.parsedDOM) {
|
|
693
|
+
return checkXPathUniqueness(ctx.parsedDOM, xpath2, targetNode);
|
|
694
|
+
}
|
|
695
|
+
const match = xpath2.match(/\/\/\*\[@([^=]+)="([^"]+)"\]/);
|
|
696
|
+
if (match) {
|
|
697
|
+
const [, attr, value] = match;
|
|
698
|
+
return { isUnique: isAttributeUnique(ctx.sourceXML, attr, value) };
|
|
699
|
+
}
|
|
700
|
+
return { isUnique: false };
|
|
701
|
+
}
|
|
702
|
+
function getSiblingIndex(element) {
|
|
703
|
+
const parent = element.parentNode;
|
|
704
|
+
if (!parent) return 1;
|
|
705
|
+
const tagName = element.nodeName;
|
|
706
|
+
let index = 0;
|
|
707
|
+
for (let i = 0; i < parent.childNodes.length; i++) {
|
|
708
|
+
const child = parent.childNodes.item(i);
|
|
709
|
+
if (child?.nodeType === 1 && child.nodeName === tagName) {
|
|
710
|
+
index++;
|
|
711
|
+
if (child === element) return index;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return 1;
|
|
715
|
+
}
|
|
716
|
+
function countSiblings(element) {
|
|
717
|
+
const parent = element.parentNode;
|
|
718
|
+
if (!parent) return 1;
|
|
719
|
+
const tagName = element.nodeName;
|
|
720
|
+
let count = 0;
|
|
721
|
+
for (let i = 0; i < parent.childNodes.length; i++) {
|
|
722
|
+
const child = parent.childNodes.item(i);
|
|
723
|
+
if (child?.nodeType === 1 && child.nodeName === tagName) {
|
|
724
|
+
count++;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return count;
|
|
728
|
+
}
|
|
729
|
+
function findUniqueAttribute(element, ctx) {
|
|
730
|
+
const attrs = ctx.isAndroid ? ["resource-id", "content-desc", "text"] : ["name", "label", "value"];
|
|
731
|
+
for (const attr of attrs) {
|
|
732
|
+
const value = element.getAttribute(attr);
|
|
733
|
+
if (value && value.trim()) {
|
|
734
|
+
const xpath2 = `//*[@${attr}=${escapeXPathValue(value)}]`;
|
|
735
|
+
const result = ctx.parsedDOM ? checkXPathUniqueness(ctx.parsedDOM, xpath2) : { isUnique: isAttributeUnique(ctx.sourceXML, attr, value) };
|
|
736
|
+
if (result.isUnique) {
|
|
737
|
+
return `@${attr}=${escapeXPathValue(value)}`;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
function buildHierarchicalXPath(ctx, element, maxDepth = 3) {
|
|
744
|
+
if (!ctx.parsedDOM) return null;
|
|
745
|
+
const pathParts = [];
|
|
746
|
+
let current = element;
|
|
747
|
+
let depth = 0;
|
|
748
|
+
while (current && depth < maxDepth) {
|
|
749
|
+
const tagName = current.nodeName;
|
|
750
|
+
const uniqueAttr = findUniqueAttribute(current, ctx);
|
|
751
|
+
if (uniqueAttr) {
|
|
752
|
+
pathParts.unshift(`//${tagName}[${uniqueAttr}]`);
|
|
753
|
+
break;
|
|
754
|
+
} else {
|
|
755
|
+
const siblingIndex = getSiblingIndex(current);
|
|
756
|
+
const siblingCount = countSiblings(current);
|
|
757
|
+
if (siblingCount > 1) {
|
|
758
|
+
pathParts.unshift(`${tagName}[${siblingIndex}]`);
|
|
759
|
+
} else {
|
|
760
|
+
pathParts.unshift(tagName);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const parent = current.parentNode;
|
|
764
|
+
current = parent && parent.nodeType === 1 ? parent : null;
|
|
765
|
+
depth++;
|
|
766
|
+
}
|
|
767
|
+
if (pathParts.length === 0) return null;
|
|
768
|
+
let result = pathParts[0];
|
|
769
|
+
for (let i = 1; i < pathParts.length; i++) {
|
|
770
|
+
result += "/" + pathParts[i];
|
|
771
|
+
}
|
|
772
|
+
if (!result.startsWith("//")) {
|
|
773
|
+
result = "//" + result;
|
|
774
|
+
}
|
|
775
|
+
return result;
|
|
776
|
+
}
|
|
777
|
+
function addXPathLocator(results, xpath2, ctx, targetNode) {
|
|
778
|
+
const uniqueness = checkUniqueness(ctx, xpath2, targetNode);
|
|
779
|
+
if (uniqueness.isUnique) {
|
|
780
|
+
results.push(["xpath", xpath2]);
|
|
781
|
+
} else if (uniqueness.index) {
|
|
782
|
+
results.push(["xpath", generateIndexedXPath(xpath2, uniqueness.index)]);
|
|
783
|
+
} else {
|
|
784
|
+
if (targetNode && ctx.parsedDOM) {
|
|
785
|
+
const hierarchical = buildHierarchicalXPath(ctx, targetNode);
|
|
786
|
+
if (hierarchical) {
|
|
787
|
+
results.push(["xpath", hierarchical]);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
results.push(["xpath", xpath2]);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
function isInUiAutomatorScope(element, doc) {
|
|
794
|
+
if (!doc) return true;
|
|
795
|
+
const hierarchyNodes = evaluateXPath(doc, "/hierarchy/*");
|
|
796
|
+
if (hierarchyNodes.length === 0) return true;
|
|
797
|
+
const lastIndex = hierarchyNodes.length;
|
|
798
|
+
const pathParts = element.path.split(".");
|
|
799
|
+
if (pathParts.length === 0 || pathParts[0] === "") return true;
|
|
800
|
+
const firstIndex = parseInt(pathParts[0], 10);
|
|
801
|
+
return firstIndex === lastIndex - 1;
|
|
802
|
+
}
|
|
803
|
+
function buildUiAutomatorSelector(element) {
|
|
804
|
+
const attrs = element.attributes;
|
|
805
|
+
const parts = [];
|
|
806
|
+
if (isValidValue(attrs["resource-id"])) {
|
|
807
|
+
parts.push(`resourceId("${attrs["resource-id"]}")`);
|
|
808
|
+
}
|
|
809
|
+
if (isValidValue(attrs.text) && attrs.text.length < 100) {
|
|
810
|
+
parts.push(`text("${escapeText(attrs.text)}")`);
|
|
811
|
+
}
|
|
812
|
+
if (isValidValue(attrs["content-desc"])) {
|
|
813
|
+
parts.push(`description("${attrs["content-desc"]}")`);
|
|
814
|
+
}
|
|
815
|
+
if (isValidValue(attrs.class)) {
|
|
816
|
+
parts.push(`className("${attrs.class}")`);
|
|
817
|
+
}
|
|
818
|
+
if (parts.length === 0) return null;
|
|
819
|
+
return `android=new UiSelector().${parts.join(".")}`;
|
|
820
|
+
}
|
|
821
|
+
function buildPredicateString(element) {
|
|
822
|
+
const attrs = element.attributes;
|
|
823
|
+
const conditions = [];
|
|
824
|
+
if (isValidValue(attrs.name)) {
|
|
825
|
+
conditions.push(`name == "${escapeText(attrs.name)}"`);
|
|
826
|
+
}
|
|
827
|
+
if (isValidValue(attrs.label)) {
|
|
828
|
+
conditions.push(`label == "${escapeText(attrs.label)}"`);
|
|
829
|
+
}
|
|
830
|
+
if (isValidValue(attrs.value)) {
|
|
831
|
+
conditions.push(`value == "${escapeText(attrs.value)}"`);
|
|
832
|
+
}
|
|
833
|
+
if (attrs.visible === "true") {
|
|
834
|
+
conditions.push("visible == 1");
|
|
835
|
+
}
|
|
836
|
+
if (attrs.enabled === "true") {
|
|
837
|
+
conditions.push("enabled == 1");
|
|
838
|
+
}
|
|
839
|
+
if (conditions.length === 0) return null;
|
|
840
|
+
return `-ios predicate string:${conditions.join(" AND ")}`;
|
|
841
|
+
}
|
|
842
|
+
function buildClassChain(element) {
|
|
843
|
+
const attrs = element.attributes;
|
|
844
|
+
const tagName = element.tagName;
|
|
845
|
+
if (!tagName.startsWith("XCUI")) return null;
|
|
846
|
+
let selector = `**/${tagName}`;
|
|
847
|
+
if (isValidValue(attrs.label)) {
|
|
848
|
+
selector += `[\`label == "${escapeText(attrs.label)}"\`]`;
|
|
849
|
+
} else if (isValidValue(attrs.name)) {
|
|
850
|
+
selector += `[\`name == "${escapeText(attrs.name)}"\`]`;
|
|
851
|
+
}
|
|
852
|
+
return `-ios class chain:${selector}`;
|
|
853
|
+
}
|
|
854
|
+
function buildXPath(element, sourceXML, isAndroid) {
|
|
855
|
+
const attrs = element.attributes;
|
|
856
|
+
const tagName = element.tagName;
|
|
857
|
+
const conditions = [];
|
|
858
|
+
if (isAndroid) {
|
|
859
|
+
if (isValidValue(attrs["resource-id"])) {
|
|
860
|
+
conditions.push(`@resource-id="${attrs["resource-id"]}"`);
|
|
861
|
+
}
|
|
862
|
+
if (isValidValue(attrs["content-desc"])) {
|
|
863
|
+
conditions.push(`@content-desc="${attrs["content-desc"]}"`);
|
|
864
|
+
}
|
|
865
|
+
if (isValidValue(attrs.text) && attrs.text.length < 100) {
|
|
866
|
+
conditions.push(`@text="${escapeText(attrs.text)}"`);
|
|
867
|
+
}
|
|
868
|
+
} else {
|
|
869
|
+
if (isValidValue(attrs.name)) {
|
|
870
|
+
conditions.push(`@name="${attrs.name}"`);
|
|
871
|
+
}
|
|
872
|
+
if (isValidValue(attrs.label)) {
|
|
873
|
+
conditions.push(`@label="${attrs.label}"`);
|
|
874
|
+
}
|
|
875
|
+
if (isValidValue(attrs.value)) {
|
|
876
|
+
conditions.push(`@value="${attrs.value}"`);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
if (conditions.length === 0) {
|
|
880
|
+
return `//${tagName}`;
|
|
881
|
+
}
|
|
882
|
+
return `//${tagName}[${conditions.join(" and ")}]`;
|
|
883
|
+
}
|
|
884
|
+
function getSimpleSuggestedLocators(element, ctx, automationName, targetNode) {
|
|
885
|
+
const results = [];
|
|
886
|
+
const isAndroid = automationName.toLowerCase().includes("uiautomator");
|
|
887
|
+
const attrs = element.attributes;
|
|
888
|
+
const inUiAutomatorScope = isAndroid ? isInUiAutomatorScope(element, ctx.parsedDOM) : true;
|
|
889
|
+
if (isAndroid) {
|
|
890
|
+
const resourceId = attrs["resource-id"];
|
|
891
|
+
if (isValidValue(resourceId)) {
|
|
892
|
+
const xpath2 = `//*[@resource-id="${resourceId}"]`;
|
|
893
|
+
const uniqueness = checkUniqueness(ctx, xpath2, targetNode);
|
|
894
|
+
if (uniqueness.isUnique && inUiAutomatorScope) {
|
|
895
|
+
results.push(["id", `android=new UiSelector().resourceId("${resourceId}")`]);
|
|
896
|
+
} else if (uniqueness.index && inUiAutomatorScope) {
|
|
897
|
+
const base = `android=new UiSelector().resourceId("${resourceId}")`;
|
|
898
|
+
results.push(["id", generateIndexedUiAutomator(base, uniqueness.index)]);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
const contentDesc = attrs["content-desc"];
|
|
902
|
+
if (isValidValue(contentDesc)) {
|
|
903
|
+
const xpath2 = `//*[@content-desc="${contentDesc}"]`;
|
|
904
|
+
const uniqueness = checkUniqueness(ctx, xpath2, targetNode);
|
|
905
|
+
if (uniqueness.isUnique) {
|
|
906
|
+
results.push(["accessibility-id", `~${contentDesc}`]);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
const text = attrs.text;
|
|
910
|
+
if (isValidValue(text) && text.length < 100) {
|
|
911
|
+
const xpath2 = `//*[@text="${escapeText(text)}"]`;
|
|
912
|
+
const uniqueness = checkUniqueness(ctx, xpath2, targetNode);
|
|
913
|
+
if (uniqueness.isUnique && inUiAutomatorScope) {
|
|
914
|
+
results.push(["text", `android=new UiSelector().text("${escapeText(text)}")`]);
|
|
915
|
+
} else if (uniqueness.index && inUiAutomatorScope) {
|
|
916
|
+
const base = `android=new UiSelector().text("${escapeText(text)}")`;
|
|
917
|
+
results.push(["text", generateIndexedUiAutomator(base, uniqueness.index)]);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
} else {
|
|
921
|
+
const name = attrs.name;
|
|
922
|
+
if (isValidValue(name)) {
|
|
923
|
+
const xpath2 = `//*[@name="${name}"]`;
|
|
924
|
+
const uniqueness = checkUniqueness(ctx, xpath2, targetNode);
|
|
925
|
+
if (uniqueness.isUnique) {
|
|
926
|
+
results.push(["accessibility-id", `~${name}`]);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
const label = attrs.label;
|
|
930
|
+
if (isValidValue(label) && label !== attrs.name) {
|
|
931
|
+
const xpath2 = `//*[@label="${escapeText(label)}"]`;
|
|
932
|
+
const uniqueness = checkUniqueness(ctx, xpath2, targetNode);
|
|
933
|
+
if (uniqueness.isUnique) {
|
|
934
|
+
results.push(["predicate-string", `-ios predicate string:label == "${escapeText(label)}"`]);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
const value = attrs.value;
|
|
938
|
+
if (isValidValue(value)) {
|
|
939
|
+
const xpath2 = `//*[@value="${escapeText(value)}"]`;
|
|
940
|
+
const uniqueness = checkUniqueness(ctx, xpath2, targetNode);
|
|
941
|
+
if (uniqueness.isUnique) {
|
|
942
|
+
results.push(["predicate-string", `-ios predicate string:value == "${escapeText(value)}"`]);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
return results;
|
|
947
|
+
}
|
|
948
|
+
function getComplexSuggestedLocators(element, ctx, automationName, targetNode) {
|
|
949
|
+
const results = [];
|
|
950
|
+
const isAndroid = automationName.toLowerCase().includes("uiautomator");
|
|
951
|
+
const inUiAutomatorScope = isAndroid ? isInUiAutomatorScope(element, ctx.parsedDOM) : true;
|
|
952
|
+
if (isAndroid) {
|
|
953
|
+
if (inUiAutomatorScope) {
|
|
954
|
+
const uiAutomator = buildUiAutomatorSelector(element);
|
|
955
|
+
if (uiAutomator) {
|
|
956
|
+
results.push(["uiautomator", uiAutomator]);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
const xpath2 = buildXPath(element, ctx.sourceXML, true);
|
|
960
|
+
if (xpath2) {
|
|
961
|
+
addXPathLocator(results, xpath2, ctx, targetNode);
|
|
962
|
+
}
|
|
963
|
+
if (inUiAutomatorScope && isValidValue(element.attributes.class)) {
|
|
964
|
+
results.push([
|
|
965
|
+
"class-name",
|
|
966
|
+
`android=new UiSelector().className("${element.attributes.class}")`
|
|
967
|
+
]);
|
|
968
|
+
}
|
|
969
|
+
} else {
|
|
970
|
+
const predicate = buildPredicateString(element);
|
|
971
|
+
if (predicate) {
|
|
972
|
+
results.push(["predicate-string", predicate]);
|
|
973
|
+
}
|
|
974
|
+
const classChain = buildClassChain(element);
|
|
975
|
+
if (classChain) {
|
|
976
|
+
results.push(["class-chain", classChain]);
|
|
977
|
+
}
|
|
978
|
+
const xpath2 = buildXPath(element, ctx.sourceXML, false);
|
|
979
|
+
if (xpath2) {
|
|
980
|
+
addXPathLocator(results, xpath2, ctx, targetNode);
|
|
981
|
+
}
|
|
982
|
+
const type = element.tagName;
|
|
983
|
+
if (type.startsWith("XCUIElementType")) {
|
|
984
|
+
results.push(["class-name", `-ios class chain:**/${type}`]);
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return results;
|
|
988
|
+
}
|
|
989
|
+
function getSuggestedLocators(element, sourceXML, automationName, ctx, targetNode) {
|
|
990
|
+
const locatorCtx = ctx ?? {
|
|
991
|
+
sourceXML,
|
|
992
|
+
parsedDOM: null,
|
|
993
|
+
isAndroid: automationName.toLowerCase().includes("uiautomator")
|
|
994
|
+
};
|
|
995
|
+
const simpleLocators = getSimpleSuggestedLocators(element, locatorCtx, automationName, targetNode);
|
|
996
|
+
const complexLocators = getComplexSuggestedLocators(element, locatorCtx, automationName, targetNode);
|
|
997
|
+
const seen = /* @__PURE__ */ new Set();
|
|
998
|
+
const results = [];
|
|
999
|
+
for (const locator of [...simpleLocators, ...complexLocators]) {
|
|
1000
|
+
if (!seen.has(locator[1])) {
|
|
1001
|
+
seen.add(locator[1]);
|
|
1002
|
+
results.push(locator);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return results;
|
|
1006
|
+
}
|
|
1007
|
+
function locatorsToObject(locators) {
|
|
1008
|
+
const result = {};
|
|
1009
|
+
for (const [strategy, value] of locators) {
|
|
1010
|
+
if (!result[strategy]) {
|
|
1011
|
+
result[strategy] = value;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return result;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// src/locators/index.ts
|
|
1018
|
+
function parseBounds(element, platform) {
|
|
1019
|
+
return platform === "android" ? parseAndroidBounds(element.attributes.bounds || "") : parseIOSBounds(element.attributes);
|
|
1020
|
+
}
|
|
1021
|
+
function isWithinViewport(bounds, viewport) {
|
|
1022
|
+
return bounds.x >= 0 && bounds.y >= 0 && bounds.width > 0 && bounds.height > 0 && bounds.x + bounds.width <= viewport.width && bounds.y + bounds.height <= viewport.height;
|
|
1023
|
+
}
|
|
1024
|
+
function transformElement(element, locators, ctx) {
|
|
1025
|
+
const attrs = element.attributes;
|
|
1026
|
+
const bounds = parseBounds(element, ctx.platform);
|
|
1027
|
+
return {
|
|
1028
|
+
tagName: element.tagName,
|
|
1029
|
+
locators: locatorsToObject(locators),
|
|
1030
|
+
text: attrs.text || attrs.label || "",
|
|
1031
|
+
contentDesc: attrs["content-desc"] || "",
|
|
1032
|
+
resourceId: attrs["resource-id"] || "",
|
|
1033
|
+
accessibilityId: attrs.name || attrs["content-desc"] || "",
|
|
1034
|
+
label: attrs.label || "",
|
|
1035
|
+
value: attrs.value || "",
|
|
1036
|
+
className: attrs.class || element.tagName,
|
|
1037
|
+
clickable: attrs.clickable === "true" || attrs.accessible === "true" || attrs["long-clickable"] === "true",
|
|
1038
|
+
enabled: attrs.enabled !== "false",
|
|
1039
|
+
displayed: ctx.platform === "android" ? attrs.displayed !== "false" : attrs.visible !== "false",
|
|
1040
|
+
bounds,
|
|
1041
|
+
isInViewport: isWithinViewport(bounds, ctx.viewportSize)
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
function shouldProcess(element, ctx) {
|
|
1045
|
+
if (shouldIncludeElement(element, ctx.filters, ctx.isNative, ctx.automationName)) {
|
|
1046
|
+
return true;
|
|
1047
|
+
}
|
|
1048
|
+
return isLayoutContainer(element, ctx.platform) && hasMeaningfulContent(element, ctx.platform);
|
|
1049
|
+
}
|
|
1050
|
+
function processElement(element, ctx) {
|
|
1051
|
+
if (!shouldProcess(element, ctx)) return;
|
|
1052
|
+
try {
|
|
1053
|
+
const targetNode = ctx.parsedDOM ? findDOMNodeByPath(ctx.parsedDOM, element.path) : void 0;
|
|
1054
|
+
const locators = getSuggestedLocators(
|
|
1055
|
+
element,
|
|
1056
|
+
ctx.sourceXML,
|
|
1057
|
+
ctx.automationName,
|
|
1058
|
+
{ sourceXML: ctx.sourceXML, parsedDOM: ctx.parsedDOM, isAndroid: ctx.platform === "android" },
|
|
1059
|
+
targetNode || void 0
|
|
1060
|
+
);
|
|
1061
|
+
if (locators.length === 0) return;
|
|
1062
|
+
const transformed = transformElement(element, locators, ctx);
|
|
1063
|
+
if (Object.keys(transformed.locators).length === 0) return;
|
|
1064
|
+
ctx.results.push(transformed);
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
console.error(`[processElement] Error at path ${element.path}:`, error);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
function traverseTree(element, ctx) {
|
|
1070
|
+
if (!element) return;
|
|
1071
|
+
processElement(element, ctx);
|
|
1072
|
+
for (const child of element.children || []) {
|
|
1073
|
+
traverseTree(child, ctx);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
function generateAllElementLocators(sourceXML, options) {
|
|
1077
|
+
const sourceJSON = xmlToJSON(sourceXML);
|
|
1078
|
+
if (!sourceJSON) {
|
|
1079
|
+
console.error("[generateAllElementLocators] Failed to parse page source XML");
|
|
1080
|
+
return [];
|
|
1081
|
+
}
|
|
1082
|
+
const parsedDOM = xmlToDOM(sourceXML);
|
|
1083
|
+
const ctx = {
|
|
1084
|
+
sourceXML,
|
|
1085
|
+
platform: options.platform,
|
|
1086
|
+
automationName: options.platform === "android" ? "uiautomator2" : "xcuitest",
|
|
1087
|
+
isNative: options.isNative ?? true,
|
|
1088
|
+
viewportSize: options.viewportSize ?? { width: 9999, height: 9999 },
|
|
1089
|
+
filters: options.filters ?? {},
|
|
1090
|
+
results: [],
|
|
1091
|
+
parsedDOM
|
|
1092
|
+
};
|
|
1093
|
+
traverseTree(sourceJSON, ctx);
|
|
1094
|
+
return ctx.results;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// src/scripts/get-visible-mobile-elements.ts
|
|
1098
|
+
var LOCATOR_PRIORITY = [
|
|
1099
|
+
"accessibility-id",
|
|
1100
|
+
// Most stable, cross-platform
|
|
1101
|
+
"id",
|
|
1102
|
+
// Android resource-id
|
|
1103
|
+
"text",
|
|
1104
|
+
// Text-based (can be fragile but readable)
|
|
1105
|
+
"predicate-string",
|
|
1106
|
+
// iOS predicate
|
|
1107
|
+
"class-chain",
|
|
1108
|
+
// iOS class chain
|
|
1109
|
+
"uiautomator",
|
|
1110
|
+
// Android UiAutomator compound
|
|
1111
|
+
"xpath"
|
|
1112
|
+
// XPath (last resort, brittle)
|
|
1113
|
+
// 'class-name' intentionally excluded - too generic
|
|
1114
|
+
];
|
|
1115
|
+
function selectBestLocators(locators) {
|
|
1116
|
+
const selected = [];
|
|
1117
|
+
for (const strategy of LOCATOR_PRIORITY) {
|
|
1118
|
+
if (locators[strategy]) {
|
|
1119
|
+
selected.push(locators[strategy]);
|
|
1120
|
+
break;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
for (const strategy of LOCATOR_PRIORITY) {
|
|
1124
|
+
if (locators[strategy] && !selected.includes(locators[strategy])) {
|
|
1125
|
+
selected.push(locators[strategy]);
|
|
1126
|
+
break;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
return selected;
|
|
1130
|
+
}
|
|
1131
|
+
function toMobileElementInfo(element, includeBounds) {
|
|
1132
|
+
const selectedLocators = selectBestLocators(element.locators);
|
|
1133
|
+
const accessId = element.accessibilityId || element.contentDesc;
|
|
1134
|
+
const info = {
|
|
1135
|
+
selector: selectedLocators[0] || "",
|
|
1136
|
+
tagName: element.tagName,
|
|
1137
|
+
isInViewport: element.isInViewport,
|
|
1138
|
+
text: element.text || "",
|
|
1139
|
+
resourceId: element.resourceId || "",
|
|
1140
|
+
accessibilityId: accessId || "",
|
|
1141
|
+
isEnabled: element.enabled !== false,
|
|
1142
|
+
altSelector: selectedLocators[1] || ""
|
|
1143
|
+
// Single alternative (flattened for tabular)
|
|
1144
|
+
};
|
|
1145
|
+
if (includeBounds) {
|
|
1146
|
+
info.bounds = element.bounds;
|
|
1147
|
+
}
|
|
1148
|
+
return info;
|
|
1149
|
+
}
|
|
1150
|
+
async function getViewportSize(browser) {
|
|
1151
|
+
try {
|
|
1152
|
+
const size = await browser.getWindowSize();
|
|
1153
|
+
return { width: size.width, height: size.height };
|
|
1154
|
+
} catch {
|
|
1155
|
+
return { width: 9999, height: 9999 };
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
async function getMobileVisibleElements(browser, platform, options = {}) {
|
|
1159
|
+
const { includeContainers = false, includeBounds = false, filterOptions } = options;
|
|
1160
|
+
const viewportSize = await getViewportSize(browser);
|
|
1161
|
+
const pageSource = await browser.getPageSource();
|
|
1162
|
+
const filters = {
|
|
1163
|
+
...getDefaultFilters(platform, includeContainers),
|
|
1164
|
+
...filterOptions
|
|
1165
|
+
};
|
|
1166
|
+
const elements = generateAllElementLocators(pageSource, {
|
|
1167
|
+
platform,
|
|
1168
|
+
viewportSize,
|
|
1169
|
+
filters
|
|
1170
|
+
});
|
|
1171
|
+
return elements.map((el) => toMobileElementInfo(el, includeBounds));
|
|
1172
|
+
}
|
|
1173
|
+
export {
|
|
1174
|
+
getBrowserAccessibilityTree,
|
|
1175
|
+
getBrowserInteractableElements,
|
|
1176
|
+
getMobileVisibleElements
|
|
1177
|
+
};
|
|
1178
|
+
//# sourceMappingURL=snapshot.js.map
|