@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,351 @@
|
|
|
1
|
+
import { clampDepth, clampMaxChars, clipText, renderSnapshotText, SNAPSHOT_LIMITS, } from './format.js';
|
|
2
|
+
/** Roles whose accessible name comes from a label, so `{label}` is a good alternative. */
|
|
3
|
+
const FIELD_ROLES = new Set(['textbox', 'searchbox', 'combobox', 'spinbutton', 'checkbox', 'radio', 'switch', 'slider', 'listbox']);
|
|
4
|
+
/** Roles whose name is their visible text, so `{text}` is a good alternative. */
|
|
5
|
+
const TEXT_ROLES = new Set(['button', 'link', 'heading', 'menuitem', 'tab', 'option', 'cell', 'listitem', 'paragraph', 'label']);
|
|
6
|
+
const LAYOUT_ROLES = new Set(['generic', 'group', 'none', 'presentation']);
|
|
7
|
+
const STATE_ATTRS = new Set(['checked', 'disabled', 'expanded', 'selected', 'pressed', 'required', 'readonly', 'invalid']);
|
|
8
|
+
const REF_PATTERN = /^[a-z0-9]{1,16}$/i;
|
|
9
|
+
/** Parses Playwright's aria snapshot text (the YAML-like list) into a forest. */
|
|
10
|
+
export function parseAriaSnapshot(text) {
|
|
11
|
+
const roots = [];
|
|
12
|
+
const stack = [];
|
|
13
|
+
for (const raw of text.split('\n')) {
|
|
14
|
+
const match = /^(\s*)- (.*)$/.exec(raw);
|
|
15
|
+
if (!match)
|
|
16
|
+
continue;
|
|
17
|
+
const indent = match[1].length;
|
|
18
|
+
while (stack.length > 0 && stack.at(-1).indent >= indent)
|
|
19
|
+
stack.pop();
|
|
20
|
+
const parent = stack.at(-1)?.node;
|
|
21
|
+
const body = match[2];
|
|
22
|
+
if (body.startsWith('/')) {
|
|
23
|
+
const colon = body.indexOf(':');
|
|
24
|
+
if (parent && colon > 1)
|
|
25
|
+
parent.props[body.slice(1, colon)] = unquote(body.slice(colon + 1).trim());
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const node = parseLine(body);
|
|
29
|
+
if (parent)
|
|
30
|
+
parent.children.push(node);
|
|
31
|
+
else
|
|
32
|
+
roots.push(node);
|
|
33
|
+
stack.push({ indent, node });
|
|
34
|
+
}
|
|
35
|
+
return roots;
|
|
36
|
+
}
|
|
37
|
+
function parseLine(body) {
|
|
38
|
+
let key = body;
|
|
39
|
+
let value;
|
|
40
|
+
// A key YAML had to quote: 'link "a: b" [ref=e4]': value
|
|
41
|
+
if (body.startsWith("'")) {
|
|
42
|
+
let i = 1;
|
|
43
|
+
let unquoted = '';
|
|
44
|
+
while (i < body.length) {
|
|
45
|
+
if (body[i] === "'" && body[i + 1] === "'") {
|
|
46
|
+
unquoted += "'";
|
|
47
|
+
i += 2;
|
|
48
|
+
}
|
|
49
|
+
else if (body[i] === "'") {
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
unquoted += body[i];
|
|
54
|
+
i += 1;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
key = unquoted;
|
|
58
|
+
const rest = body.slice(i + 1).trim();
|
|
59
|
+
if (rest.startsWith(':'))
|
|
60
|
+
value = unquote(rest.slice(1).trim()) || undefined;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const split = splitKeyValue(body);
|
|
64
|
+
key = split.key;
|
|
65
|
+
value = split.value;
|
|
66
|
+
}
|
|
67
|
+
const node = { role: '', attrs: {}, props: {}, children: [] };
|
|
68
|
+
let rest = key.trim();
|
|
69
|
+
const role = /^[a-zA-Z][\w-]*/.exec(rest);
|
|
70
|
+
node.role = role ? role[0] : rest;
|
|
71
|
+
rest = rest.slice(node.role.length).trim();
|
|
72
|
+
if (rest.startsWith('"')) {
|
|
73
|
+
const end = closingQuote(rest);
|
|
74
|
+
node.name = JSON.parse(rest.slice(0, end + 1));
|
|
75
|
+
rest = rest.slice(end + 1).trim();
|
|
76
|
+
}
|
|
77
|
+
for (const attr of rest.matchAll(/\[([\w-]+)(?:=([^\]]*))?\]/g)) {
|
|
78
|
+
const name = attr[1];
|
|
79
|
+
const attrValue = attr[2];
|
|
80
|
+
if (name === 'ref')
|
|
81
|
+
node.ref = attrValue ?? '';
|
|
82
|
+
else
|
|
83
|
+
node.attrs[name] = attrValue ?? true;
|
|
84
|
+
}
|
|
85
|
+
if (node.role === 'text' && value === undefined && node.name !== undefined) {
|
|
86
|
+
value = node.name;
|
|
87
|
+
delete node.name;
|
|
88
|
+
}
|
|
89
|
+
if (value !== undefined)
|
|
90
|
+
node.value = value;
|
|
91
|
+
return node;
|
|
92
|
+
}
|
|
93
|
+
/** Splits `role "name" [attr]: value`, ignoring colons inside the quoted name. */
|
|
94
|
+
function splitKeyValue(body) {
|
|
95
|
+
let inQuote = false;
|
|
96
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
97
|
+
const ch = body[i];
|
|
98
|
+
if (ch === '\\' && inQuote) {
|
|
99
|
+
i += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (ch === '"')
|
|
103
|
+
inQuote = !inQuote;
|
|
104
|
+
if (ch === ':' && !inQuote && (i === body.length - 1 || body[i + 1] === ' ')) {
|
|
105
|
+
const value = unquote(body.slice(i + 1).trim());
|
|
106
|
+
return value ? { key: body.slice(0, i), value } : { key: body.slice(0, i) };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { key: body };
|
|
110
|
+
}
|
|
111
|
+
function closingQuote(text) {
|
|
112
|
+
for (let i = 1; i < text.length; i += 1) {
|
|
113
|
+
if (text[i] === '\\')
|
|
114
|
+
i += 1;
|
|
115
|
+
else if (text[i] === '"')
|
|
116
|
+
return i;
|
|
117
|
+
}
|
|
118
|
+
return text.length - 1;
|
|
119
|
+
}
|
|
120
|
+
function unquote(value) {
|
|
121
|
+
if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(value);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return value.slice(1, -1);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (value.length >= 2 && value.startsWith("'") && value.endsWith("'"))
|
|
130
|
+
return value.slice(1, -1).replace(/''/g, "'");
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
function parseBox(value) {
|
|
134
|
+
if (typeof value !== 'string')
|
|
135
|
+
return undefined;
|
|
136
|
+
const parts = value.split(',').map((part) => Math.round(Number(part)));
|
|
137
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isFinite(part)))
|
|
138
|
+
return undefined;
|
|
139
|
+
const [x, y, width, height] = parts;
|
|
140
|
+
return { x, y, width, height };
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Turns the parsed aria forest into a snapshot: invisible (zero-size) elements dropped unless
|
|
144
|
+
* `includeHidden`, unnamed layout wrappers hoisted, depth ≤ 25, ≤ 400 elements, text
|
|
145
|
+
* clipped to `maxChars`, and a suggested DSL target per ref.
|
|
146
|
+
*/
|
|
147
|
+
export function buildWebSnapshot(forest, options = {}) {
|
|
148
|
+
const maxDepth = clampDepth(options.depth);
|
|
149
|
+
const secretRefs = options.secretRefs ?? new Set();
|
|
150
|
+
let count = 0;
|
|
151
|
+
let truncated = false;
|
|
152
|
+
const ariaByRef = new Map();
|
|
153
|
+
const convert = (node, depth) => {
|
|
154
|
+
const bounds = parseBox(node.attrs.box);
|
|
155
|
+
if (!options.includeHidden && bounds && bounds.width === 0 && bounds.height === 0)
|
|
156
|
+
return [];
|
|
157
|
+
const childDepth = node.ref ? depth + 1 : depth;
|
|
158
|
+
const children = () => {
|
|
159
|
+
if (childDepth > maxDepth) {
|
|
160
|
+
if (node.children.some((child) => child.ref))
|
|
161
|
+
truncated = true;
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
return node.children.flatMap((child) => convert(child, childDepth));
|
|
165
|
+
};
|
|
166
|
+
// Plain text lines, and elements without a ref, only contribute their children.
|
|
167
|
+
if (!node.ref || !REF_PATTERN.test(node.ref))
|
|
168
|
+
return node.role === 'text' ? [] : children();
|
|
169
|
+
const texts = node.children.filter((child) => child.role === 'text' && child.value).map((child) => child.value);
|
|
170
|
+
const layoutOnly = LAYOUT_ROLES.has(node.role) && !node.name && texts.length === 0 && !node.value && depth > 0;
|
|
171
|
+
if (layoutOnly)
|
|
172
|
+
return children();
|
|
173
|
+
if (count >= SNAPSHOT_LIMITS.nodes) {
|
|
174
|
+
truncated = true;
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
count += 1;
|
|
178
|
+
ariaByRef.set(node.ref, node);
|
|
179
|
+
const secret = secretRefs.has(node.ref);
|
|
180
|
+
const out = { ref: node.ref, role: node.role };
|
|
181
|
+
if (node.name)
|
|
182
|
+
out.name = clipText(node.name);
|
|
183
|
+
const text = secret ? undefined : (node.value ?? texts.join(' '));
|
|
184
|
+
if (text)
|
|
185
|
+
out.text = clipText(text);
|
|
186
|
+
if (bounds)
|
|
187
|
+
out.bounds = bounds;
|
|
188
|
+
const state = stateOf(node, secret);
|
|
189
|
+
if (state.length > 0)
|
|
190
|
+
out.state = state;
|
|
191
|
+
const kids = children();
|
|
192
|
+
if (kids.length > 0)
|
|
193
|
+
out.children = kids;
|
|
194
|
+
return [out];
|
|
195
|
+
};
|
|
196
|
+
const nodes = forest.flatMap((node) => convert(node, 0));
|
|
197
|
+
const root = nodes.length === 0 ? null : nodes.length === 1 ? nodes[0] : { ref: 'e0', role: 'document', children: nodes };
|
|
198
|
+
const rendered = renderSnapshotText(root, clampMaxChars(options.maxChars));
|
|
199
|
+
const refs = suggestTargets(root, ariaByRef, rendered.refs);
|
|
200
|
+
return {
|
|
201
|
+
platform: 'web',
|
|
202
|
+
taken_at: options.takenAt ?? new Date().toISOString(),
|
|
203
|
+
screen: options.screen ?? null,
|
|
204
|
+
root,
|
|
205
|
+
text: rendered.text,
|
|
206
|
+
refs,
|
|
207
|
+
element_count: count,
|
|
208
|
+
truncated: truncated || rendered.truncated,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function stateOf(node, secret) {
|
|
212
|
+
const state = [];
|
|
213
|
+
for (const [name, value] of Object.entries(node.attrs)) {
|
|
214
|
+
if (name === 'box' || name === 'ref')
|
|
215
|
+
continue;
|
|
216
|
+
if (name === 'active')
|
|
217
|
+
state.push('focused');
|
|
218
|
+
else if (name === 'cursor' && value === 'pointer')
|
|
219
|
+
state.push('clickable');
|
|
220
|
+
else if (name === 'level')
|
|
221
|
+
state.push(`level=${value}`);
|
|
222
|
+
else if (STATE_ATTRS.has(name))
|
|
223
|
+
state.push(value === true || value === 'true' ? name : `${name}=${value}`);
|
|
224
|
+
}
|
|
225
|
+
if (node.props.url)
|
|
226
|
+
state.push(`url=${clipText(node.props.url)}`);
|
|
227
|
+
if (node.props.placeholder)
|
|
228
|
+
state.push(`placeholder="${clipText(node.props.placeholder)}"`);
|
|
229
|
+
if (secret)
|
|
230
|
+
state.push('password');
|
|
231
|
+
return state;
|
|
232
|
+
}
|
|
233
|
+
/** Suggested locators: role+name first, then label/text/placeholder; duplicates get `nth` and a fallback. */
|
|
234
|
+
function suggestTargets(root, aria, shown) {
|
|
235
|
+
const all = [];
|
|
236
|
+
const walk = (node) => {
|
|
237
|
+
const source = aria.get(node.ref);
|
|
238
|
+
if (source)
|
|
239
|
+
all.push({ node, ...candidates(source) });
|
|
240
|
+
for (const child of node.children ?? [])
|
|
241
|
+
walk(child);
|
|
242
|
+
};
|
|
243
|
+
if (root)
|
|
244
|
+
walk(root);
|
|
245
|
+
const seen = new Map();
|
|
246
|
+
const totals = new Map();
|
|
247
|
+
for (const entry of all) {
|
|
248
|
+
const key = JSON.stringify(entry.primary);
|
|
249
|
+
totals.set(key, (totals.get(key) ?? 0) + 1);
|
|
250
|
+
}
|
|
251
|
+
const refs = {};
|
|
252
|
+
for (const entry of all) {
|
|
253
|
+
const key = JSON.stringify(entry.primary);
|
|
254
|
+
const index = seen.get(key) ?? 0;
|
|
255
|
+
seen.set(key, index + 1);
|
|
256
|
+
let suggest = entry.primary;
|
|
257
|
+
let fragile = entry.fragile;
|
|
258
|
+
if ((totals.get(key) ?? 1) > 1) {
|
|
259
|
+
suggest = { ...entry.primary, nth: index };
|
|
260
|
+
fragile = true;
|
|
261
|
+
if (entry.alternatives[0])
|
|
262
|
+
suggest.fallbacks = [entry.alternatives[0]];
|
|
263
|
+
}
|
|
264
|
+
if (!shown.has(entry.node.ref))
|
|
265
|
+
continue;
|
|
266
|
+
refs[entry.node.ref] = {
|
|
267
|
+
locate: { aria_ref: entry.node.ref },
|
|
268
|
+
suggest,
|
|
269
|
+
alternatives: entry.alternatives,
|
|
270
|
+
fragile,
|
|
271
|
+
bounds: entry.node.bounds ?? null,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
return refs;
|
|
275
|
+
}
|
|
276
|
+
function candidates(node) {
|
|
277
|
+
const alternatives = [];
|
|
278
|
+
const name = node.name && node.name.length <= SNAPSHOT_LIMITS.nameChars ? node.name : undefined;
|
|
279
|
+
if (name) {
|
|
280
|
+
if (FIELD_ROLES.has(node.role))
|
|
281
|
+
alternatives.push({ label: name, exact: true });
|
|
282
|
+
if (TEXT_ROLES.has(node.role))
|
|
283
|
+
alternatives.push({ text: name, exact: true });
|
|
284
|
+
if (node.props.placeholder)
|
|
285
|
+
alternatives.push({ placeholder: node.props.placeholder, exact: true });
|
|
286
|
+
return { primary: { role: node.role, name, exact: true }, alternatives, fragile: false };
|
|
287
|
+
}
|
|
288
|
+
if (node.props.placeholder) {
|
|
289
|
+
return { primary: { placeholder: node.props.placeholder, exact: true }, alternatives: [{ role: node.role }], fragile: false };
|
|
290
|
+
}
|
|
291
|
+
const text = node.value ?? node.children.find((child) => child.role === 'text' && child.value)?.value;
|
|
292
|
+
if (text && text.length <= SNAPSHOT_LIMITS.nameChars && !FIELD_ROLES.has(node.role)) {
|
|
293
|
+
return { primary: { text, exact: true }, alternatives: [{ role: node.role }], fragile: false };
|
|
294
|
+
}
|
|
295
|
+
return { primary: { role: node.role }, alternatives: [], fragile: true };
|
|
296
|
+
}
|
|
297
|
+
/** The page's screen: viewport, URL and title. */
|
|
298
|
+
export async function webScreen(page) {
|
|
299
|
+
const viewport = page.viewportSize();
|
|
300
|
+
return {
|
|
301
|
+
width: viewport?.width ?? null,
|
|
302
|
+
height: viewport?.height ?? null,
|
|
303
|
+
scale: null,
|
|
304
|
+
orientation: viewport ? (viewport.width >= viewport.height ? 'landscape' : 'portrait') : null,
|
|
305
|
+
activity: null,
|
|
306
|
+
url: page.url(),
|
|
307
|
+
title: await page.title().catch(() => null),
|
|
308
|
+
package: null,
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/** Takes a web snapshot of `page`; password field values are left out. */
|
|
312
|
+
export async function webSnapshot(page, options = {}) {
|
|
313
|
+
const depth = clampDepth(options.depth);
|
|
314
|
+
const yaml = await page.ariaSnapshot({ mode: 'ai', boxes: true, depth: depth + 5 });
|
|
315
|
+
const forest = parseAriaSnapshot(yaml);
|
|
316
|
+
const secretRefs = new Set();
|
|
317
|
+
const fields = [];
|
|
318
|
+
const collect = (nodes) => {
|
|
319
|
+
for (const node of nodes) {
|
|
320
|
+
if (node.ref && node.role === 'textbox' && REF_PATTERN.test(node.ref))
|
|
321
|
+
fields.push(node.ref);
|
|
322
|
+
collect(node.children);
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
collect(forest);
|
|
326
|
+
for (const ref of fields.slice(0, 100)) {
|
|
327
|
+
const secret = await page
|
|
328
|
+
.locator(`aria-ref=${ref}`)
|
|
329
|
+
.evaluate((element) => element instanceof HTMLInputElement && element.type === 'password', undefined, { timeout: 1_000 })
|
|
330
|
+
.catch(() => true);
|
|
331
|
+
if (secret)
|
|
332
|
+
secretRefs.add(ref);
|
|
333
|
+
}
|
|
334
|
+
return buildWebSnapshot(forest, {
|
|
335
|
+
depth,
|
|
336
|
+
...(options.includeHidden !== undefined ? { includeHidden: options.includeHidden } : {}),
|
|
337
|
+
...(options.maxChars !== undefined ? { maxChars: options.maxChars } : {}),
|
|
338
|
+
secretRefs,
|
|
339
|
+
screen: await webScreen(page),
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
/** The selector for a snapshot ref, or null when `locate` is not a web ref. */
|
|
343
|
+
export function ariaRefSelector(locate) {
|
|
344
|
+
const ref = locate?.aria_ref;
|
|
345
|
+
return typeof ref === 'string' && REF_PATTERN.test(ref) ? `aria-ref=${ref}` : null;
|
|
346
|
+
}
|
|
347
|
+
/** The ref at the top of an element's own AI-mode aria snapshot. */
|
|
348
|
+
export function refFromElementSnapshot(yaml) {
|
|
349
|
+
const match = /\[ref=([a-z0-9]{1,16})\]/i.exec(yaml);
|
|
350
|
+
return match ? match[1] : null;
|
|
351
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export const SNAPSHOT_LIMITS = {
|
|
2
|
+
depth: 25,
|
|
3
|
+
nodes: 400,
|
|
4
|
+
textChars: 12_000,
|
|
5
|
+
maxTextChars: 60_000,
|
|
6
|
+
nameChars: 80,
|
|
7
|
+
};
|
|
8
|
+
export function emptyScreen() {
|
|
9
|
+
return { width: null, height: null, scale: null, orientation: null, activity: null, url: null, title: null, package: null };
|
|
10
|
+
}
|
|
11
|
+
/** Clamps a requested `max_chars` to 1 000–60 000 (default 12 000). */
|
|
12
|
+
export function clampMaxChars(requested) {
|
|
13
|
+
if (requested === undefined || !Number.isFinite(requested))
|
|
14
|
+
return SNAPSHOT_LIMITS.textChars;
|
|
15
|
+
return Math.max(1_000, Math.min(SNAPSHOT_LIMITS.maxTextChars, Math.floor(requested)));
|
|
16
|
+
}
|
|
17
|
+
/** Clamps a requested depth to 1–25 (default 25). */
|
|
18
|
+
export function clampDepth(requested) {
|
|
19
|
+
if (requested === undefined || !Number.isFinite(requested))
|
|
20
|
+
return SNAPSHOT_LIMITS.depth;
|
|
21
|
+
return Math.max(1, Math.min(SNAPSHOT_LIMITS.depth, Math.floor(requested)));
|
|
22
|
+
}
|
|
23
|
+
export function clipText(value, max = SNAPSHOT_LIMITS.nameChars) {
|
|
24
|
+
const single = value.replace(/\s+/g, ' ').trim();
|
|
25
|
+
return single.length > max ? `${single.slice(0, max - 1)}…` : single;
|
|
26
|
+
}
|
|
27
|
+
function quote(value) {
|
|
28
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
29
|
+
}
|
|
30
|
+
/** One node as a line, without indentation: `[e4] button id=login_button "Sign in" {48,760,984,140} clickable`. */
|
|
31
|
+
export function formatNodeLine(node) {
|
|
32
|
+
const parts = [`[${node.ref}]`, node.role];
|
|
33
|
+
if (node.ids?.resource_id)
|
|
34
|
+
parts.push(`id=${node.ids.resource_id}`);
|
|
35
|
+
if (node.ids?.accessibility_id)
|
|
36
|
+
parts.push(`aid=${node.ids.accessibility_id}`);
|
|
37
|
+
if (node.ids?.test_id && node.ids.test_id !== node.ids.resource_id && node.ids.test_id !== node.ids.accessibility_id) {
|
|
38
|
+
parts.push(`tid=${node.ids.test_id}`);
|
|
39
|
+
}
|
|
40
|
+
if (node.name)
|
|
41
|
+
parts.push(quote(node.name));
|
|
42
|
+
if (node.text && node.text !== node.name)
|
|
43
|
+
parts.push(`text=${quote(node.text)}`);
|
|
44
|
+
if (node.bounds) {
|
|
45
|
+
const { x, y, width, height } = node.bounds;
|
|
46
|
+
parts.push(`{${x},${y},${width},${height}}`);
|
|
47
|
+
}
|
|
48
|
+
if (node.state && node.state.length > 0)
|
|
49
|
+
parts.push(node.state.join(' '));
|
|
50
|
+
return parts.join(' ');
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Renders the tree depth-first, two spaces per level. Stops before a line would push the
|
|
54
|
+
* text past `maxChars` and says how many elements were left out.
|
|
55
|
+
*/
|
|
56
|
+
export function renderSnapshotText(root, maxChars = SNAPSHOT_LIMITS.textChars) {
|
|
57
|
+
const lines = [];
|
|
58
|
+
const refs = new Set();
|
|
59
|
+
let used = 0;
|
|
60
|
+
let count = 0;
|
|
61
|
+
let omitted = 0;
|
|
62
|
+
const budget = Math.max(0, maxChars - 120); // room for the truncation note
|
|
63
|
+
const visit = (node, depth) => {
|
|
64
|
+
count += 1;
|
|
65
|
+
const line = `${' '.repeat(depth)}${formatNodeLine(node)}`;
|
|
66
|
+
if (omitted === 0 && used + line.length + 1 <= budget) {
|
|
67
|
+
lines.push(line);
|
|
68
|
+
refs.add(node.ref);
|
|
69
|
+
used += line.length + 1;
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
omitted += 1;
|
|
73
|
+
}
|
|
74
|
+
for (const child of node.children ?? [])
|
|
75
|
+
visit(child, depth + 1);
|
|
76
|
+
};
|
|
77
|
+
if (root)
|
|
78
|
+
visit(root, 0);
|
|
79
|
+
const truncated = omitted > 0;
|
|
80
|
+
if (truncated) {
|
|
81
|
+
lines.push(`… ${omitted} more element${omitted === 1 ? '' : 's'} not shown (use find, a smaller depth, or a larger max_chars)`);
|
|
82
|
+
}
|
|
83
|
+
return { text: lines.join('\n'), truncated, refs, count };
|
|
84
|
+
}
|
|
85
|
+
/** Builds the session-result `snapshot` object; refs are limited to the elements shown in the text. */
|
|
86
|
+
export function toSnapshotResult(snapshot) {
|
|
87
|
+
return {
|
|
88
|
+
text: snapshot.text,
|
|
89
|
+
refs: snapshot.refs,
|
|
90
|
+
element_count: snapshot.element_count,
|
|
91
|
+
truncated: snapshot.truncated ?? false,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/** Walks a tree depth-first. */
|
|
95
|
+
export function* walkNodes(root) {
|
|
96
|
+
if (!root)
|
|
97
|
+
return;
|
|
98
|
+
const stack = [root];
|
|
99
|
+
while (stack.length > 0) {
|
|
100
|
+
const node = stack.pop();
|
|
101
|
+
yield node;
|
|
102
|
+
const children = node.children ?? [];
|
|
103
|
+
for (let i = children.length - 1; i >= 0; i -= 1)
|
|
104
|
+
stack.push(children[i]);
|
|
105
|
+
}
|
|
106
|
+
}
|