@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,376 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Runner-owned snapshot script for WebDriver-backed web views (Tauri, contracts §L).
|
|
3
|
+
* Executed with W3C `execute/sync` as `return (<this file>).apply(null, arguments);`, never
|
|
4
|
+
* with user-provided script. Walks the document and returns a compact tree the runner turns
|
|
5
|
+
* into a session snapshot (plan §7.3): role from ARIA or the tag, accessible name, text
|
|
6
|
+
* (≤80 characters), data-testid, id, bounds and state. Invisible elements are dropped unless
|
|
7
|
+
* `includeHidden`; layout-only elements are hoisted; depth and node count are capped.
|
|
8
|
+
*
|
|
9
|
+
* request: { depth?, includeHidden?, maxNodes?, viewportOnly? }
|
|
10
|
+
* returns: { viewport: {width, height, scale}, url, title, truncated, count,
|
|
11
|
+
* nodes: RawNode[], elements: Element[] }
|
|
12
|
+
* RawNode: { i, role, name?, text?, test_id?, id?, bounds?, state?, xpath,
|
|
13
|
+
* test_id_count?, test_id_index?, id_selector?, id_count?, text_key?, text_count?, text_index?,
|
|
14
|
+
* children? } (`i` indexes `elements`)
|
|
15
|
+
*/
|
|
16
|
+
(function domSnapshot(request) {
|
|
17
|
+
const options = request || {};
|
|
18
|
+
const viewport = { width: window.innerWidth, height: window.innerHeight, scale: window.devicePixelRatio || 1 };
|
|
19
|
+
if (options.viewportOnly) return { viewport: viewport, url: String(location.href), title: document.title };
|
|
20
|
+
|
|
21
|
+
const MAX_DEPTH = Math.max(1, Math.min(25, Number(options.depth) || 25));
|
|
22
|
+
const MAX_NODES = Math.max(1, Math.min(400, Number(options.maxNodes) || 400));
|
|
23
|
+
const MAX_TEXT = 80;
|
|
24
|
+
const includeHidden = options.includeHidden === true;
|
|
25
|
+
const SKIP = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEMPLATE: 1, HEAD: 1, META: 1, LINK: 1, TITLE: 1, BR: 1 };
|
|
26
|
+
const CONTENT_NAMED = { button: 1, link: 1, heading: 1, menuitem: 1, tab: 1, option: 1, cell: 1, columnheader: 1, listitem: 1, label: 1, paragraph: 1 };
|
|
27
|
+
const LEAF_ROLES = { button: 1, link: 1, heading: 1, menuitem: 1, tab: 1, option: 1, paragraph: 1, label: 1 };
|
|
28
|
+
const INTERACTIVE = { button: 1, link: 1, textbox: 1, searchbox: 1, checkbox: 1, radio: 1, combobox: 1, listbox: 1, slider: 1, spinbutton: 1, switch: 1, tab: 1, menuitem: 1, option: 1 };
|
|
29
|
+
|
|
30
|
+
function normalize(value) {
|
|
31
|
+
return String(value == null ? '' : value)
|
|
32
|
+
.replace(/\s+/g, ' ')
|
|
33
|
+
.trim();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clip(value) {
|
|
37
|
+
const text = normalize(value);
|
|
38
|
+
return text.length > MAX_TEXT ? `${text.slice(0, MAX_TEXT - 1)}…` : text;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function inputType(element) {
|
|
42
|
+
return (element.getAttribute('type') || 'text').toLowerCase();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function roleOf(element) {
|
|
46
|
+
const explicit = normalize(element.getAttribute('role')).split(' ')[0];
|
|
47
|
+
if (explicit) return explicit;
|
|
48
|
+
const tag = element.tagName;
|
|
49
|
+
switch (tag) {
|
|
50
|
+
case 'A':
|
|
51
|
+
return element.hasAttribute('href') ? 'link' : 'generic';
|
|
52
|
+
case 'BUTTON':
|
|
53
|
+
case 'SUMMARY':
|
|
54
|
+
return 'button';
|
|
55
|
+
case 'INPUT': {
|
|
56
|
+
const type = inputType(element);
|
|
57
|
+
if (type === 'hidden') return null;
|
|
58
|
+
if (type === 'button' || type === 'submit' || type === 'reset' || type === 'image') return 'button';
|
|
59
|
+
if (type === 'checkbox') return 'checkbox';
|
|
60
|
+
if (type === 'radio') return 'radio';
|
|
61
|
+
if (type === 'range') return 'slider';
|
|
62
|
+
if (type === 'number') return 'spinbutton';
|
|
63
|
+
if (type === 'search') return 'searchbox';
|
|
64
|
+
return 'textbox';
|
|
65
|
+
}
|
|
66
|
+
case 'SELECT':
|
|
67
|
+
return element.multiple || element.size > 1 ? 'listbox' : 'combobox';
|
|
68
|
+
case 'TEXTAREA':
|
|
69
|
+
return 'textbox';
|
|
70
|
+
case 'OPTION':
|
|
71
|
+
return 'option';
|
|
72
|
+
case 'IMG':
|
|
73
|
+
return 'img';
|
|
74
|
+
case 'H1':
|
|
75
|
+
case 'H2':
|
|
76
|
+
case 'H3':
|
|
77
|
+
case 'H4':
|
|
78
|
+
case 'H5':
|
|
79
|
+
case 'H6':
|
|
80
|
+
return 'heading';
|
|
81
|
+
case 'UL':
|
|
82
|
+
case 'OL':
|
|
83
|
+
return 'list';
|
|
84
|
+
case 'LI':
|
|
85
|
+
return 'listitem';
|
|
86
|
+
case 'NAV':
|
|
87
|
+
return 'navigation';
|
|
88
|
+
case 'MAIN':
|
|
89
|
+
return 'main';
|
|
90
|
+
case 'HEADER':
|
|
91
|
+
return 'banner';
|
|
92
|
+
case 'FOOTER':
|
|
93
|
+
return 'contentinfo';
|
|
94
|
+
case 'ASIDE':
|
|
95
|
+
return 'complementary';
|
|
96
|
+
case 'FORM':
|
|
97
|
+
return 'form';
|
|
98
|
+
case 'TABLE':
|
|
99
|
+
return 'table';
|
|
100
|
+
case 'TR':
|
|
101
|
+
return 'row';
|
|
102
|
+
case 'TD':
|
|
103
|
+
return 'cell';
|
|
104
|
+
case 'TH':
|
|
105
|
+
return 'columnheader';
|
|
106
|
+
case 'DIALOG':
|
|
107
|
+
return 'dialog';
|
|
108
|
+
case 'P':
|
|
109
|
+
return 'paragraph';
|
|
110
|
+
case 'LABEL':
|
|
111
|
+
return 'label';
|
|
112
|
+
case 'FIELDSET':
|
|
113
|
+
case 'DETAILS':
|
|
114
|
+
return 'group';
|
|
115
|
+
case 'PROGRESS':
|
|
116
|
+
return 'progressbar';
|
|
117
|
+
case 'ARTICLE':
|
|
118
|
+
return 'article';
|
|
119
|
+
case 'SECTION':
|
|
120
|
+
return 'region';
|
|
121
|
+
case 'IFRAME':
|
|
122
|
+
return 'iframe';
|
|
123
|
+
default:
|
|
124
|
+
return 'generic';
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function isVisible(element) {
|
|
129
|
+
if (typeof element.checkVisibility === 'function') {
|
|
130
|
+
if (!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false;
|
|
131
|
+
} else {
|
|
132
|
+
const style = window.getComputedStyle(element);
|
|
133
|
+
if (style.visibility === 'hidden' || style.display === 'none' || style.opacity === '0') return false;
|
|
134
|
+
}
|
|
135
|
+
const rect = element.getBoundingClientRect();
|
|
136
|
+
return rect.width > 0 && rect.height > 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function labelText(element) {
|
|
140
|
+
const labelledBy = element.getAttribute('aria-labelledby');
|
|
141
|
+
if (labelledBy) {
|
|
142
|
+
const parts = [];
|
|
143
|
+
labelledBy.split(/\s+/).forEach((id) => {
|
|
144
|
+
const ref = id && document.getElementById(id);
|
|
145
|
+
if (ref) parts.push(normalize(ref.textContent));
|
|
146
|
+
});
|
|
147
|
+
if (parts.join(' ').trim()) return parts.join(' ').trim();
|
|
148
|
+
}
|
|
149
|
+
if (element.labels && element.labels.length > 0) {
|
|
150
|
+
const texts = [];
|
|
151
|
+
for (let i = 0; i < element.labels.length; i += 1) texts.push(controlLabel(element.labels[i], element));
|
|
152
|
+
const joined = normalize(texts.join(' '));
|
|
153
|
+
if (joined) return joined;
|
|
154
|
+
}
|
|
155
|
+
return '';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** A label's text without the text of the control it wraps (a select's options, say). */
|
|
159
|
+
function controlLabel(label, control) {
|
|
160
|
+
if (!label.contains(control)) return normalize(label.textContent);
|
|
161
|
+
let text = '';
|
|
162
|
+
const walker = document.createTreeWalker(label, NodeFilter.SHOW_TEXT);
|
|
163
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
164
|
+
if (!control.contains(node)) text += ` ${node.nodeValue}`;
|
|
165
|
+
}
|
|
166
|
+
return normalize(text);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function nameOf(element, role) {
|
|
170
|
+
const aria = normalize(element.getAttribute('aria-label'));
|
|
171
|
+
if (aria) return aria;
|
|
172
|
+
const tag = element.tagName;
|
|
173
|
+
if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') {
|
|
174
|
+
const type = tag === 'INPUT' ? inputType(element) : '';
|
|
175
|
+
if (type === 'button' || type === 'submit' || type === 'reset') return normalize(element.value) || normalize(element.title);
|
|
176
|
+
if (type === 'image') return normalize(element.alt) || normalize(element.title);
|
|
177
|
+
return labelText(element) || normalize(element.getAttribute('placeholder')) || normalize(element.title);
|
|
178
|
+
}
|
|
179
|
+
const labelled = labelText(element);
|
|
180
|
+
if (labelled) return labelled;
|
|
181
|
+
if (tag === 'IMG') return normalize(element.getAttribute('alt')) || normalize(element.title);
|
|
182
|
+
if (tag === 'LABEL') {
|
|
183
|
+
const control = element.querySelector('select, textarea');
|
|
184
|
+
if (control) return controlLabel(element, control) || normalize(element.title);
|
|
185
|
+
}
|
|
186
|
+
if (CONTENT_NAMED[role]) return normalize(element.textContent) || normalize(element.title);
|
|
187
|
+
return normalize(element.title);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Text directly inside the element (not in child elements). */
|
|
191
|
+
function ownText(element) {
|
|
192
|
+
let text = '';
|
|
193
|
+
for (let node = element.firstChild; node; node = node.nextSibling) {
|
|
194
|
+
if (node.nodeType === 3) text += ` ${node.nodeValue}`;
|
|
195
|
+
}
|
|
196
|
+
return normalize(text);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The text a text locator compares (same rules as dom-locate.js). */
|
|
200
|
+
function locatorText(element) {
|
|
201
|
+
if (element.tagName === 'INPUT') {
|
|
202
|
+
const type = inputType(element);
|
|
203
|
+
return type === 'button' || type === 'submit' || type === 'reset' ? normalize(element.value) : '';
|
|
204
|
+
}
|
|
205
|
+
return normalize(element.textContent);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function xpathOf(element) {
|
|
209
|
+
const steps = [];
|
|
210
|
+
for (let node = element; node && node.nodeType === 1; node = node.parentElement) {
|
|
211
|
+
const tag = node.tagName.toLowerCase();
|
|
212
|
+
let index = 1;
|
|
213
|
+
let same = 0;
|
|
214
|
+
for (let sibling = node.parentElement ? node.parentElement.firstElementChild : null; sibling; sibling = sibling.nextElementSibling) {
|
|
215
|
+
if (sibling.tagName === node.tagName) {
|
|
216
|
+
same += 1;
|
|
217
|
+
if (sibling === node) index = same;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
steps.unshift(same > 1 ? `${tag}[${index}]` : tag);
|
|
221
|
+
}
|
|
222
|
+
return `/${steps.join('/')}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function stateOf(element, role) {
|
|
226
|
+
const state = [];
|
|
227
|
+
const tag = element.tagName;
|
|
228
|
+
if (element.disabled === true || element.getAttribute('aria-disabled') === 'true') state.push('disabled');
|
|
229
|
+
if ((tag === 'INPUT' && (element.type === 'checkbox' || element.type === 'radio') && element.checked) || element.getAttribute('aria-checked') === 'true') {
|
|
230
|
+
state.push('checked');
|
|
231
|
+
}
|
|
232
|
+
if ((tag === 'OPTION' && element.selected) || element.getAttribute('aria-selected') === 'true') state.push('selected');
|
|
233
|
+
if (element.getAttribute('aria-expanded') === 'true') state.push('expanded');
|
|
234
|
+
if (element.required === true || element.getAttribute('aria-required') === 'true') state.push('required');
|
|
235
|
+
if (element.readOnly === true || element.getAttribute('aria-readonly') === 'true') state.push('readonly');
|
|
236
|
+
if (document.activeElement === element && element !== document.body) state.push('focused');
|
|
237
|
+
if (tag === 'INPUT' && inputType(element) === 'password') state.push('password');
|
|
238
|
+
if (role === 'heading' && /^H[1-6]$/.test(tag)) state.push(`level=${tag.slice(1)}`);
|
|
239
|
+
if (INTERACTIVE[role] || element.hasAttribute('onclick') || (element.tabIndex >= 0 && element.hasAttribute('tabindex'))) {
|
|
240
|
+
state.push('clickable');
|
|
241
|
+
} else {
|
|
242
|
+
try {
|
|
243
|
+
if (window.getComputedStyle(element).cursor === 'pointer') state.push('clickable');
|
|
244
|
+
} catch {
|
|
245
|
+
// Detached or odd element: no state.
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return state;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function cssEscape(value) {
|
|
252
|
+
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(value);
|
|
253
|
+
return String(value).replace(/[^a-zA-Z0-9_-]/g, (ch) => `\\${ch}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Duplicates for suggestions: data-testid values and innermost exact texts across the document.
|
|
257
|
+
const testIdCounts = {};
|
|
258
|
+
const testIdSeen = {};
|
|
259
|
+
const textCounts = {};
|
|
260
|
+
const textSeen = {};
|
|
261
|
+
const innermost = typeof WeakMap === 'function' ? new WeakMap() : null;
|
|
262
|
+
if (document.body) {
|
|
263
|
+
const every = document.body.querySelectorAll('*');
|
|
264
|
+
for (let k = 0; k < every.length; k += 1) {
|
|
265
|
+
const element = every[k];
|
|
266
|
+
if (SKIP[element.tagName]) continue;
|
|
267
|
+
const tid = element.getAttribute('data-testid');
|
|
268
|
+
if (tid !== null) testIdCounts[tid] = (testIdCounts[tid] || 0) + 1;
|
|
269
|
+
const text = locatorText(element);
|
|
270
|
+
if (!text || text.length > MAX_TEXT || !innermost) continue;
|
|
271
|
+
let inner = true;
|
|
272
|
+
for (let child = element.firstElementChild; child; child = child.nextElementSibling) {
|
|
273
|
+
if (!SKIP[child.tagName] && locatorText(child) === text) {
|
|
274
|
+
inner = false;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if (!inner) continue;
|
|
279
|
+
textCounts[text] = (textCounts[text] || 0) + 1;
|
|
280
|
+
innermost.set(element, text);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const elements = [];
|
|
285
|
+
let truncated = false;
|
|
286
|
+
let count = 0;
|
|
287
|
+
|
|
288
|
+
function hasInterestingDescendant(element) {
|
|
289
|
+
const all = element.querySelectorAll('a[href],button,input,select,textarea,[role],[data-testid],[tabindex],[onclick]');
|
|
290
|
+
return all.length > 0;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function visit(element, depth) {
|
|
294
|
+
if (SKIP[element.tagName]) return [];
|
|
295
|
+
if (!includeHidden && !isVisible(element)) {
|
|
296
|
+
// `display: contents` wrappers have no box but visible children.
|
|
297
|
+
const style = window.getComputedStyle(element);
|
|
298
|
+
if (style.display !== 'contents') return [];
|
|
299
|
+
}
|
|
300
|
+
const role = roleOf(element);
|
|
301
|
+
if (role === null) return [];
|
|
302
|
+
const name = nameOf(element, role);
|
|
303
|
+
const testId = element.getAttribute('data-testid');
|
|
304
|
+
const id = element.id || '';
|
|
305
|
+
const own = element.tagName === 'INPUT' || element.tagName === 'TEXTAREA' ? '' : ownText(element);
|
|
306
|
+
const layoutOnly = (role === 'generic' || role === 'group' || role === 'none' || role === 'presentation') && !name && !own && testId === null;
|
|
307
|
+
const interactiveish = INTERACTIVE[role] || element.hasAttribute('onclick');
|
|
308
|
+
|
|
309
|
+
function children(nextDepth) {
|
|
310
|
+
let out = [];
|
|
311
|
+
for (let child = element.firstElementChild; child; child = child.nextElementSibling) {
|
|
312
|
+
out = out.concat(visit(child, nextDepth));
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (layoutOnly && !interactiveish) return children(depth);
|
|
318
|
+
if (depth >= MAX_DEPTH) {
|
|
319
|
+
truncated = true;
|
|
320
|
+
return [];
|
|
321
|
+
}
|
|
322
|
+
if (count >= MAX_NODES) {
|
|
323
|
+
truncated = true;
|
|
324
|
+
return [];
|
|
325
|
+
}
|
|
326
|
+
count += 1;
|
|
327
|
+
const node = { i: elements.length, role: role, xpath: xpathOf(element) };
|
|
328
|
+
elements.push(element);
|
|
329
|
+
if (name) node.name = clip(name);
|
|
330
|
+
const password = element.tagName === 'INPUT' && inputType(element) === 'password';
|
|
331
|
+
let text = '';
|
|
332
|
+
if ((element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') && !password && role === 'textbox') text = normalize(element.value);
|
|
333
|
+
else if (!CONTENT_NAMED[role] || !name) text = own;
|
|
334
|
+
if (text && text !== name) node.text = clip(text);
|
|
335
|
+
if (testId !== null) {
|
|
336
|
+
node.test_id = testId;
|
|
337
|
+
node.test_id_count = testIdCounts[testId] || 1;
|
|
338
|
+
testIdSeen[testId] = (testIdSeen[testId] || 0) + 1;
|
|
339
|
+
node.test_id_index = testIdSeen[testId] - 1;
|
|
340
|
+
}
|
|
341
|
+
if (id) {
|
|
342
|
+
node.id = id;
|
|
343
|
+
node.id_selector = `#${cssEscape(id)}`;
|
|
344
|
+
try {
|
|
345
|
+
node.id_count = document.querySelectorAll(node.id_selector).length;
|
|
346
|
+
} catch {
|
|
347
|
+
node.id_count = 0;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const key = innermost ? innermost.get(element) : undefined;
|
|
351
|
+
if (key !== undefined) {
|
|
352
|
+
node.text_key = key;
|
|
353
|
+
node.text_count = textCounts[key] || 1;
|
|
354
|
+
textSeen[key] = (textSeen[key] || 0) + 1;
|
|
355
|
+
node.text_index = textSeen[key] - 1;
|
|
356
|
+
}
|
|
357
|
+
const rect = element.getBoundingClientRect();
|
|
358
|
+
if (rect.width > 0 || rect.height > 0) {
|
|
359
|
+
node.bounds = { x: Math.round(rect.left), y: Math.round(rect.top), width: Math.round(rect.width), height: Math.round(rect.height) };
|
|
360
|
+
}
|
|
361
|
+
const state = stateOf(element, role);
|
|
362
|
+
if (state.length > 0) node.state = state;
|
|
363
|
+
if (!(LEAF_ROLES[role] && !hasInterestingDescendant(element))) {
|
|
364
|
+
const kids = children(depth + 1);
|
|
365
|
+
if (kids.length > 0) node.children = kids;
|
|
366
|
+
}
|
|
367
|
+
return [node];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
let nodes = document.body ? visit(document.body, 0) : [];
|
|
371
|
+
// The body itself is layout: its children are the top level.
|
|
372
|
+
if (nodes.length === 1 && elements[nodes[0].i] === document.body && nodes[0].role === 'generic' && !nodes[0].name) {
|
|
373
|
+
nodes = nodes[0].children || [];
|
|
374
|
+
}
|
|
375
|
+
return { viewport: viewport, url: String(location.href), title: document.title, truncated: truncated, count: count, nodes: nodes, elements: elements };
|
|
376
|
+
})
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { clampMaxChars, clipText, renderSnapshotText, } from '../snapshot/format.js';
|
|
2
|
+
import { MAX_XPATH_CHARS } from './dom.js';
|
|
3
|
+
import { elementIdOf } from './locate.js';
|
|
4
|
+
/** The screen a DOM snapshot (or its viewport-only form) describes. */
|
|
5
|
+
export function domScreen(raw, appId = null) {
|
|
6
|
+
const width = numberOrNull(raw.viewport?.width);
|
|
7
|
+
const height = numberOrNull(raw.viewport?.height);
|
|
8
|
+
return {
|
|
9
|
+
width,
|
|
10
|
+
height,
|
|
11
|
+
scale: numberOrNull(raw.viewport?.scale),
|
|
12
|
+
orientation: width !== null && height !== null ? (width >= height ? 'landscape' : 'portrait') : null,
|
|
13
|
+
activity: null,
|
|
14
|
+
url: typeof raw.url === 'string' ? raw.url : null,
|
|
15
|
+
title: typeof raw.title === 'string' ? raw.title : null,
|
|
16
|
+
package: appId,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function numberOrNull(value) {
|
|
20
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
21
|
+
}
|
|
22
|
+
/** The locators a node can be found with, best first. */
|
|
23
|
+
export function domCandidatesFor(node) {
|
|
24
|
+
const out = [];
|
|
25
|
+
if (node.test_id)
|
|
26
|
+
out.push({ target: { test_id: node.test_id }, count: node.test_id_count ?? 1, index: node.test_id_index ?? 0 });
|
|
27
|
+
if (node.id_selector && node.id_count === 1)
|
|
28
|
+
out.push({ target: { css: node.id_selector }, count: 1, index: 0 });
|
|
29
|
+
if (node.text_key && !node.state?.includes('password')) {
|
|
30
|
+
out.push({ target: { text: node.text_key, exact: true }, count: node.text_count ?? 1, index: node.text_index ?? 0 });
|
|
31
|
+
}
|
|
32
|
+
if (node.xpath && node.xpath.length <= MAX_XPATH_CHARS)
|
|
33
|
+
out.push({ target: { xpath: node.xpath }, count: 1, index: 0 });
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/** The suggestion for a node: unique best candidate, or the best with `nth` plus the next-best as a fallback. */
|
|
37
|
+
export function suggestFor(node) {
|
|
38
|
+
const candidates = domCandidatesFor(node);
|
|
39
|
+
const first = candidates[0];
|
|
40
|
+
if (!first)
|
|
41
|
+
return { suggest: { xpath: node.xpath }, alternatives: [], fragile: true };
|
|
42
|
+
const alternatives = candidates.slice(1).map((candidate) => (candidate.count > 1 ? { ...candidate.target, nth: candidate.index } : candidate.target));
|
|
43
|
+
if (first.count <= 1)
|
|
44
|
+
return { suggest: first.target, alternatives, fragile: 'xpath' in first.target };
|
|
45
|
+
const next = candidates.slice(1).find((candidate) => candidate.count <= 1) ?? candidates[1];
|
|
46
|
+
const suggest = { ...first.target, nth: first.index };
|
|
47
|
+
if (next)
|
|
48
|
+
suggest.fallbacks = [next.count > 1 ? { ...next.target, nth: next.index } : next.target];
|
|
49
|
+
return { suggest, alternatives, fragile: true };
|
|
50
|
+
}
|
|
51
|
+
/** Builds the snapshot and the ref → element map from the script's result. */
|
|
52
|
+
export function buildDomSnapshot(raw, options = {}) {
|
|
53
|
+
const rawElements = Array.isArray(raw.elements) ? raw.elements : [];
|
|
54
|
+
const elements = new Map();
|
|
55
|
+
const sources = new Map();
|
|
56
|
+
let next = 1;
|
|
57
|
+
const convert = (node) => {
|
|
58
|
+
const ref = `e${next++}`;
|
|
59
|
+
const reference = rawElements[node.i];
|
|
60
|
+
if (reference !== undefined) {
|
|
61
|
+
try {
|
|
62
|
+
elements.set(ref, elementIdOf(reference));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Not an element reference: the node stays in the tree without a ref target.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
sources.set(ref, node);
|
|
69
|
+
const out = { ref, role: String(node.role || 'generic') };
|
|
70
|
+
if (node.name)
|
|
71
|
+
out.name = clipText(node.name);
|
|
72
|
+
if (node.text)
|
|
73
|
+
out.text = clipText(node.text);
|
|
74
|
+
const ids = {};
|
|
75
|
+
if (node.test_id)
|
|
76
|
+
ids.test_id = node.test_id;
|
|
77
|
+
if (node.id)
|
|
78
|
+
ids.resource_id = node.id;
|
|
79
|
+
if (Object.keys(ids).length > 0)
|
|
80
|
+
out.ids = ids;
|
|
81
|
+
if (node.bounds)
|
|
82
|
+
out.bounds = node.bounds;
|
|
83
|
+
if (node.state && node.state.length > 0)
|
|
84
|
+
out.state = node.state;
|
|
85
|
+
const children = (node.children ?? []).map(convert);
|
|
86
|
+
if (children.length > 0)
|
|
87
|
+
out.children = children;
|
|
88
|
+
return out;
|
|
89
|
+
};
|
|
90
|
+
const forest = (raw.nodes ?? []).map(convert);
|
|
91
|
+
const root = forest.length === 0 ? null : forest.length === 1 ? forest[0] : { ref: 'e0', role: 'document', ...(raw.title ? { name: clipText(raw.title) } : {}), children: forest };
|
|
92
|
+
const rendered = renderSnapshotText(root, clampMaxChars(options.maxChars));
|
|
93
|
+
const refs = {};
|
|
94
|
+
for (const [ref, node] of sources) {
|
|
95
|
+
if (!rendered.refs.has(ref) || !elements.has(ref))
|
|
96
|
+
continue;
|
|
97
|
+
refs[ref] = { locate: { dom_ref: ref }, ...suggestFor(node), bounds: node.bounds ?? null };
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
snapshot: {
|
|
101
|
+
platform: options.platform ?? 'tauri',
|
|
102
|
+
taken_at: options.takenAt ?? new Date().toISOString(),
|
|
103
|
+
screen: domScreen(raw, options.appId ?? null),
|
|
104
|
+
...(options.appId ? { app: { id: options.appId } } : {}),
|
|
105
|
+
root,
|
|
106
|
+
text: rendered.text,
|
|
107
|
+
refs,
|
|
108
|
+
element_count: typeof raw.count === 'number' ? raw.count : sources.size,
|
|
109
|
+
truncated: raw.truncated === true || rendered.truncated,
|
|
110
|
+
},
|
|
111
|
+
elements,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** The ref a `locate` from a DOM snapshot names, or null. */
|
|
115
|
+
export function domRefOf(locate) {
|
|
116
|
+
const ref = locate?.dom_ref;
|
|
117
|
+
return typeof ref === 'string' && /^e\d{1,6}$/.test(ref) ? ref : null;
|
|
118
|
+
}
|