@leera.io/qa-runner 1.0.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/LICENSE +64 -0
- package/README.md +333 -0
- package/dist/src/appium/drivers.js +60 -0
- package/dist/src/appium/home.js +60 -0
- package/dist/src/appium/server.js +237 -0
- package/dist/src/builds/cache.js +133 -0
- package/dist/src/builds/cleanup.js +208 -0
- package/dist/src/builds/download.js +64 -0
- package/dist/src/builds/install.js +75 -0
- package/dist/src/builds/ios-app.js +99 -0
- package/dist/src/builds/unpack.js +347 -0
- package/dist/src/capabilities.js +93 -0
- package/dist/src/ci/builds.js +68 -0
- package/dist/src/ci/client.js +90 -0
- package/dist/src/ci/env.js +118 -0
- package/dist/src/ci/junit.js +79 -0
- package/dist/src/ci/run.js +320 -0
- package/dist/src/ci/summary.js +126 -0
- package/dist/src/cli/commands/builds.js +104 -0
- package/dist/src/cli/commands/ci.js +167 -0
- package/dist/src/cli/commands/config.js +83 -0
- package/dist/src/cli/commands/connect.js +70 -0
- package/dist/src/cli/commands/doctor.js +57 -0
- package/dist/src/cli/commands/service.js +165 -0
- package/dist/src/cli/commands/setup.js +193 -0
- package/dist/src/cli/commands/start.js +94 -0
- package/dist/src/cli/commands/update.js +189 -0
- package/dist/src/cli/commands/version.js +28 -0
- package/dist/src/cli/main.js +59 -0
- package/dist/src/cli/output.js +67 -0
- package/dist/src/cli.js +40 -0
- package/dist/src/client.js +146 -0
- package/dist/src/config.js +260 -0
- package/dist/src/debug.js +137 -0
- package/dist/src/devices/android/adb.js +251 -0
- package/dist/src/devices/android/avd.js +95 -0
- package/dist/src/devices/android/emulator.js +153 -0
- package/dist/src/devices/android/index.js +133 -0
- package/dist/src/devices/android/logcat.js +205 -0
- package/dist/src/devices/android/prepare.js +40 -0
- package/dist/src/devices/android/setup.js +162 -0
- package/dist/src/devices/android/system-dialog.js +78 -0
- package/dist/src/devices/desktop.js +145 -0
- package/dist/src/devices/ios/devicectl.js +87 -0
- package/dist/src/devices/ios/index.js +130 -0
- package/dist/src/devices/ios/prepare.js +39 -0
- package/dist/src/devices/ios/record.js +122 -0
- package/dist/src/devices/ios/settings.js +55 -0
- package/dist/src/devices/ios/setup.js +186 -0
- package/dist/src/devices/ios/simctl.js +201 -0
- package/dist/src/devices/ios/syslog.js +185 -0
- package/dist/src/devices/ios/wda.js +108 -0
- package/dist/src/devices/macos.js +248 -0
- package/dist/src/devices/manager.js +206 -0
- package/dist/src/devices/screen-record.js +95 -0
- package/dist/src/devices/tauri.js +189 -0
- package/dist/src/devices/types.js +6 -0
- package/dist/src/devices/windows.js +362 -0
- package/dist/src/doctor.js +394 -0
- package/dist/src/download.js +39 -0
- package/dist/src/drivers/android-appium.js +525 -0
- package/dist/src/drivers/common.js +64 -0
- package/dist/src/drivers/desktop-appium.js +282 -0
- package/dist/src/drivers/driver.js +15 -0
- package/dist/src/drivers/electron-playwright.js +566 -0
- package/dist/src/drivers/ios-appium.js +535 -0
- package/dist/src/drivers/mac2-appium.js +313 -0
- package/dist/src/drivers/registry.js +47 -0
- package/dist/src/drivers/snapshot/aria.js +351 -0
- package/dist/src/drivers/snapshot/format.js +106 -0
- package/dist/src/drivers/tauri-webdriver.js +678 -0
- package/dist/src/drivers/web-playwright.js +403 -0
- package/dist/src/drivers/webdriver/actions.js +328 -0
- package/dist/src/drivers/webdriver/desktop-keys.js +141 -0
- package/dist/src/drivers/webdriver/dom-locate.js +113 -0
- package/dist/src/drivers/webdriver/dom-snapshot.js +376 -0
- package/dist/src/drivers/webdriver/dom-tree.js +118 -0
- package/dist/src/drivers/webdriver/dom.js +213 -0
- package/dist/src/drivers/webdriver/ios-actions.js +299 -0
- package/dist/src/drivers/webdriver/ios-locate.js +121 -0
- package/dist/src/drivers/webdriver/locate.js +136 -0
- package/dist/src/drivers/webdriver/native-actions.js +175 -0
- package/dist/src/drivers/webdriver/native-locate.js +273 -0
- package/dist/src/drivers/webdriver/session.js +105 -0
- package/dist/src/drivers/webdriver/xml-tree-desktop.js +425 -0
- package/dist/src/drivers/webdriver/xml-tree-ios.js +314 -0
- package/dist/src/drivers/webdriver/xml-tree.js +393 -0
- package/dist/src/drivers/windows-appium.js +319 -0
- package/dist/src/errors.js +21 -0
- package/dist/src/executor.js +189 -0
- package/dist/src/home.js +76 -0
- package/dist/src/index.js +22 -0
- package/dist/src/log.js +69 -0
- package/dist/src/runner.js +498 -0
- package/dist/src/service/index.js +64 -0
- package/dist/src/service/launchd.js +85 -0
- package/dist/src/service/names.js +2 -0
- package/dist/src/service/schtasks.js +128 -0
- package/dist/src/service/systemd.js +65 -0
- package/dist/src/session/commands.js +165 -0
- package/dist/src/session/execute.js +232 -0
- package/dist/src/session/loop.js +148 -0
- package/dist/src/template.js +62 -0
- package/dist/src/types.js +7 -0
- package/dist/src/update/apply.js +223 -0
- package/dist/src/update/check.js +59 -0
- package/dist/src/update/manifest.js +93 -0
- package/dist/src/update/verify.js +65 -0
- package/dist/src/version.js +42 -0
- package/package.json +44 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { XMLParser } from 'fast-xml-parser';
|
|
2
|
+
import { clampDepth, clampMaxChars, clipText, renderSnapshotText, SNAPSHOT_LIMITS, } from '../snapshot/format.js';
|
|
3
|
+
import { escapeUiSelector } from './locate.js';
|
|
4
|
+
const parser = new XMLParser({
|
|
5
|
+
preserveOrder: true,
|
|
6
|
+
ignoreAttributes: false,
|
|
7
|
+
attributeNamePrefix: '',
|
|
8
|
+
parseAttributeValue: false,
|
|
9
|
+
parseTagValue: false,
|
|
10
|
+
trimValues: false,
|
|
11
|
+
processEntities: true,
|
|
12
|
+
});
|
|
13
|
+
function tagOf(node) {
|
|
14
|
+
for (const key of Object.keys(node)) {
|
|
15
|
+
if (key !== ':@' && !key.startsWith('#') && !key.startsWith('?'))
|
|
16
|
+
return key;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
function num(value) {
|
|
21
|
+
if (value === undefined || value === '')
|
|
22
|
+
return null;
|
|
23
|
+
const n = Number(value);
|
|
24
|
+
return Number.isFinite(n) ? n : null;
|
|
25
|
+
}
|
|
26
|
+
/** Parses UiAutomator2's page source. Throws when it is not XML with a `hierarchy` root. */
|
|
27
|
+
export function parsePageSource(xml) {
|
|
28
|
+
const doc = parser.parse(xml);
|
|
29
|
+
const top = doc.find((node) => tagOf(node) !== null);
|
|
30
|
+
if (!top)
|
|
31
|
+
throw new Error('the page source has no root element');
|
|
32
|
+
const all = [];
|
|
33
|
+
const build = (node, xpath) => {
|
|
34
|
+
const tag = tagOf(node);
|
|
35
|
+
const element = { tag, attrs: node[':@'] ?? {}, children: [], xpath, order: -1 };
|
|
36
|
+
const kids = node[tag] ?? [];
|
|
37
|
+
const seen = new Map();
|
|
38
|
+
for (const kid of kids) {
|
|
39
|
+
const kidTag = tagOf(kid);
|
|
40
|
+
if (!kidTag)
|
|
41
|
+
continue;
|
|
42
|
+
const position = (seen.get(kidTag) ?? 0) + 1;
|
|
43
|
+
seen.set(kidTag, position);
|
|
44
|
+
element.children.push(build(kid, `${xpath}/${kidTag}[${position}]`));
|
|
45
|
+
}
|
|
46
|
+
return element;
|
|
47
|
+
};
|
|
48
|
+
const topTag = tagOf(top);
|
|
49
|
+
const root = build(top, `/${topTag}`);
|
|
50
|
+
// Document order, excluding the synthetic root.
|
|
51
|
+
const visit = (element) => {
|
|
52
|
+
element.order = all.length;
|
|
53
|
+
all.push(element);
|
|
54
|
+
for (const child of element.children)
|
|
55
|
+
visit(child);
|
|
56
|
+
};
|
|
57
|
+
for (const child of root.children)
|
|
58
|
+
visit(child);
|
|
59
|
+
return {
|
|
60
|
+
root,
|
|
61
|
+
all,
|
|
62
|
+
width: num(root.attrs.width),
|
|
63
|
+
height: num(root.attrs.height),
|
|
64
|
+
rotation: num(root.attrs.rotation),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** `[l,t][r,b]` → bounds, or null. */
|
|
68
|
+
export function parseBounds(value) {
|
|
69
|
+
if (!value)
|
|
70
|
+
return null;
|
|
71
|
+
const match = /^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$/.exec(value.trim());
|
|
72
|
+
if (!match)
|
|
73
|
+
return null;
|
|
74
|
+
const [l, t, r, b] = match.slice(1).map(Number);
|
|
75
|
+
return { x: l, y: t, width: r - l, height: b - t };
|
|
76
|
+
}
|
|
77
|
+
/** Simple class name → role. Classes not listed are `group` (layouts) unless they are interactive. */
|
|
78
|
+
const ROLE_BY_CLASS = {
|
|
79
|
+
Button: 'button',
|
|
80
|
+
ImageButton: 'button',
|
|
81
|
+
MaterialButton: 'button',
|
|
82
|
+
FloatingActionButton: 'button',
|
|
83
|
+
ExtendedFloatingActionButton: 'button',
|
|
84
|
+
Chip: 'button',
|
|
85
|
+
EditText: 'textfield',
|
|
86
|
+
AutoCompleteTextView: 'textfield',
|
|
87
|
+
MultiAutoCompleteTextView: 'textfield',
|
|
88
|
+
TextInputEditText: 'textfield',
|
|
89
|
+
SearchView: 'search',
|
|
90
|
+
TextView: 'text',
|
|
91
|
+
CheckedTextView: 'text',
|
|
92
|
+
ImageView: 'image',
|
|
93
|
+
CheckBox: 'checkbox',
|
|
94
|
+
Switch: 'switch',
|
|
95
|
+
SwitchCompat: 'switch',
|
|
96
|
+
SwitchMaterial: 'switch',
|
|
97
|
+
MaterialSwitch: 'switch',
|
|
98
|
+
ToggleButton: 'switch',
|
|
99
|
+
RadioButton: 'radio',
|
|
100
|
+
RadioGroup: 'radiogroup',
|
|
101
|
+
Spinner: 'combobox',
|
|
102
|
+
SeekBar: 'slider',
|
|
103
|
+
RatingBar: 'slider',
|
|
104
|
+
ProgressBar: 'progressbar',
|
|
105
|
+
RecyclerView: 'list',
|
|
106
|
+
ListView: 'list',
|
|
107
|
+
GridView: 'grid',
|
|
108
|
+
ScrollView: 'scrollview',
|
|
109
|
+
NestedScrollView: 'scrollview',
|
|
110
|
+
HorizontalScrollView: 'scrollview',
|
|
111
|
+
ViewPager: 'pager',
|
|
112
|
+
ViewPager2: 'pager',
|
|
113
|
+
WebView: 'webview',
|
|
114
|
+
TabWidget: 'tablist',
|
|
115
|
+
TabLayout: 'tablist',
|
|
116
|
+
Toolbar: 'toolbar',
|
|
117
|
+
ActionBar: 'toolbar',
|
|
118
|
+
NumberPicker: 'picker',
|
|
119
|
+
DatePicker: 'picker',
|
|
120
|
+
TimePicker: 'picker',
|
|
121
|
+
VideoView: 'video',
|
|
122
|
+
Image: 'image',
|
|
123
|
+
};
|
|
124
|
+
export function simpleClass(className) {
|
|
125
|
+
const cleaned = className.replace(/\$.*/, '');
|
|
126
|
+
return cleaned.slice(cleaned.lastIndexOf('.') + 1);
|
|
127
|
+
}
|
|
128
|
+
export function roleFor(element) {
|
|
129
|
+
const cls = element.attrs.class || element.tag;
|
|
130
|
+
const role = ROLE_BY_CLASS[simpleClass(cls)];
|
|
131
|
+
if (role)
|
|
132
|
+
return role;
|
|
133
|
+
if (element.attrs.checkable === 'true')
|
|
134
|
+
return 'checkbox';
|
|
135
|
+
if (element.attrs.scrollable === 'true')
|
|
136
|
+
return 'scrollview';
|
|
137
|
+
if (element.attrs.clickable === 'true' || element.attrs['long-clickable'] === 'true')
|
|
138
|
+
return 'button';
|
|
139
|
+
return 'group';
|
|
140
|
+
}
|
|
141
|
+
/** `com.acme:id/login` → `login` for the app's own package; other packages keep the full id. */
|
|
142
|
+
export function shortResourceId(resourceId, appPackage) {
|
|
143
|
+
if (!resourceId)
|
|
144
|
+
return '';
|
|
145
|
+
if (appPackage && resourceId.startsWith(`${appPackage}:id/`))
|
|
146
|
+
return resourceId.slice(appPackage.length + 4);
|
|
147
|
+
return resourceId;
|
|
148
|
+
}
|
|
149
|
+
function isTrue(value) {
|
|
150
|
+
return value === 'true';
|
|
151
|
+
}
|
|
152
|
+
function isVisible(element, width, height) {
|
|
153
|
+
if (element.attrs.displayed === 'false')
|
|
154
|
+
return false;
|
|
155
|
+
const bounds = parseBounds(element.attrs.bounds);
|
|
156
|
+
if (!bounds)
|
|
157
|
+
return true;
|
|
158
|
+
if (bounds.width <= 0 || bounds.height <= 0)
|
|
159
|
+
return false;
|
|
160
|
+
if (width !== null && (bounds.x >= width || bounds.x + bounds.width <= 0))
|
|
161
|
+
return false;
|
|
162
|
+
if (height !== null && (bounds.y >= height || bounds.y + bounds.height <= 0))
|
|
163
|
+
return false;
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
function isPassword(element) {
|
|
167
|
+
return isTrue(element.attrs.password);
|
|
168
|
+
}
|
|
169
|
+
/** Window scaffolding ids that never help a script (`android:id/content`, `…:id/action_bar_root`). */
|
|
170
|
+
const FRAMEWORK_CONTAINER = /^(android:id\/.*|.*:id\/(action_bar_root|decor_content_parent|content|statusBarBackground|navigationBarBackground))$/;
|
|
171
|
+
/** A layout that carries nothing an agent needs: its children take its place. */
|
|
172
|
+
function layoutOnly(element) {
|
|
173
|
+
const a = element.attrs;
|
|
174
|
+
return (roleFor(element) === 'group' &&
|
|
175
|
+
!a.text &&
|
|
176
|
+
!a['content-desc'] &&
|
|
177
|
+
(!a['resource-id'] || FRAMEWORK_CONTAINER.test(a['resource-id'])) &&
|
|
178
|
+
!isTrue(a.clickable) &&
|
|
179
|
+
!isTrue(a['long-clickable']) &&
|
|
180
|
+
!isTrue(a.checkable) &&
|
|
181
|
+
!isTrue(a.scrollable));
|
|
182
|
+
}
|
|
183
|
+
/** A plain container whose only feature is a resource id (wrapper chains such as `…:id/main_content`). */
|
|
184
|
+
function wrapperWithId(element) {
|
|
185
|
+
const a = element.attrs;
|
|
186
|
+
return roleFor(element) === 'group' && Boolean(a['resource-id']) && !a.text && !a['content-desc'] && !isTrue(a.clickable) && !isTrue(a['long-clickable']) && !isTrue(a.checkable) && !isTrue(a.scrollable);
|
|
187
|
+
}
|
|
188
|
+
function isDescendant(ancestor, element) {
|
|
189
|
+
return element.xpath.startsWith(`${ancestor.xpath}/`);
|
|
190
|
+
}
|
|
191
|
+
/** The first non-empty, non-password text in an element's subtree. */
|
|
192
|
+
function firstText(element) {
|
|
193
|
+
for (const child of element.children) {
|
|
194
|
+
if (child.attrs.text && !isPassword(child) && child.attrs.displayed !== 'false')
|
|
195
|
+
return child.attrs.text;
|
|
196
|
+
const nested = firstText(child);
|
|
197
|
+
if (nested)
|
|
198
|
+
return nested;
|
|
199
|
+
}
|
|
200
|
+
return '';
|
|
201
|
+
}
|
|
202
|
+
function stateOf(element, role) {
|
|
203
|
+
const a = element.attrs;
|
|
204
|
+
const state = [];
|
|
205
|
+
if (isTrue(a.clickable) && role !== 'button')
|
|
206
|
+
state.push('clickable');
|
|
207
|
+
if (isTrue(a['long-clickable']))
|
|
208
|
+
state.push('long-clickable');
|
|
209
|
+
if (isTrue(a.checkable))
|
|
210
|
+
state.push(isTrue(a.checked) ? 'checked' : 'unchecked');
|
|
211
|
+
if (a.enabled === 'false')
|
|
212
|
+
state.push('disabled');
|
|
213
|
+
if (isTrue(a.focused))
|
|
214
|
+
state.push('focused');
|
|
215
|
+
if (isTrue(a.selected))
|
|
216
|
+
state.push('selected');
|
|
217
|
+
if (isTrue(a.scrollable))
|
|
218
|
+
state.push('scrollable');
|
|
219
|
+
if (isPassword(element))
|
|
220
|
+
state.push('password');
|
|
221
|
+
if (a.displayed === 'false')
|
|
222
|
+
state.push('hidden');
|
|
223
|
+
return state;
|
|
224
|
+
}
|
|
225
|
+
function index(all) {
|
|
226
|
+
const counts = { desc: new Map(), id: new Map(), text: new Map(), cls: new Map() };
|
|
227
|
+
const add = (map, key, element) => {
|
|
228
|
+
if (!key)
|
|
229
|
+
return;
|
|
230
|
+
const list = map.get(key);
|
|
231
|
+
if (list)
|
|
232
|
+
list.push(element);
|
|
233
|
+
else
|
|
234
|
+
map.set(key, [element]);
|
|
235
|
+
};
|
|
236
|
+
for (const element of all) {
|
|
237
|
+
// UiAutomator2 does not return elements it reports as not displayed, so they do not shift `nth`.
|
|
238
|
+
if (element.attrs.displayed === 'false')
|
|
239
|
+
continue;
|
|
240
|
+
add(counts.desc, element.attrs['content-desc'], element);
|
|
241
|
+
add(counts.id, element.attrs['resource-id'], element);
|
|
242
|
+
if (!isPassword(element))
|
|
243
|
+
add(counts.text, element.attrs.text, element);
|
|
244
|
+
add(counts.cls, element.attrs.class || element.tag, element);
|
|
245
|
+
}
|
|
246
|
+
return counts;
|
|
247
|
+
}
|
|
248
|
+
function withNth(target, list, element) {
|
|
249
|
+
if (!list || list.length <= 1)
|
|
250
|
+
return target;
|
|
251
|
+
return { ...target, nth: list.indexOf(element) };
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Locator candidates for an element, best first: desc > id > exact text > UiSelector
|
|
255
|
+
* instance (fragile) > xpath (fragile). Duplicated values carry `nth` (document order).
|
|
256
|
+
*/
|
|
257
|
+
export function candidatesFor(element, counts, appPackage) {
|
|
258
|
+
const out = [];
|
|
259
|
+
const a = element.attrs;
|
|
260
|
+
const desc = a['content-desc'];
|
|
261
|
+
if (desc && desc.length <= 2000) {
|
|
262
|
+
const list = counts.desc.get(desc);
|
|
263
|
+
out.push({ target: withNth({ desc }, list, element), fragile: (list?.length ?? 0) > 1 });
|
|
264
|
+
}
|
|
265
|
+
const rid = a['resource-id'];
|
|
266
|
+
if (rid) {
|
|
267
|
+
const list = counts.id.get(rid);
|
|
268
|
+
out.push({ target: withNth({ id: shortResourceId(rid, appPackage) }, list, element), fragile: (list?.length ?? 0) > 1 });
|
|
269
|
+
}
|
|
270
|
+
const text = a.text;
|
|
271
|
+
if (text && !isPassword(element) && text.length <= 2000) {
|
|
272
|
+
const list = counts.text.get(text);
|
|
273
|
+
out.push({ target: withNth({ text, exact: true }, list, element), fragile: (list?.length ?? 0) > 1 });
|
|
274
|
+
}
|
|
275
|
+
if (!text && !desc && isTrue(a.clickable)) {
|
|
276
|
+
// A clickable row without its own label: its first text finds a child inside the row, and tapping that taps the row.
|
|
277
|
+
const inner = firstText(element);
|
|
278
|
+
const list = inner ? counts.text.get(inner) : undefined;
|
|
279
|
+
if (inner && list && inner.length <= 2000) {
|
|
280
|
+
const child = list.find((candidate) => isDescendant(element, candidate));
|
|
281
|
+
if (child)
|
|
282
|
+
out.push({ target: withNth({ text: inner, exact: true }, list, child), fragile: list.length > 1 });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const cls = a.class || element.tag;
|
|
286
|
+
const instance = counts.cls.get(cls)?.indexOf(element) ?? -1;
|
|
287
|
+
if (instance >= 0) {
|
|
288
|
+
out.push({ target: { uiautomator: `new UiSelector().className("${escapeUiSelector(cls)}").instance(${instance})` }, fragile: true });
|
|
289
|
+
}
|
|
290
|
+
if (element.xpath.length <= 500)
|
|
291
|
+
out.push({ target: { xpath: element.xpath }, fragile: true });
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Suggestion + alternatives: the first candidate that is unique wins (preference order kept);
|
|
296
|
+
* when none is, the best one gets `nth` and the next-best strategy as `fallbacks[0]`.
|
|
297
|
+
*/
|
|
298
|
+
export function suggestionFor(candidates) {
|
|
299
|
+
if (candidates.length === 0)
|
|
300
|
+
return { suggest: {}, alternatives: [], fragile: true };
|
|
301
|
+
const uniqueIndex = candidates.findIndex((c) => !c.fragile);
|
|
302
|
+
const chosen = uniqueIndex >= 0 ? uniqueIndex : 0;
|
|
303
|
+
const best = candidates[chosen];
|
|
304
|
+
const rest = candidates.filter((_, i) => i !== chosen);
|
|
305
|
+
let suggest = best.target;
|
|
306
|
+
if (best.target.nth !== undefined && rest[0]) {
|
|
307
|
+
suggest = { ...best.target, fallbacks: [rest[0].target] };
|
|
308
|
+
}
|
|
309
|
+
return { suggest, alternatives: rest.map((c) => c.target), fragile: best.fragile };
|
|
310
|
+
}
|
|
311
|
+
/** Builds the snapshot tree, text and refs from page source XML. */
|
|
312
|
+
export function snapshotFromXml(xml, options) {
|
|
313
|
+
const tree = parsePageSource(xml);
|
|
314
|
+
const counts = index(tree.all);
|
|
315
|
+
const depthLimit = clampDepth(options.depth);
|
|
316
|
+
const maxNodes = Math.min(options.maxNodes ?? SNAPSHOT_LIMITS.nodes, SNAPSHOT_LIMITS.nodes);
|
|
317
|
+
let next = options.firstRef ?? 1;
|
|
318
|
+
let emitted = 0;
|
|
319
|
+
let truncated = false;
|
|
320
|
+
const refs = {};
|
|
321
|
+
const appPackage = options.appPackage ?? tree.all.find((e) => e.attrs.package)?.attrs.package ?? null;
|
|
322
|
+
const root = { ref: 'e0', role: 'screen', ...(appPackage ? { name: appPackage } : {}) };
|
|
323
|
+
if (tree.width !== null && tree.height !== null)
|
|
324
|
+
root.bounds = { x: 0, y: 0, width: tree.width, height: tree.height };
|
|
325
|
+
const convert = (element, depth, parent) => {
|
|
326
|
+
if (!options.includeHidden && !isVisible(element, tree.width, tree.height))
|
|
327
|
+
return;
|
|
328
|
+
const visibleKids = options.includeHidden ? element.children : element.children.filter((c) => isVisible(c, tree.width, tree.height));
|
|
329
|
+
if (layoutOnly(element) || (wrapperWithId(element) && visibleKids.length === 1)) {
|
|
330
|
+
for (const child of element.children)
|
|
331
|
+
convert(child, depth, parent);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (emitted >= maxNodes || depth > depthLimit) {
|
|
335
|
+
truncated = true;
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
emitted += 1;
|
|
339
|
+
const role = roleFor(element);
|
|
340
|
+
const a = element.attrs;
|
|
341
|
+
const ref = `e${next}`;
|
|
342
|
+
next += 1;
|
|
343
|
+
const node = { ref, role };
|
|
344
|
+
const desc = a['content-desc'];
|
|
345
|
+
const text = isPassword(element) ? '' : (a.text ?? '');
|
|
346
|
+
const hint = a.hint ?? '';
|
|
347
|
+
// A clickable row without a label of its own is named after the first text inside it.
|
|
348
|
+
const inner = !desc && !text && role === 'button' ? firstText(element) : '';
|
|
349
|
+
const name = desc || text || (role === 'textfield' ? hint : '') || inner;
|
|
350
|
+
if (name)
|
|
351
|
+
node.name = clipText(name);
|
|
352
|
+
if (text && desc && text !== desc)
|
|
353
|
+
node.text = clipText(text);
|
|
354
|
+
const rid = a['resource-id'];
|
|
355
|
+
// content-desc is already the name; only the resource id is worth a separate field.
|
|
356
|
+
if (rid)
|
|
357
|
+
node.ids = { resource_id: shortResourceId(rid, appPackage) };
|
|
358
|
+
const bounds = parseBounds(a.bounds);
|
|
359
|
+
if (bounds)
|
|
360
|
+
node.bounds = bounds;
|
|
361
|
+
const state = stateOf(element, role);
|
|
362
|
+
if (state.length > 0)
|
|
363
|
+
node.state = state;
|
|
364
|
+
const { suggest, alternatives, fragile } = suggestionFor(candidatesFor(element, counts, appPackage));
|
|
365
|
+
refs[ref] = {
|
|
366
|
+
locate: { xpath: element.xpath, ...(bounds ? { bounds } : {}), class: a.class || element.tag },
|
|
367
|
+
suggest,
|
|
368
|
+
alternatives,
|
|
369
|
+
fragile,
|
|
370
|
+
bounds: bounds ?? null,
|
|
371
|
+
};
|
|
372
|
+
(parent.children ??= []).push(node);
|
|
373
|
+
for (const child of element.children)
|
|
374
|
+
convert(child, depth + 1, node);
|
|
375
|
+
};
|
|
376
|
+
for (const child of tree.root.children)
|
|
377
|
+
convert(child, 1, root);
|
|
378
|
+
const rendered = renderSnapshotText(root, clampMaxChars(options.maxChars));
|
|
379
|
+
for (const ref of Object.keys(refs)) {
|
|
380
|
+
if (!rendered.refs.has(ref))
|
|
381
|
+
delete refs[ref];
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
root,
|
|
385
|
+
text: rendered.text,
|
|
386
|
+
refs,
|
|
387
|
+
element_count: emitted,
|
|
388
|
+
truncated: truncated || rendered.truncated,
|
|
389
|
+
width: tree.width,
|
|
390
|
+
height: tree.height,
|
|
391
|
+
rotation: tree.rotation,
|
|
392
|
+
};
|
|
393
|
+
}
|