@vostride/agent-qa-android 0.1.21 → 0.2.1
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/dist/index.cjs +1296 -277
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +135 -8
- package/dist/index.d.ts +135 -8
- package/dist/index.js +1320 -278
- package/dist/index.js.map +1 -1
- package/package.json +7 -3
package/dist/index.js
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
// src/adapter.ts
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { mkdir, rm, writeFile } from "fs/promises";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import {
|
|
6
|
+
assertNever,
|
|
7
|
+
buildScreenState,
|
|
8
|
+
compareFillReread,
|
|
9
|
+
createSnapshotLiveStream,
|
|
10
|
+
resolveAppName,
|
|
11
|
+
runDirectionScroll,
|
|
12
|
+
runToEndScroll,
|
|
13
|
+
runUntilVisibleLoop
|
|
14
|
+
} from "@vostride/agent-qa-runner";
|
|
15
|
+
import {
|
|
16
|
+
KEY_MAP,
|
|
17
|
+
MobileElementResolver,
|
|
18
|
+
MobileSetupError as MobileSetupError2,
|
|
19
|
+
WEBVIEW_SCROLLERS_SCRIPT,
|
|
20
|
+
buildRefEntries,
|
|
21
|
+
computeFingerPositions,
|
|
22
|
+
computePinch,
|
|
23
|
+
computeSwipe,
|
|
24
|
+
deriveMobileScroll,
|
|
25
|
+
parseMobileSource,
|
|
26
|
+
parseWebViewScrollers,
|
|
27
|
+
warnIfOutOfBounds,
|
|
28
|
+
webViewSwipeSignal
|
|
29
|
+
} from "@vostride/agent-qa-core";
|
|
3
30
|
|
|
4
31
|
// src/session.ts
|
|
5
32
|
import { remote } from "webdriverio";
|
|
@@ -69,6 +96,22 @@ async function createAndroidSession(config, farmSession) {
|
|
|
69
96
|
|
|
70
97
|
// src/adapter.ts
|
|
71
98
|
var ANDROID_FILLABLE_ROLES = /* @__PURE__ */ new Set(["textbox", "searchbox", "edittext", "combobox"]);
|
|
99
|
+
var FOCUSED_XPATH = '//*[@focused="true"]';
|
|
100
|
+
var FOCUSED_HINT_XPATH = '//*[@focused="true" and @showing-hint="true"]';
|
|
101
|
+
var FOCUSED_MULTILINE_XPATH = '//*[@focused="true" and @multiline="true"]';
|
|
102
|
+
var FOCUSED_SINGLE_LINE_XPATH = '//*[@focused="true" and @multiline="false"]';
|
|
103
|
+
var FOCUS_BOUNDS_TOLERANCE = 1;
|
|
104
|
+
var ANDROID_BOUNDS_RE = /^\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]$/;
|
|
105
|
+
var ENTER_KEYCODE = 66;
|
|
106
|
+
var CTRL_A = { keycode: 29, metastate: 4096 };
|
|
107
|
+
var DEL_KEYCODE = 67;
|
|
108
|
+
var MULTI_LINE_SUBMIT_NOTICE = "submit not sent: Return would add a new line in this multi-line field; tap its send or submit control";
|
|
109
|
+
var LINE_BREAK_RE = /[\r\n]/;
|
|
110
|
+
var ADB_SHELL_REFUSAL = "Potentially insecure feature 'adb_shell' has not been enabled";
|
|
111
|
+
var WEB_FOCUS_PROBE_SCRIPT = 'var a = document.activeElement;var hit = document.elementFromPoint(arguments[0], arguments[1]);if (!a) { return { editable: false, matches: false, multiline: false, secure: false, value: null };}var tag = a.tagName;var type = tag === "INPUT" ? String(a.type || "text").toLowerCase() : "";var textual = ["text", "search", "email", "url", "tel", "password", "number"].indexOf(type) >= 0;var field = (tag === "INPUT" && textual) || tag === "TEXTAREA";var rich = !field && a.isContentEditable === true;var v = field ? a.value : (rich ? a.innerText : null);return { editable: (field && !a.readOnly && !a.disabled) || rich, matches: !!hit && (a === hit || a.contains(hit)), multiline: tag === "TEXTAREA" || rich, secure: type === "password", value: typeof v === "string" ? v : null};';
|
|
112
|
+
var WEB_FOCUS_POLLS = 3;
|
|
113
|
+
var WEB_FOCUS_POLL_MS = 300;
|
|
114
|
+
var WEB_FIELD_VALUE_SCRIPT = 'var a = document.activeElement;if (!a) { return null; }var v = (a.tagName === "INPUT" || a.tagName === "TEXTAREA") ? a.value : (a.isContentEditable ? a.innerText : null);return typeof v === "string" ? v : null;';
|
|
72
115
|
var ANDROID_NATIVE_SELECT_ROLES = /* @__PURE__ */ new Set(["combobox", "spinbutton", "listbox", "list"]);
|
|
73
116
|
var ANDROID_NATIVE_SELECT_TYPES = /* @__PURE__ */ new Set([
|
|
74
117
|
"android.widget.Spinner",
|
|
@@ -96,12 +139,32 @@ var ANDROID_LEVEL_MAP = {
|
|
|
96
139
|
E: "error",
|
|
97
140
|
F: "error"
|
|
98
141
|
};
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return
|
|
142
|
+
var INPUT_FAILED = { sent: false, outcome: "not-sent", reason: "input-failed" };
|
|
143
|
+
function fieldKindOf(refData) {
|
|
144
|
+
const inputType = refData?.inputType;
|
|
145
|
+
if (inputType === void 0 || (inputType & 15) === 0) return "unknown";
|
|
146
|
+
if ((inputType & 15) === 1 && (inputType & 131072) !== 0) return "multi-line";
|
|
147
|
+
return "single-line";
|
|
148
|
+
}
|
|
149
|
+
function webFocusEvidence(focus) {
|
|
150
|
+
return focus.editable ? "focus-elsewhere" : "no-keyboard-focus";
|
|
151
|
+
}
|
|
152
|
+
function parseBoundsAttr(value) {
|
|
153
|
+
if (typeof value !== "string") return void 0;
|
|
154
|
+
const match = ANDROID_BOUNDS_RE.exec(value.trim());
|
|
155
|
+
if (!match) return void 0;
|
|
156
|
+
const [left, top, right, bottom] = match.slice(1).map(Number);
|
|
157
|
+
return { x: left, y: top, width: right - left, height: bottom - top };
|
|
158
|
+
}
|
|
159
|
+
function rectWithin(inner, outer) {
|
|
160
|
+
return inner.x >= outer.x - FOCUS_BOUNDS_TOLERANCE && inner.y >= outer.y - FOCUS_BOUNDS_TOLERANCE && inner.x + inner.width <= outer.x + outer.width + FOCUS_BOUNDS_TOLERANCE && inner.y + inner.height <= outer.y + outer.height + FOCUS_BOUNDS_TOLERANCE;
|
|
161
|
+
}
|
|
162
|
+
function firstErrorLine(err) {
|
|
163
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
164
|
+
return message.split("\n")[0].trim();
|
|
165
|
+
}
|
|
166
|
+
function isShellRefusal(err) {
|
|
167
|
+
return (err instanceof Error ? err.message : String(err)).includes(ADB_SHELL_REFUSAL);
|
|
105
168
|
}
|
|
106
169
|
function isAndroidWebContextName(context) {
|
|
107
170
|
const normalized = context.toUpperCase();
|
|
@@ -118,6 +181,27 @@ function isAndroidNativeSelectTarget(refData) {
|
|
|
118
181
|
role && ANDROID_NATIVE_SELECT_ROLES.has(role) || refData?.nativeType && ANDROID_NATIVE_SELECT_TYPES.has(refData.nativeType)
|
|
119
182
|
);
|
|
120
183
|
}
|
|
184
|
+
var CONTROL_CHAR_RE = /[\u0000-\u001F\u007F]/;
|
|
185
|
+
function escapeUiSelectorText(value) {
|
|
186
|
+
if (CONTROL_CHAR_RE.test(value)) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`scroll target contains a control character and cannot be used as a UiAutomator selector \u2014 provide a plain-text target for untilVisible.`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
192
|
+
}
|
|
193
|
+
function fingerDirectionFor(scroll) {
|
|
194
|
+
switch (scroll) {
|
|
195
|
+
case "up":
|
|
196
|
+
return "down";
|
|
197
|
+
case "down":
|
|
198
|
+
return "up";
|
|
199
|
+
case "left":
|
|
200
|
+
return "right";
|
|
201
|
+
case "right":
|
|
202
|
+
return "left";
|
|
203
|
+
}
|
|
204
|
+
}
|
|
121
205
|
function androidUiSelectorText(value) {
|
|
122
206
|
return `android=new UiSelector().text(${JSON.stringify(value)})`;
|
|
123
207
|
}
|
|
@@ -158,18 +242,65 @@ return (function() {
|
|
|
158
242
|
H4: 'heading', H5: 'heading', H6: 'heading', IMG: 'image'
|
|
159
243
|
};
|
|
160
244
|
|
|
161
|
-
var
|
|
245
|
+
var doc = document;
|
|
162
246
|
var vw = window.innerWidth;
|
|
163
|
-
var
|
|
164
|
-
var
|
|
165
|
-
var refs = {};
|
|
166
|
-
var lines = [];
|
|
167
|
-
var seen = {};
|
|
247
|
+
var vh = window.innerHeight;
|
|
248
|
+
var active = doc.activeElement;
|
|
168
249
|
|
|
169
|
-
var all =
|
|
170
|
-
'a, button, input, textarea, select, h1, h2, h3, h4, h5, h6, img[alt], [role]'
|
|
250
|
+
var all = doc.querySelectorAll(
|
|
251
|
+
'a, button, input, textarea, select, h1, h2, h3, h4, h5, h6, img[alt], [role], [contenteditable]:not([contenteditable="false"])'
|
|
171
252
|
);
|
|
172
253
|
|
|
254
|
+
var nodes = [];
|
|
255
|
+
var includedEls = [];
|
|
256
|
+
var seen = {};
|
|
257
|
+
var CAPTION_TYPES = { submit: true, button: true, reset: true };
|
|
258
|
+
var VALUELESS_TYPES = {
|
|
259
|
+
password: true, checkbox: true, radio: true, submit: true, button: true, reset: true,
|
|
260
|
+
image: true, file: true, hidden: true, range: true, color: true
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
function textOf(node) {
|
|
264
|
+
return node ? (node.textContent || '').trim() : '';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 437 D-11 (CR-02): a field is named by its label, never by its value, so typed text never
|
|
268
|
+
// becomes a name. A wrapping label's text drops the field's own text (a select's options).
|
|
269
|
+
function labelText(el, label) {
|
|
270
|
+
var text = label.textContent || '';
|
|
271
|
+
var own = el.textContent || '';
|
|
272
|
+
if (own && label.contains(el)) text = text.replace(own, '');
|
|
273
|
+
return text.trim();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function fieldName(el, tag) {
|
|
277
|
+
var ids = (el.getAttribute('aria-labelledby') || '').split(' ');
|
|
278
|
+
var parts = [];
|
|
279
|
+
for (var j = 0; j < ids.length; j++) {
|
|
280
|
+
var part = ids[j] ? textOf(doc.getElementById(ids[j])) : '';
|
|
281
|
+
if (part) parts.push(part);
|
|
282
|
+
}
|
|
283
|
+
if (parts.length > 0) return parts.join(' ');
|
|
284
|
+
var label = el.labels && el.labels.length > 0 ? labelText(el, el.labels[0]) : '';
|
|
285
|
+
if (label) return label;
|
|
286
|
+
var hint = el.getAttribute('placeholder') || el.getAttribute('title');
|
|
287
|
+
if (hint) return hint;
|
|
288
|
+
if (tag === 'INPUT' && CAPTION_TYPES[String(el.type).toLowerCase()]) return el.value || '';
|
|
289
|
+
return '';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// parentIndex is the nearest INCLUDED ancestor (structural/skipped nodes collapse
|
|
293
|
+
// away, matching the mobile/web walkers' Pitfall #7 handling).
|
|
294
|
+
function nearestAncestorIndex(el) {
|
|
295
|
+
var p = el.parentElement;
|
|
296
|
+
while (p) {
|
|
297
|
+
var idx = includedEls.indexOf(p);
|
|
298
|
+
if (idx !== -1) return idx;
|
|
299
|
+
p = p.parentElement;
|
|
300
|
+
}
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
|
|
173
304
|
for (var i = 0; i < all.length; i++) {
|
|
174
305
|
var el = all[i];
|
|
175
306
|
var rect = el.getBoundingClientRect();
|
|
@@ -179,31 +310,37 @@ return (function() {
|
|
|
179
310
|
if (style.display === 'none' || style.visibility === 'hidden') continue;
|
|
180
311
|
|
|
181
312
|
var tag = el.tagName;
|
|
313
|
+
// 2026-10-19 (444.1-05, D-09): only the outermost content-editable element is an editor;
|
|
314
|
+
// its descendants are ordinary nodes.
|
|
315
|
+
var host = el.isContentEditable === true
|
|
316
|
+
&& !(el.parentElement && el.parentElement.isContentEditable === true);
|
|
182
317
|
var ariaRole = el.getAttribute('role');
|
|
183
|
-
var role = ariaRole || TAG_ROLES[tag] ||
|
|
318
|
+
var role = (ariaRole || TAG_ROLES[tag] || (host ? 'textbox' : '')).toLowerCase();
|
|
184
319
|
if (!role) continue;
|
|
185
320
|
|
|
321
|
+
var field = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
|
|
322
|
+
var editable = field || host;
|
|
323
|
+
var textual = role === 'textbox' || role === 'searchbox' || role === 'combobox';
|
|
186
324
|
var name = '';
|
|
187
325
|
var ariaLabel = el.getAttribute('aria-label');
|
|
188
326
|
if (ariaLabel) {
|
|
189
327
|
name = ariaLabel;
|
|
190
328
|
} else if (tag === 'IMG') {
|
|
191
329
|
name = el.alt || '';
|
|
192
|
-
} else if (
|
|
193
|
-
name = el.getAttribute('placeholder') ||
|
|
330
|
+
} else if (editable || textual) {
|
|
331
|
+
name = fieldName(el, tag) || el.getAttribute('aria-placeholder') || '';
|
|
194
332
|
} else {
|
|
195
333
|
name = (el.textContent || '').trim();
|
|
196
334
|
}
|
|
197
335
|
|
|
198
336
|
name = name.slice(0, 100).replace(/[\\n\\r]+/g, ' ').trim();
|
|
199
|
-
if (!name &&
|
|
200
|
-
|
|
201
|
-
var key = role + ':' + name;
|
|
202
|
-
if (seen[key]) continue;
|
|
203
|
-
seen[key] = true;
|
|
337
|
+
if (!name && !editable && !textual) continue;
|
|
204
338
|
|
|
205
|
-
|
|
206
|
-
|
|
339
|
+
if (!editable && !textual) {
|
|
340
|
+
var key = role + ':' + name;
|
|
341
|
+
if (seen[key]) continue;
|
|
342
|
+
seen[key] = true;
|
|
343
|
+
}
|
|
207
344
|
|
|
208
345
|
var bounds = {
|
|
209
346
|
x: Math.round(rect.left),
|
|
@@ -212,41 +349,174 @@ return (function() {
|
|
|
212
349
|
height: Math.round(rect.height)
|
|
213
350
|
};
|
|
214
351
|
|
|
215
|
-
|
|
352
|
+
// Real DOM state signals only \u2014 the builder derives every default (D-08).
|
|
353
|
+
var state = {};
|
|
354
|
+
var disabled = el.disabled === true
|
|
355
|
+
|| el.hasAttribute('disabled')
|
|
356
|
+
|| el.getAttribute('aria-disabled') === 'true';
|
|
357
|
+
state.enabled = !disabled;
|
|
358
|
+
state.focused = el === active;
|
|
359
|
+
if (bounds.y + bounds.height < 0 || bounds.y > vh) state.visible = false;
|
|
360
|
+
if (tag === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) {
|
|
361
|
+
state.checked = el.checked === true;
|
|
362
|
+
}
|
|
216
363
|
|
|
217
|
-
|
|
218
|
-
|
|
364
|
+
var labels = {};
|
|
365
|
+
var testId = el.getAttribute('data-testid');
|
|
366
|
+
if (testId) labels.testId = testId;
|
|
367
|
+
if (ariaLabel) labels.ariaLabel = ariaLabel;
|
|
368
|
+
var placeholder = el.getAttribute('placeholder');
|
|
369
|
+
if (placeholder) labels.placeholder = placeholder;
|
|
370
|
+
var title = el.getAttribute('title');
|
|
371
|
+
if (title) labels.title = title;
|
|
219
372
|
|
|
220
|
-
var
|
|
221
|
-
if (
|
|
222
|
-
lines.push(line);
|
|
223
|
-
}
|
|
373
|
+
var attributes = {};
|
|
374
|
+
if (el.type) attributes.type = String(el.type);
|
|
224
375
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
var
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
376
|
+
var parentIndex = nearestAncestorIndex(el);
|
|
377
|
+
|
|
378
|
+
// 2026-10-19 (444.1-05 D-08, 444.1-22 D-33): what a field or an editor holds is its value, never its name; a password's never.
|
|
379
|
+
var observedValue = undefined;
|
|
380
|
+
var textEntry = tag === 'TEXTAREA' || (tag === 'INPUT' && !VALUELESS_TYPES[String(el.type).toLowerCase()]);
|
|
381
|
+
if (textEntry && el.value) {
|
|
382
|
+
observedValue = String(el.value).slice(0, 200);
|
|
383
|
+
} else if (tag === 'SELECT') {
|
|
384
|
+
var option = el.options && el.selectedIndex >= 0 ? el.options[el.selectedIndex] : null;
|
|
385
|
+
var optionText = option ? String(option.text || '').trim().slice(0, 200) : '';
|
|
386
|
+
if (optionText) observedValue = optionText;
|
|
387
|
+
} else if (host || (textual && !field)) {
|
|
388
|
+
var held = textOf(el).replace(/[\\n\\r]+/g, ' ').slice(0, 200).trim();
|
|
389
|
+
if (held) observedValue = held;
|
|
390
|
+
}
|
|
233
391
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
392
|
+
var record = {
|
|
393
|
+
role: role,
|
|
394
|
+
name: name,
|
|
395
|
+
value: observedValue,
|
|
396
|
+
bounds: bounds,
|
|
397
|
+
state: state,
|
|
398
|
+
labels: labels,
|
|
399
|
+
attributes: attributes
|
|
400
|
+
};
|
|
401
|
+
if (parentIndex !== undefined) record.parentIndex = parentIndex;
|
|
402
|
+
|
|
403
|
+
nodes.push(record);
|
|
404
|
+
includedEls.push(el);
|
|
238
405
|
}
|
|
239
406
|
|
|
407
|
+
var docEl = doc.documentElement;
|
|
240
408
|
return {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
409
|
+
nodes: nodes,
|
|
410
|
+
viewport: { width: vw, height: vh },
|
|
411
|
+
scroll: {
|
|
412
|
+
offsetX: window.scrollX,
|
|
413
|
+
offsetY: window.scrollY,
|
|
414
|
+
contentWidth: docEl.scrollWidth,
|
|
415
|
+
contentHeight: docEl.scrollHeight
|
|
416
|
+
},
|
|
417
|
+
url: location.href
|
|
247
418
|
};
|
|
248
419
|
})();
|
|
249
420
|
`;
|
|
421
|
+
function coerceWebInterimNode(value) {
|
|
422
|
+
if (!value || typeof value !== "object") return null;
|
|
423
|
+
const v = value;
|
|
424
|
+
const role = coerceString(v.role);
|
|
425
|
+
if (!role) return null;
|
|
426
|
+
const node = { role: role.toLowerCase(), attributes: coerceStringBag(v.attributes) };
|
|
427
|
+
const name = coerceString(v.name);
|
|
428
|
+
if (name) node.name = name;
|
|
429
|
+
const fieldValue = coerceString(v.value);
|
|
430
|
+
if (fieldValue) node.value = fieldValue;
|
|
431
|
+
if (typeof v.parentIndex === "number" && Number.isInteger(v.parentIndex) && v.parentIndex >= 0) {
|
|
432
|
+
node.parentIndex = v.parentIndex;
|
|
433
|
+
}
|
|
434
|
+
const bounds = coerceWebBounds(v.bounds);
|
|
435
|
+
if (bounds) node.bounds = bounds;
|
|
436
|
+
const state = coerceWebState(v.state);
|
|
437
|
+
if (state) node.state = state;
|
|
438
|
+
const labels = coerceWebLabels(v.labels);
|
|
439
|
+
if (labels) node.labels = labels;
|
|
440
|
+
return node;
|
|
441
|
+
}
|
|
442
|
+
function coerceString(value) {
|
|
443
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
444
|
+
}
|
|
445
|
+
function coerceStringBag(value) {
|
|
446
|
+
const bag = {};
|
|
447
|
+
if (!value || typeof value !== "object") return bag;
|
|
448
|
+
for (const [k, v] of Object.entries(value)) {
|
|
449
|
+
if (typeof v === "string") bag[k] = v;
|
|
450
|
+
else if (typeof v === "number" || typeof v === "boolean") bag[k] = String(v);
|
|
451
|
+
}
|
|
452
|
+
return bag;
|
|
453
|
+
}
|
|
454
|
+
function coerceWebBounds(value) {
|
|
455
|
+
if (!value || typeof value !== "object") return void 0;
|
|
456
|
+
const v = value;
|
|
457
|
+
const { x, y, width, height } = v;
|
|
458
|
+
if (typeof x === "number" && Number.isFinite(x) && typeof y === "number" && Number.isFinite(y) && typeof width === "number" && Number.isFinite(width) && typeof height === "number" && Number.isFinite(height)) {
|
|
459
|
+
return { x, y, width, height };
|
|
460
|
+
}
|
|
461
|
+
return void 0;
|
|
462
|
+
}
|
|
463
|
+
function coerceWebState(value) {
|
|
464
|
+
if (!value || typeof value !== "object") return void 0;
|
|
465
|
+
const v = value;
|
|
466
|
+
const state = {};
|
|
467
|
+
if (typeof v.enabled === "boolean") state.enabled = v.enabled;
|
|
468
|
+
if (typeof v.focused === "boolean") state.focused = v.focused;
|
|
469
|
+
if (typeof v.visible === "boolean") state.visible = v.visible;
|
|
470
|
+
if (typeof v.checked === "boolean") state.checked = v.checked;
|
|
471
|
+
if (typeof v.selected === "boolean") state.selected = v.selected;
|
|
472
|
+
return Object.keys(state).length > 0 ? state : void 0;
|
|
473
|
+
}
|
|
474
|
+
function coerceWebLabels(value) {
|
|
475
|
+
if (!value || typeof value !== "object") return void 0;
|
|
476
|
+
const v = value;
|
|
477
|
+
const labels = {};
|
|
478
|
+
const testId = coerceString(v.testId);
|
|
479
|
+
if (testId) labels.testId = testId;
|
|
480
|
+
const ariaLabel = coerceString(v.ariaLabel);
|
|
481
|
+
if (ariaLabel) labels.ariaLabel = ariaLabel;
|
|
482
|
+
const placeholder = coerceString(v.placeholder);
|
|
483
|
+
if (placeholder) labels.placeholder = placeholder;
|
|
484
|
+
const title = coerceString(v.title);
|
|
485
|
+
if (title) labels.title = title;
|
|
486
|
+
return Object.keys(labels).length > 0 ? labels : void 0;
|
|
487
|
+
}
|
|
488
|
+
function coerceViewport(value, fallback) {
|
|
489
|
+
if (value && typeof value === "object") {
|
|
490
|
+
const v = value;
|
|
491
|
+
const width = typeof v.width === "number" && Number.isFinite(v.width) ? v.width : fallback.width;
|
|
492
|
+
const height = typeof v.height === "number" && Number.isFinite(v.height) ? v.height : fallback.height;
|
|
493
|
+
return { width, height };
|
|
494
|
+
}
|
|
495
|
+
return fallback;
|
|
496
|
+
}
|
|
497
|
+
function coerceWebScroll(value, viewport) {
|
|
498
|
+
const num = (x, fb) => typeof x === "number" && Number.isFinite(x) ? x : fb;
|
|
499
|
+
if (value && typeof value === "object") {
|
|
500
|
+
const v = value;
|
|
501
|
+
return {
|
|
502
|
+
offsetX: num(v.offsetX, 0),
|
|
503
|
+
offsetY: num(v.offsetY, 0),
|
|
504
|
+
contentWidth: num(v.contentWidth, viewport.width),
|
|
505
|
+
contentHeight: num(v.contentHeight, viewport.height)
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
return { offsetX: 0, offsetY: 0, contentWidth: viewport.width, contentHeight: viewport.height };
|
|
509
|
+
}
|
|
510
|
+
function gestureArea(bounds, direction, viewport) {
|
|
511
|
+
const vertical = direction === "up" || direction === "down";
|
|
512
|
+
const length = vertical ? viewport.height : viewport.width;
|
|
513
|
+
const start = vertical ? bounds.y : bounds.x;
|
|
514
|
+
const end = start + (vertical ? bounds.height : bounds.width);
|
|
515
|
+
const from = Math.max(start, Math.round(length * 0.2));
|
|
516
|
+
const to = Math.min(end, Math.round(length * 0.8));
|
|
517
|
+
if (to - from < 1) return bounds;
|
|
518
|
+
return vertical ? { ...bounds, y: from, height: to - from } : { ...bounds, x: from, width: to - from };
|
|
519
|
+
}
|
|
250
520
|
var AndroidPlatformAdapter = class {
|
|
251
521
|
platform = "android";
|
|
252
522
|
driver = null;
|
|
@@ -257,15 +527,16 @@ var AndroidPlatformAdapter = class {
|
|
|
257
527
|
activeWebContext = null;
|
|
258
528
|
// Cached so we skip redundant switchContext round-trips.
|
|
259
529
|
currentContext = null;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
videoDir = null;
|
|
263
|
-
videoPath = null;
|
|
530
|
+
recordingCapability;
|
|
531
|
+
liveStreamCapability;
|
|
264
532
|
consoleLogs = [];
|
|
265
533
|
consoleDrainIndex = 0;
|
|
266
534
|
captureConsole = true;
|
|
267
535
|
appPackage = null;
|
|
268
536
|
appPid = null;
|
|
537
|
+
// EXEC-03 session-cached app registry (D-10/D-12): enumerated once per session; null = not yet
|
|
538
|
+
// enumerated (or enumeration failed and may be retried). Reset in cleanup().
|
|
539
|
+
appRegistry = null;
|
|
269
540
|
async setup(config) {
|
|
270
541
|
this.config = config;
|
|
271
542
|
this.captureConsole = config.logCapture?.console !== false;
|
|
@@ -273,17 +544,30 @@ var AndroidPlatformAdapter = class {
|
|
|
273
544
|
this.driver = await createAndroidSession(this.config, config.farmSession);
|
|
274
545
|
const appPackage = this.appPackage;
|
|
275
546
|
if (appPackage && !this.config.browserName && !this.config.device?.match?.browserName) {
|
|
547
|
+
let state;
|
|
276
548
|
try {
|
|
277
|
-
await this.driver.
|
|
278
|
-
} catch
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
549
|
+
state = await this.driver.queryAppState(appPackage);
|
|
550
|
+
} catch {
|
|
551
|
+
try {
|
|
552
|
+
const answer = await this.driver.execute("mobile: queryAppState", { appId: appPackage });
|
|
553
|
+
if (typeof answer === "number") state = answer;
|
|
554
|
+
} catch {
|
|
555
|
+
state = void 0;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (state !== 4) {
|
|
559
|
+
try {
|
|
560
|
+
await this.driver.activateApp(appPackage);
|
|
561
|
+
} catch (err) {
|
|
562
|
+
throw new MobileSetupError2({
|
|
563
|
+
category: "app-launch",
|
|
564
|
+
message: `Failed to launch Android app "${appPackage}": ${err instanceof Error ? err.message : String(err)}`,
|
|
565
|
+
platform: "android",
|
|
566
|
+
deviceName: config.device?.name,
|
|
567
|
+
appId: appPackage,
|
|
568
|
+
cause: err
|
|
569
|
+
});
|
|
570
|
+
}
|
|
287
571
|
}
|
|
288
572
|
}
|
|
289
573
|
if (process.env.AGENT_QA_ANDROID_USE_MOBILE_SHELL === "1" && this.appPackage && this.driver) {
|
|
@@ -294,32 +578,18 @@ var AndroidPlatformAdapter = class {
|
|
|
294
578
|
} catch {
|
|
295
579
|
}
|
|
296
580
|
}
|
|
297
|
-
if (config.recording?.enabled && this.driver) {
|
|
298
|
-
this.videoDir = config.recording.videoDir;
|
|
299
|
-
const videoSize = config.recording.videoSize ? `${config.recording.videoSize.width}x${config.recording.videoSize.height}` : void 0;
|
|
300
|
-
try {
|
|
301
|
-
await this.driver.startRecordingScreen({ videoSize, timeLimit: 1800, forceRestart: true });
|
|
302
|
-
this.recordingEnabled = true;
|
|
303
|
-
} catch {
|
|
304
|
-
try {
|
|
305
|
-
await this.driver.execute("mobile: startScreenRecording", {
|
|
306
|
-
videoSize,
|
|
307
|
-
timeLimit: 1800,
|
|
308
|
-
forceRestart: true
|
|
309
|
-
});
|
|
310
|
-
this.recordingEnabled = true;
|
|
311
|
-
} catch {
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
581
|
}
|
|
316
582
|
async cleanup() {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
583
|
+
try {
|
|
584
|
+
await this.recordingCapability?.stop();
|
|
585
|
+
} catch {
|
|
586
|
+
}
|
|
587
|
+
this.recordingCapability = void 0;
|
|
588
|
+
try {
|
|
589
|
+
await this.liveStreamCapability?.stop();
|
|
590
|
+
} catch {
|
|
322
591
|
}
|
|
592
|
+
this.liveStreamCapability = void 0;
|
|
323
593
|
try {
|
|
324
594
|
if (this.driver) {
|
|
325
595
|
await this.driver.deleteSession();
|
|
@@ -332,9 +602,9 @@ var AndroidPlatformAdapter = class {
|
|
|
332
602
|
this.consoleLogs = [];
|
|
333
603
|
this.appPackage = null;
|
|
334
604
|
this.appPid = null;
|
|
605
|
+
this.appRegistry = null;
|
|
335
606
|
this.activeWebContext = null;
|
|
336
607
|
this.currentContext = null;
|
|
337
|
-
this.recordingEnabled = false;
|
|
338
608
|
}
|
|
339
609
|
// Whether we've ever successfully observed via a web context.
|
|
340
610
|
// Once true, we know a WebView/browser exists and should retry rather than
|
|
@@ -360,11 +630,20 @@ var AndroidPlatformAdapter = class {
|
|
|
360
630
|
try {
|
|
361
631
|
return await this.observeNative();
|
|
362
632
|
} catch {
|
|
633
|
+
this.lastRefs = {};
|
|
363
634
|
return {
|
|
635
|
+
platform: "android",
|
|
364
636
|
tree: "(native observation unavailable \u2014 WebView content not accessible)",
|
|
365
637
|
elements: [],
|
|
638
|
+
refMap: {},
|
|
366
639
|
timestamp: Date.now(),
|
|
367
|
-
metadata: {
|
|
640
|
+
metadata: {
|
|
641
|
+
captureMode: "textOnly",
|
|
642
|
+
coordSpace: "viewport",
|
|
643
|
+
viewportWidth: 0,
|
|
644
|
+
viewportHeight: 0,
|
|
645
|
+
scroll: { offsetX: 0, offsetY: 0, contentWidth: 0, contentHeight: 0 }
|
|
646
|
+
}
|
|
368
647
|
};
|
|
369
648
|
}
|
|
370
649
|
}
|
|
@@ -402,7 +681,7 @@ var AndroidPlatformAdapter = class {
|
|
|
402
681
|
const buf = await this.captureScreenshot();
|
|
403
682
|
if (buf && this.driver) {
|
|
404
683
|
try {
|
|
405
|
-
const { getImageDimensions } = await import("@vostride/agent-qa-
|
|
684
|
+
const { getImageDimensions } = await import("@vostride/agent-qa-runner-local");
|
|
406
685
|
const dims = await getImageDimensions(buf);
|
|
407
686
|
const { width } = await this.driver.getWindowSize();
|
|
408
687
|
if (dims && Math.abs(dims.width - width) > 1) {
|
|
@@ -438,35 +717,90 @@ var AndroidPlatformAdapter = class {
|
|
|
438
717
|
if (!level) return newLogs;
|
|
439
718
|
return newLogs.filter((e) => e.level === level);
|
|
440
719
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
720
|
+
/**
|
|
721
|
+
* 428 D-11: the labelled 1s snapshot preview, over `captureScreenshot()` — `takeScreenshot()`
|
|
722
|
+
* and nothing else. Never `screenshot()`: that one switches the Appium context, and a tick
|
|
723
|
+
* landing between a step's context switch and its command would run the command in the wrong
|
|
724
|
+
* context. Declared before the driver exists (a capture then answers `undefined`), so the
|
|
725
|
+
* composition's selection never falls through to `screenshot()`.
|
|
726
|
+
*/
|
|
727
|
+
get liveStream() {
|
|
728
|
+
this.liveStreamCapability ??= createSnapshotLiveStream({ capture: () => this.captureScreenshot() });
|
|
729
|
+
return this.liveStreamCapability;
|
|
449
730
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
731
|
+
get recording() {
|
|
732
|
+
const driver = this.driver;
|
|
733
|
+
const config = this.config?.recording;
|
|
734
|
+
if (!driver || !config) return void 0;
|
|
735
|
+
if (this.recordingCapability) return this.recordingCapability;
|
|
736
|
+
let running = false;
|
|
737
|
+
let busy = false;
|
|
738
|
+
let needsCleanup = false;
|
|
739
|
+
async function recover() {
|
|
740
|
+
if (!needsCleanup) return;
|
|
741
|
+
await driver.stopRecordingScreen();
|
|
742
|
+
needsCleanup = false;
|
|
461
743
|
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
744
|
+
this.recordingCapability = {
|
|
745
|
+
async start() {
|
|
746
|
+
if (busy || running) throw new Error("Recording already started");
|
|
747
|
+
busy = true;
|
|
748
|
+
try {
|
|
749
|
+
await recover();
|
|
750
|
+
const videoSize = config.videoSize ? `${config.videoSize.width}x${config.videoSize.height}` : void 0;
|
|
751
|
+
needsCleanup = true;
|
|
752
|
+
try {
|
|
753
|
+
await driver.startRecordingScreen({ videoSize, timeLimit: 1800, forceRestart: true });
|
|
754
|
+
} catch (error) {
|
|
755
|
+
try {
|
|
756
|
+
await recover();
|
|
757
|
+
} catch {
|
|
758
|
+
}
|
|
759
|
+
throw error;
|
|
760
|
+
}
|
|
761
|
+
needsCleanup = false;
|
|
762
|
+
running = true;
|
|
763
|
+
return {};
|
|
764
|
+
} finally {
|
|
765
|
+
busy = false;
|
|
766
|
+
}
|
|
767
|
+
},
|
|
768
|
+
async stop() {
|
|
769
|
+
if (busy) throw new Error("Recording operation already in progress");
|
|
770
|
+
busy = true;
|
|
771
|
+
try {
|
|
772
|
+
if (!running) {
|
|
773
|
+
await recover();
|
|
774
|
+
return void 0;
|
|
775
|
+
}
|
|
776
|
+
running = false;
|
|
777
|
+
let base64Video;
|
|
778
|
+
try {
|
|
779
|
+
base64Video = await driver.stopRecordingScreen();
|
|
780
|
+
} catch (error) {
|
|
781
|
+
needsCleanup = true;
|
|
782
|
+
try {
|
|
783
|
+
await recover();
|
|
784
|
+
} catch {
|
|
785
|
+
}
|
|
786
|
+
throw error;
|
|
787
|
+
}
|
|
788
|
+
if (!base64Video) return void 0;
|
|
789
|
+
await mkdir(config.videoDir, { recursive: true });
|
|
790
|
+
const stagingPath = join(config.videoDir, `${randomUUID()}.mp4`);
|
|
791
|
+
try {
|
|
792
|
+
await writeFile(stagingPath, Buffer.from(base64Video, "base64"));
|
|
793
|
+
} catch (error) {
|
|
794
|
+
await rm(stagingPath, { force: true }).catch(() => void 0);
|
|
795
|
+
throw error;
|
|
796
|
+
}
|
|
797
|
+
return { stagingPath, contentType: "video/mp4" };
|
|
798
|
+
} finally {
|
|
799
|
+
busy = false;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
return this.recordingCapability;
|
|
470
804
|
}
|
|
471
805
|
// ---------------------------------------------------------------------------
|
|
472
806
|
// Observation
|
|
@@ -481,22 +815,33 @@ var AndroidPlatformAdapter = class {
|
|
|
481
815
|
);
|
|
482
816
|
if (!webCtx) return null;
|
|
483
817
|
await this.ensureContext(webCtx);
|
|
484
|
-
const
|
|
485
|
-
if (!
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
return
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
818
|
+
const raw = await driver.execute(WEB_OBSERVER_SCRIPT);
|
|
819
|
+
if (!raw || typeof raw !== "object") return null;
|
|
820
|
+
const payload = raw;
|
|
821
|
+
const rawNodes = payload.nodes;
|
|
822
|
+
if (!Array.isArray(rawNodes) || rawNodes.length === 0) return null;
|
|
823
|
+
const nodes = [];
|
|
824
|
+
for (const candidate of rawNodes) {
|
|
825
|
+
const node = coerceWebInterimNode(candidate);
|
|
826
|
+
if (node) nodes.push(node);
|
|
827
|
+
}
|
|
828
|
+
if (nodes.length === 0) return null;
|
|
829
|
+
const windowSize = await driver.getWindowSize();
|
|
830
|
+
const viewport = coerceViewport(payload.viewport, { width: windowSize.width, height: windowSize.height });
|
|
831
|
+
const scroll = coerceWebScroll(payload.scroll, viewport);
|
|
832
|
+
const url = typeof payload.url === "string" ? payload.url : void 0;
|
|
833
|
+
const { state, binding } = buildScreenState({
|
|
834
|
+
platform: "android",
|
|
835
|
+
nodes,
|
|
836
|
+
viewport,
|
|
837
|
+
scroll,
|
|
838
|
+
coordSpace: "viewport",
|
|
492
839
|
timestamp: Date.now(),
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
}
|
|
499
|
-
};
|
|
840
|
+
...url !== void 0 ? { url } : {}
|
|
841
|
+
});
|
|
842
|
+
this.lastRefs = buildRefEntries(nodes, binding);
|
|
843
|
+
this.activeWebContext = webCtx;
|
|
844
|
+
return state;
|
|
500
845
|
} catch {
|
|
501
846
|
return null;
|
|
502
847
|
}
|
|
@@ -508,14 +853,18 @@ var AndroidPlatformAdapter = class {
|
|
|
508
853
|
driver.getPageSource(),
|
|
509
854
|
driver.getWindowSize()
|
|
510
855
|
]);
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
856
|
+
const { nodes } = parseMobileSource(xml, "android", viewport);
|
|
857
|
+
const scroll = deriveMobileScroll(nodes, viewport);
|
|
858
|
+
const { state, binding } = buildScreenState({
|
|
859
|
+
platform: "android",
|
|
860
|
+
nodes,
|
|
861
|
+
viewport,
|
|
862
|
+
scroll,
|
|
863
|
+
coordSpace: "viewport",
|
|
864
|
+
timestamp: Date.now()
|
|
865
|
+
});
|
|
866
|
+
this.lastRefs = buildRefEntries(nodes, binding);
|
|
867
|
+
return state;
|
|
519
868
|
}
|
|
520
869
|
// ---------------------------------------------------------------------------
|
|
521
870
|
// Context management
|
|
@@ -536,7 +885,14 @@ var AndroidPlatformAdapter = class {
|
|
|
536
885
|
try {
|
|
537
886
|
if (this.driver) {
|
|
538
887
|
const base64 = await this.driver.takeScreenshot();
|
|
539
|
-
|
|
888
|
+
const buf = Buffer.from(base64, "base64");
|
|
889
|
+
if (!this.activeWebContext) return buf;
|
|
890
|
+
try {
|
|
891
|
+
const { flattenOntoWhite } = await import("@vostride/agent-qa-runner-local");
|
|
892
|
+
return await flattenOntoWhite(buf);
|
|
893
|
+
} catch {
|
|
894
|
+
return buf;
|
|
895
|
+
}
|
|
540
896
|
}
|
|
541
897
|
return void 0;
|
|
542
898
|
} catch {
|
|
@@ -544,6 +900,621 @@ var AndroidPlatformAdapter = class {
|
|
|
544
900
|
}
|
|
545
901
|
}
|
|
546
902
|
// ---------------------------------------------------------------------------
|
|
903
|
+
// Compound behaviors (EXEC-01/02/03)
|
|
904
|
+
// ---------------------------------------------------------------------------
|
|
905
|
+
/** Read the live text of a focused element handle; undefined when nothing is readable. */
|
|
906
|
+
async readElementText(el) {
|
|
907
|
+
try {
|
|
908
|
+
const value = await el.getText();
|
|
909
|
+
return typeof value === "string" ? value : void 0;
|
|
910
|
+
} catch {
|
|
911
|
+
return void 0;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
async focusedMatches(xpath) {
|
|
915
|
+
try {
|
|
916
|
+
return await (await this.driver.$(xpath)).isExisting();
|
|
917
|
+
} catch {
|
|
918
|
+
return false;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
/** An empty field's getText() returns its hint (437 Pitfall 4), so a showing hint reads ''. */
|
|
922
|
+
async readFocusedValue(el) {
|
|
923
|
+
if (await this.focusedMatches(FOCUSED_HINT_XPATH)) return "";
|
|
924
|
+
return await this.readElementText(el);
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* The `mobile: shell` route of keyevent and openQuickSettings (437 D-18). A refusal by secure
|
|
928
|
+
* Appium is recorded as unavailable and any other driver error as failed; neither is a pass.
|
|
929
|
+
*/
|
|
930
|
+
async runShell(verb, args) {
|
|
931
|
+
try {
|
|
932
|
+
await this.driver.execute("mobile: shell", args);
|
|
933
|
+
return { success: true };
|
|
934
|
+
} catch (err) {
|
|
935
|
+
if (isShellRefusal(err)) {
|
|
936
|
+
const shellAction2 = {
|
|
937
|
+
verb,
|
|
938
|
+
outcome: "unavailable",
|
|
939
|
+
reason: "adb-shell-disabled"
|
|
940
|
+
};
|
|
941
|
+
return {
|
|
942
|
+
success: false,
|
|
943
|
+
error: `${verb} unavailable: Appium does not allow mobile: shell here (adb_shell is not enabled), so nothing was sent. Use pressBack, pressHome or keypress where they fit.`,
|
|
944
|
+
metadata: { shellAction: shellAction2 }
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
const detail = firstErrorLine(err);
|
|
948
|
+
const shellAction = { verb, outcome: "failed", detail };
|
|
949
|
+
return { success: false, error: `${verb} failed: ${detail}`, metadata: { shellAction } };
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
/** The one submit mechanism on every Android path (437 D-13); pressed once, never retried. */
|
|
953
|
+
async pressEnter() {
|
|
954
|
+
try {
|
|
955
|
+
await this.driver.execute("mobile: pressKey", { keycode: ENTER_KEYCODE });
|
|
956
|
+
return { sent: true, outcome: "sent" };
|
|
957
|
+
} catch (err) {
|
|
958
|
+
return { sent: false, outcome: "failed", detail: firstErrorLine(err) };
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
/** 437 FA-2 (owner, 2026-10-08): line breaks into a single-line field are refused before typing. */
|
|
962
|
+
refuseLineBreaks(action, box) {
|
|
963
|
+
const fillRefusal = {
|
|
964
|
+
verb: "fill",
|
|
965
|
+
cause: "single-line-field",
|
|
966
|
+
evidence: "text-has-line-breaks"
|
|
967
|
+
};
|
|
968
|
+
return {
|
|
969
|
+
success: false,
|
|
970
|
+
error: "fill: this field is single-line and the text has line breaks; nothing was typed. Send the text without line breaks, or use a multi-line field.",
|
|
971
|
+
...box,
|
|
972
|
+
metadata: { fillRefusal, ...action.submit ? { fillSubmit: { ...INPUT_FAILED } } : {} }
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Native fill from the focused handle (437 D-08..D-10, D-12, D-13, D-15, D-17). Delivers once
|
|
977
|
+
* (addValue for append, setValue for replace), reads back from the same handle and records the
|
|
978
|
+
* shared verdict. An unknown append prior or an unreadable read-back is unverified, which never
|
|
979
|
+
* stops a requested Enter; a mismatch or a multi-line field holds it. A driver error during
|
|
980
|
+
* delivery ends the fill: nothing is typed again and no Enter follows. A secure field yields
|
|
981
|
+
* length sentinels only.
|
|
982
|
+
*/
|
|
983
|
+
async fillNativeCompound(el, action, box) {
|
|
984
|
+
const isAppend = action.clearMode === "append";
|
|
985
|
+
if (LINE_BREAK_RE.test(action.text) && await this.focusedMatches(FOCUSED_SINGLE_LINE_XPATH)) {
|
|
986
|
+
return this.refuseLineBreaks(action, box);
|
|
987
|
+
}
|
|
988
|
+
const multiline = action.submit && await this.focusedMatches(FOCUSED_MULTILINE_XPATH);
|
|
989
|
+
const secure = await el.getAttribute("password").catch(() => null) === "true";
|
|
990
|
+
const prior = isAppend ? await this.readFocusedValue(el) : "";
|
|
991
|
+
try {
|
|
992
|
+
if (isAppend) {
|
|
993
|
+
await el.addValue(action.text);
|
|
994
|
+
} else {
|
|
995
|
+
await el.setValue(action.text);
|
|
996
|
+
}
|
|
997
|
+
} catch {
|
|
998
|
+
return this.interruptedFill("native", secure, action, box);
|
|
999
|
+
}
|
|
1000
|
+
const actual = prior === void 0 ? void 0 : await this.readFocusedValue(el);
|
|
1001
|
+
const verdict = compareFillReread({ expected: (prior ?? "") + action.text, actual, secure });
|
|
1002
|
+
return await this.settleFill(action, box, {
|
|
1003
|
+
path: "native",
|
|
1004
|
+
verdict,
|
|
1005
|
+
multiline,
|
|
1006
|
+
enter: () => this.pressEnter()
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
/** The focused element handle, or undefined when UiAutomator2 reports none or the lookup fails. */
|
|
1010
|
+
async focusedField() {
|
|
1011
|
+
try {
|
|
1012
|
+
const el = await this.driver.$(FOCUSED_XPATH);
|
|
1013
|
+
return await el.isExisting() ? el : void 0;
|
|
1014
|
+
} catch {
|
|
1015
|
+
return void 0;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* 437 D-11 (WR-02): where the focused node is relative to the ref, judged by its read bounds.
|
|
1020
|
+
* An unreadable or unparseable value is no evidence, so the native path stays (D-04, D-15).
|
|
1021
|
+
* 444.1 D-07: 'elsewhere' needs positive evidence, the bounds of another observed field.
|
|
1022
|
+
*/
|
|
1023
|
+
async focusIsElsewhere(handle, ref, bounds) {
|
|
1024
|
+
let raw;
|
|
1025
|
+
try {
|
|
1026
|
+
raw = await handle.getAttribute("bounds");
|
|
1027
|
+
} catch {
|
|
1028
|
+
return "target";
|
|
1029
|
+
}
|
|
1030
|
+
const focused = parseBoundsAttr(raw);
|
|
1031
|
+
if (focused === void 0 || rectWithin(focused, bounds)) return "target";
|
|
1032
|
+
for (const [other, entry] of Object.entries(this.lastRefs)) {
|
|
1033
|
+
if (other === ref || !entry.bounds) continue;
|
|
1034
|
+
const field = entry.editable === true || ANDROID_FILLABLE_ROLES.has(entry.role.toLowerCase());
|
|
1035
|
+
if (field && rectWithin(focused, entry.bounds)) return "elsewhere";
|
|
1036
|
+
}
|
|
1037
|
+
return "moved";
|
|
1038
|
+
}
|
|
1039
|
+
/** Ctrl+A then DEL as key input; clears only the view holding input focus (437 D-14). */
|
|
1040
|
+
async clearByKeys() {
|
|
1041
|
+
await this.driver.execute("mobile: pressKey", CTRL_A);
|
|
1042
|
+
await this.driver.execute("mobile: pressKey", { keycode: DEL_KEYCODE });
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* The last-resort keyboard path (437 D-14): the tap left no focused handle. Replace clears by
|
|
1046
|
+
* keys, the text goes through one `mobile: type` (Appium's Unicode IME, exact for every D-08
|
|
1047
|
+
* text), and the value is read back only when a focused handle can be found afterwards. An
|
|
1048
|
+
* append's prior value is unknown, so it is never better than unverified. The field's kind comes
|
|
1049
|
+
* from the observed input-type (D-15): only positive evidence refuses line breaks or holds Enter.
|
|
1050
|
+
*/
|
|
1051
|
+
async fillKeyboardPath(action, box, refData) {
|
|
1052
|
+
const isAppend = action.clearMode === "append";
|
|
1053
|
+
const kind = fieldKindOf(refData);
|
|
1054
|
+
if (kind === "single-line" && LINE_BREAK_RE.test(action.text)) {
|
|
1055
|
+
return this.refuseLineBreaks(action, box);
|
|
1056
|
+
}
|
|
1057
|
+
try {
|
|
1058
|
+
if (!isAppend) await this.clearByKeys();
|
|
1059
|
+
await this.driver.execute("mobile: type", { text: action.text });
|
|
1060
|
+
} catch {
|
|
1061
|
+
return this.interruptedFill("keyboard", false, action, box);
|
|
1062
|
+
}
|
|
1063
|
+
const handle = await this.focusedField();
|
|
1064
|
+
let secure = false;
|
|
1065
|
+
let actual;
|
|
1066
|
+
if (handle) {
|
|
1067
|
+
secure = await this.isSecure(handle);
|
|
1068
|
+
if (!isAppend) actual = await this.readFocusedValue(handle);
|
|
1069
|
+
}
|
|
1070
|
+
const verdict = compareFillReread({ expected: action.text, actual, secure });
|
|
1071
|
+
return await this.settleFill(action, box, {
|
|
1072
|
+
path: "keyboard",
|
|
1073
|
+
verdict,
|
|
1074
|
+
multiline: kind === "multi-line",
|
|
1075
|
+
enter: () => this.pressEnter()
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* Native and keyboard clearText (437 D-16): clearValue on the focused handle, or Ctrl+A then DEL
|
|
1080
|
+
* when the tap left none, then the hint-aware read-back judged against the empty string. The
|
|
1081
|
+
* caller has already checked that a focused handle is the referenced field (WR-02).
|
|
1082
|
+
*/
|
|
1083
|
+
async clearNativeOrKeyboard(box, handle) {
|
|
1084
|
+
const path = handle ? "native" : "keyboard";
|
|
1085
|
+
const before = handle ? await this.isSecure(handle) : false;
|
|
1086
|
+
try {
|
|
1087
|
+
if (handle) await handle.clearValue();
|
|
1088
|
+
else await this.clearByKeys();
|
|
1089
|
+
} catch {
|
|
1090
|
+
return this.interruptedClear(path, before, box);
|
|
1091
|
+
}
|
|
1092
|
+
const readable = handle ?? await this.focusedField();
|
|
1093
|
+
if (!readable) return this.settleClear(path, void 0, false, box);
|
|
1094
|
+
const secure = handle ? before : await this.isSecure(readable);
|
|
1095
|
+
return this.settleClear(path, await this.readFocusedValue(readable), secure, box);
|
|
1096
|
+
}
|
|
1097
|
+
async isSecure(el) {
|
|
1098
|
+
return await el.getAttribute("password").catch(() => null) === "true";
|
|
1099
|
+
}
|
|
1100
|
+
settleClear(path, actual, secure, box) {
|
|
1101
|
+
const verdict = compareFillReread({ expected: "", actual, secure, verb: "clearText" });
|
|
1102
|
+
const fillVerdict = {
|
|
1103
|
+
verb: "clearText",
|
|
1104
|
+
verdict: verdict.verdict,
|
|
1105
|
+
path,
|
|
1106
|
+
...verdict.secure ? { secure: true } : {}
|
|
1107
|
+
};
|
|
1108
|
+
const metadata = { ...verdict.meta ? { fillReread: verdict.meta } : {}, fillVerdict };
|
|
1109
|
+
return verdict.ok ? { success: true, ...box, metadata } : { success: false, error: verdict.selfCorrectingError, ...box, metadata };
|
|
1110
|
+
}
|
|
1111
|
+
/** A driver error while clearing ends the clear; it is never repeated. */
|
|
1112
|
+
interruptedClear(path, secure, box) {
|
|
1113
|
+
return {
|
|
1114
|
+
success: false,
|
|
1115
|
+
error: "clearText was interrupted by a driver error; the field may be partly cleared, and it was not cleared again. Re-observe the field before clearing it again.",
|
|
1116
|
+
...box,
|
|
1117
|
+
metadata: {
|
|
1118
|
+
fillVerdict: {
|
|
1119
|
+
verb: "clearText",
|
|
1120
|
+
verdict: "unverified",
|
|
1121
|
+
path,
|
|
1122
|
+
...secure ? { secure: true } : {}
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
/** A driver error during delivery ends the fill: nothing is typed again and no Enter follows. */
|
|
1128
|
+
interruptedFill(path, secure, action, box) {
|
|
1129
|
+
const fillVerdict = {
|
|
1130
|
+
verb: "fill",
|
|
1131
|
+
verdict: "unverified",
|
|
1132
|
+
path,
|
|
1133
|
+
...secure ? { secure: true } : {}
|
|
1134
|
+
};
|
|
1135
|
+
return {
|
|
1136
|
+
success: false,
|
|
1137
|
+
error: "fill: typing was interrupted by a driver error; the field may hold part of the text, and it was not typed again. Re-observe the field before filling it again.",
|
|
1138
|
+
...box,
|
|
1139
|
+
metadata: { fillVerdict, ...action.submit ? { fillSubmit: { ...INPUT_FAILED } } : {} }
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
/**
|
|
1143
|
+
* The shared end of every Android fill (437 D-09, D-10, D-13, D-15, D-17): records the verdict
|
|
1144
|
+
* and, when a submit was requested, holds Enter on a mismatch or a multi-line field and otherwise
|
|
1145
|
+
* presses it once through `enter`.
|
|
1146
|
+
*/
|
|
1147
|
+
async settleFill(action, box, settle) {
|
|
1148
|
+
const { path, verdict, multiline, enter } = settle;
|
|
1149
|
+
const fillVerdict = {
|
|
1150
|
+
verb: "fill",
|
|
1151
|
+
verdict: verdict.verdict,
|
|
1152
|
+
path,
|
|
1153
|
+
...verdict.secure ? { secure: true } : {}
|
|
1154
|
+
};
|
|
1155
|
+
let fillSubmit;
|
|
1156
|
+
if (action.submit) {
|
|
1157
|
+
if (verdict.verdict === "mismatch") {
|
|
1158
|
+
fillSubmit = { sent: false, outcome: "not-sent", reason: "mismatch" };
|
|
1159
|
+
} else if (multiline) {
|
|
1160
|
+
fillSubmit = { sent: false, outcome: "not-sent", reason: "multi-line-field" };
|
|
1161
|
+
} else {
|
|
1162
|
+
fillSubmit = await enter();
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
const notice = fillSubmit?.reason === "multi-line-field" ? { data: MULTI_LINE_SUBMIT_NOTICE } : {};
|
|
1166
|
+
const metadata = {
|
|
1167
|
+
...verdict.meta ? { fillReread: verdict.meta } : {},
|
|
1168
|
+
fillVerdict,
|
|
1169
|
+
...fillSubmit ? { fillSubmit } : {}
|
|
1170
|
+
};
|
|
1171
|
+
if (!verdict.ok) return { success: false, error: verdict.selfCorrectingError, ...box, metadata };
|
|
1172
|
+
if (fillSubmit?.outcome === "failed") {
|
|
1173
|
+
return {
|
|
1174
|
+
success: false,
|
|
1175
|
+
error: "fill typed the text, but Enter failed and was not sent again. Re-observe the screen; if the field still holds the text, submit it without typing it again.",
|
|
1176
|
+
...box,
|
|
1177
|
+
metadata
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
return { success: true, ...box, ...notice, metadata };
|
|
1181
|
+
}
|
|
1182
|
+
/** A real W3C touch tap in the WebView context at the ref's CSS-pixel centre (437 D-11). */
|
|
1183
|
+
async trustedWebTap(center) {
|
|
1184
|
+
await safePerform(
|
|
1185
|
+
this.driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(center.x), y: Math.round(center.y), origin: "viewport" }).down({ button: 0 }).up({ button: 0 })
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
/** Whether the tap left an editable field focused at the ref's point; read-only. */
|
|
1189
|
+
async probeWebFocus(center) {
|
|
1190
|
+
const none = { editable: false, matches: false, multiline: false, secure: false };
|
|
1191
|
+
try {
|
|
1192
|
+
const raw = await this.driver.execute(
|
|
1193
|
+
WEB_FOCUS_PROBE_SCRIPT,
|
|
1194
|
+
Math.round(center.x),
|
|
1195
|
+
Math.round(center.y)
|
|
1196
|
+
);
|
|
1197
|
+
if (!raw || typeof raw !== "object") return none;
|
|
1198
|
+
return {
|
|
1199
|
+
editable: raw.editable === true,
|
|
1200
|
+
matches: raw.matches === true,
|
|
1201
|
+
multiline: raw.multiline === true,
|
|
1202
|
+
secure: raw.secure === true,
|
|
1203
|
+
...typeof raw.value === "string" ? { value: raw.value } : {}
|
|
1204
|
+
};
|
|
1205
|
+
} catch {
|
|
1206
|
+
return none;
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
/** Polls the read-only probe until the tap's field holds focus; returns the last answer. */
|
|
1210
|
+
async awaitWebFocus(center) {
|
|
1211
|
+
let focus = await this.probeWebFocus(center);
|
|
1212
|
+
for (let poll = 1; poll < WEB_FOCUS_POLLS && !(focus.editable && focus.matches); poll++) {
|
|
1213
|
+
await this.driver.pause(WEB_FOCUS_POLL_MS);
|
|
1214
|
+
focus = await this.probeWebFocus(center);
|
|
1215
|
+
}
|
|
1216
|
+
return focus;
|
|
1217
|
+
}
|
|
1218
|
+
async readWebFieldValue() {
|
|
1219
|
+
try {
|
|
1220
|
+
const value = await this.driver.execute(WEB_FIELD_VALUE_SCRIPT);
|
|
1221
|
+
return typeof value === "string" ? value : void 0;
|
|
1222
|
+
} catch {
|
|
1223
|
+
return void 0;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
/** Runs a driver call from NATIVE_APP and always returns to the active WebView context. */
|
|
1227
|
+
async withNativeContext(fn) {
|
|
1228
|
+
await this.ensureContext("NATIVE_APP");
|
|
1229
|
+
try {
|
|
1230
|
+
return await fn();
|
|
1231
|
+
} finally {
|
|
1232
|
+
if (this.activeWebContext) await this.ensureContext(this.activeWebContext);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
/** 437 D-11: a tap that left no editable field, or another field, focused writes nothing. */
|
|
1236
|
+
refuseFocus(verb, ref, focus, submit, box) {
|
|
1237
|
+
const nothing = verb === "fill" ? "nothing was typed" : "nothing was cleared";
|
|
1238
|
+
let fillRefusal;
|
|
1239
|
+
let error;
|
|
1240
|
+
if (focus === "moved") {
|
|
1241
|
+
fillRefusal = { verb, cause: "target-not-found", evidence: "stale-target" };
|
|
1242
|
+
error = `${verb}: after tapping ${ref}, the focused field is no longer where it was observed; ${nothing}. Re-observe and target the field where it is now.`;
|
|
1243
|
+
} else if (focus === "elsewhere") {
|
|
1244
|
+
fillRefusal = { verb, cause: "not-editable", evidence: "focus-elsewhere" };
|
|
1245
|
+
error = `${verb}: after tapping ${ref}, input focus is on another field; ${nothing}. Input is never sent to a field other than the target. Re-observe and target the field itself.`;
|
|
1246
|
+
} else {
|
|
1247
|
+
fillRefusal = { verb, cause: "not-editable", evidence: focus };
|
|
1248
|
+
error = `${verb}: the tap on ${ref} did not focus an editable field; ${nothing}. Target the input itself and try again.`;
|
|
1249
|
+
}
|
|
1250
|
+
return {
|
|
1251
|
+
success: false,
|
|
1252
|
+
error,
|
|
1253
|
+
...box,
|
|
1254
|
+
metadata: { fillRefusal, ...submit ? { fillSubmit: { ...INPUT_FAILED } } : {} }
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* WebView clearText (437 D-11, D-16): the same tap and focus probe as the fill, W3C Ctrl+A and
|
|
1259
|
+
* Backspace, then a read-back judged against the empty string. Never success without that check.
|
|
1260
|
+
*/
|
|
1261
|
+
async clearWebViewField(ref, center, box) {
|
|
1262
|
+
const driver = this.driver;
|
|
1263
|
+
await this.trustedWebTap(center);
|
|
1264
|
+
const focus = await this.awaitWebFocus(center);
|
|
1265
|
+
if (!focus.editable || !focus.matches) {
|
|
1266
|
+
return this.refuseFocus("clearText", ref, webFocusEvidence(focus), false, box);
|
|
1267
|
+
}
|
|
1268
|
+
try {
|
|
1269
|
+
await driver.keys(["Control", "a"]);
|
|
1270
|
+
await driver.keys(["Backspace"]);
|
|
1271
|
+
} catch {
|
|
1272
|
+
return this.interruptedClear("webview", focus.secure, box);
|
|
1273
|
+
}
|
|
1274
|
+
return this.settleClear("webview", await this.readWebFieldValue(), focus.secure, box);
|
|
1275
|
+
}
|
|
1276
|
+
/**
|
|
1277
|
+
* WebView fill as trusted input (437 D-11): a W3C tap on the ref, a read-only focus probe,
|
|
1278
|
+
* W3C Ctrl+A and Backspace for replace, `mobile: type` from NATIVE_APP, a read-only read-back,
|
|
1279
|
+
* and one `mobile: pressKey 66` from NATIVE_APP. No script writes the field or fires an event.
|
|
1280
|
+
*/
|
|
1281
|
+
async fillWebViewCompound(action, box, center) {
|
|
1282
|
+
const driver = this.driver;
|
|
1283
|
+
const isAppend = action.clearMode === "append";
|
|
1284
|
+
await this.trustedWebTap(center);
|
|
1285
|
+
const focus = await this.awaitWebFocus(center);
|
|
1286
|
+
if (!focus.editable || !focus.matches) {
|
|
1287
|
+
return this.refuseFocus("fill", action.ref, webFocusEvidence(focus), action.submit, box);
|
|
1288
|
+
}
|
|
1289
|
+
if (!focus.multiline && LINE_BREAK_RE.test(action.text)) {
|
|
1290
|
+
return this.refuseLineBreaks(action, box);
|
|
1291
|
+
}
|
|
1292
|
+
const prior = isAppend ? focus.value : "";
|
|
1293
|
+
try {
|
|
1294
|
+
if (isAppend) {
|
|
1295
|
+
await driver.keys(focus.multiline ? ["Control", "End"] : ["End"]);
|
|
1296
|
+
} else {
|
|
1297
|
+
await driver.keys(["Control", "a"]);
|
|
1298
|
+
await driver.keys(["Backspace"]);
|
|
1299
|
+
}
|
|
1300
|
+
await this.withNativeContext(() => driver.execute("mobile: type", { text: action.text }));
|
|
1301
|
+
} catch {
|
|
1302
|
+
return this.interruptedFill("webview", focus.secure, action, box);
|
|
1303
|
+
}
|
|
1304
|
+
const actual = prior === void 0 ? void 0 : await this.readWebFieldValue();
|
|
1305
|
+
const verdict = compareFillReread({
|
|
1306
|
+
expected: (prior ?? "") + action.text,
|
|
1307
|
+
actual,
|
|
1308
|
+
secure: focus.secure
|
|
1309
|
+
});
|
|
1310
|
+
return await this.settleFill(action, box, {
|
|
1311
|
+
path: "webview",
|
|
1312
|
+
verdict,
|
|
1313
|
+
multiline: focus.multiline,
|
|
1314
|
+
enter: () => this.withNativeContext(() => this.pressEnter())
|
|
1315
|
+
});
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* EXEC-02 platform primitives injected into the shared runner scroll loops (D-02/D-03; 436-02
|
|
1319
|
+
* D-25). A native swipe is `mobile: scrollGesture` over the container (the ref's bounds, else the
|
|
1320
|
+
* screen), whose boolean is the driver's own boundary signal; in a WebView the touch swipe is
|
|
1321
|
+
* measured over every WebView scroller before and after (436-18), paired by identity (436-23).
|
|
1322
|
+
* nativeSearchScroll drives the UiAutomator
|
|
1323
|
+
* `UiScrollable.scrollIntoView` rung over an injection-escaped selector; readInterimTree is a
|
|
1324
|
+
* light page-source parse — NO refMap mint, NO screenshot, lastRefs untouched.
|
|
1325
|
+
*/
|
|
1326
|
+
async buildAndroidScrollPrimitives(scrollDirection, maxSwipes, factor = 1) {
|
|
1327
|
+
const driver = this.driver;
|
|
1328
|
+
const viewport = await driver.getWindowSize();
|
|
1329
|
+
const axisLength = scrollDirection === "left" || scrollDirection === "right" ? viewport.width : viewport.height;
|
|
1330
|
+
const distance = Math.max(1, Math.round(axisLength * 0.6 * factor));
|
|
1331
|
+
const webViewSwipe = async (direction, ref) => {
|
|
1332
|
+
let sx = Math.round(viewport.width / 2);
|
|
1333
|
+
let sy = Math.round(viewport.height / 2);
|
|
1334
|
+
if (ref) {
|
|
1335
|
+
const { center } = new MobileElementResolver(this.lastRefs).resolve(ref);
|
|
1336
|
+
sx = Math.round(center.x);
|
|
1337
|
+
sy = Math.round(center.y);
|
|
1338
|
+
}
|
|
1339
|
+
const before = await this.readWebViewScrollers();
|
|
1340
|
+
const pts = computeSwipe(sx, sy, fingerDirectionFor(direction), distance);
|
|
1341
|
+
await safePerform(
|
|
1342
|
+
driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: pts.startX, y: pts.startY }).down({ button: 0 }).move({ x: pts.endX, y: pts.endY, duration: 300 }).up({ button: 0 })
|
|
1343
|
+
);
|
|
1344
|
+
const after = await this.readWebViewScrollers();
|
|
1345
|
+
return webViewSwipeSignal(direction, before, after);
|
|
1346
|
+
};
|
|
1347
|
+
return {
|
|
1348
|
+
swipeOnce: async (direction, ref) => {
|
|
1349
|
+
if (this.activeWebContext) return webViewSwipe(direction, ref);
|
|
1350
|
+
const area = gestureArea(
|
|
1351
|
+
ref ? new MobileElementResolver(this.lastRefs).resolve(ref).bounds : { x: 0, y: 0, width: viewport.width, height: viewport.height },
|
|
1352
|
+
direction,
|
|
1353
|
+
viewport
|
|
1354
|
+
);
|
|
1355
|
+
const canScrollMore = await driver.execute("mobile: scrollGesture", {
|
|
1356
|
+
left: Math.round(area.x),
|
|
1357
|
+
top: Math.round(area.y),
|
|
1358
|
+
width: Math.max(1, Math.round(area.width)),
|
|
1359
|
+
height: Math.max(1, Math.round(area.height)),
|
|
1360
|
+
direction,
|
|
1361
|
+
percent: 0.75 * factor
|
|
1362
|
+
});
|
|
1363
|
+
if (canScrollMore === true) return "moved";
|
|
1364
|
+
if (canScrollMore === false) return "at-boundary";
|
|
1365
|
+
return "unknown";
|
|
1366
|
+
},
|
|
1367
|
+
nativeSearchScroll: async (target) => {
|
|
1368
|
+
const escaped = escapeUiSelectorText(target);
|
|
1369
|
+
const selector = `new UiScrollable(new UiSelector().scrollable(true)).scrollIntoView(new UiSelector().textContains("${escaped}"))`;
|
|
1370
|
+
try {
|
|
1371
|
+
await driver.execute("mobile: scroll", { strategy: "-android uiautomator", selector, maxSwipes });
|
|
1372
|
+
return true;
|
|
1373
|
+
} catch {
|
|
1374
|
+
return false;
|
|
1375
|
+
}
|
|
1376
|
+
},
|
|
1377
|
+
readInterimTree: async () => {
|
|
1378
|
+
const xml = await driver.getPageSource();
|
|
1379
|
+
const { nodes } = parseMobileSource(xml, "android", viewport);
|
|
1380
|
+
return nodes;
|
|
1381
|
+
}
|
|
1382
|
+
};
|
|
1383
|
+
}
|
|
1384
|
+
/**
|
|
1385
|
+
* The WebView scrollers a touch swipe may have acted on (436-18 · D-25), read by the shared
|
|
1386
|
+
* WEBVIEW_SCROLLERS_SCRIPT. The touch point cannot be mapped into the WebView's CSS coordinates,
|
|
1387
|
+
* so the swipe is judged over all of them.
|
|
1388
|
+
* 2026-10-08 (436-23): a frame in view reads unknown; members carry an identity and are paired
|
|
1389
|
+
* by it; open shadow roots are walked; only on-screen element scrollers are members.
|
|
1390
|
+
*/
|
|
1391
|
+
async readWebViewScrollers() {
|
|
1392
|
+
try {
|
|
1393
|
+
return parseWebViewScrollers(await this.driver.execute(WEBVIEW_SCROLLERS_SCRIPT));
|
|
1394
|
+
} catch {
|
|
1395
|
+
return void 0;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* EXEC-03 openApp (D-10/D-11/D-12). Resolve a human name to a package id (or validate an
|
|
1400
|
+
* explicit bundleId) over the session-cached registry, then launch via activateApp. On a
|
|
1401
|
+
* resolution MISS the registry is re-enumerated exactly once (catches mid-run installs) before
|
|
1402
|
+
* erroring with the resolver's candidate ids. Never a best-guess launch (T-359-04).
|
|
1403
|
+
*/
|
|
1404
|
+
async openApp(action) {
|
|
1405
|
+
const driver = this.driver;
|
|
1406
|
+
let registry = await this.ensureAppRegistry();
|
|
1407
|
+
if (registry === null) {
|
|
1408
|
+
return {
|
|
1409
|
+
success: false,
|
|
1410
|
+
error: 'openApp could not enumerate installed apps: "mobile: listApps" failed and the shell fallback is disabled. Retry with an explicit openApp {bundleId}, or set AGENT_QA_ANDROID_USE_MOBILE_SHELL=1 to allow the pm-list fallback.'
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
if ("bundleId" in action) {
|
|
1414
|
+
if (!registry.some((entry) => entry.id === action.bundleId)) {
|
|
1415
|
+
return {
|
|
1416
|
+
success: false,
|
|
1417
|
+
error: `openApp: bundleId "${action.bundleId}" is not installed (not in the app registry). Verify the package id or use openApp {appName}.`
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
await driver.activateApp(action.bundleId);
|
|
1421
|
+
return { success: true, metadata: { openApp: { resolvedId: action.bundleId, matchKind: "explicit" } } };
|
|
1422
|
+
}
|
|
1423
|
+
let resolution = resolveAppName(action.appName, registry);
|
|
1424
|
+
if (resolution.kind === "unknown" || resolution.kind === "ambiguous") {
|
|
1425
|
+
const fresh = await this.enumerateApps();
|
|
1426
|
+
if (fresh !== null) {
|
|
1427
|
+
this.appRegistry = fresh;
|
|
1428
|
+
registry = fresh;
|
|
1429
|
+
resolution = resolveAppName(action.appName, registry);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
switch (resolution.kind) {
|
|
1433
|
+
case "exact":
|
|
1434
|
+
case "fuzzy":
|
|
1435
|
+
await driver.activateApp(resolution.id);
|
|
1436
|
+
return { success: true, metadata: { openApp: { resolvedId: resolution.id, matchKind: resolution.kind } } };
|
|
1437
|
+
case "ambiguous":
|
|
1438
|
+
case "unknown": {
|
|
1439
|
+
const ids = resolution.candidates.map((c) => c.id).join(", ");
|
|
1440
|
+
const lead = resolution.kind === "ambiguous" ? "is ambiguous between installed apps" : "did not match an installed app";
|
|
1441
|
+
return {
|
|
1442
|
+
success: false,
|
|
1443
|
+
error: `openApp: "${action.appName}" ${lead}. Closest candidates: ${ids || "(none)"}. Retry openApp {bundleId} with the exact package id.`
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
/** Return the cached registry, enumerating once on first use. Failed enumeration returns null (uncached). */
|
|
1449
|
+
async ensureAppRegistry() {
|
|
1450
|
+
if (this.appRegistry !== null) return this.appRegistry;
|
|
1451
|
+
const enumerated = await this.enumerateApps();
|
|
1452
|
+
if (enumerated !== null) this.appRegistry = enumerated;
|
|
1453
|
+
return enumerated;
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Enumerate installed apps through the driver only (ownership contract — no adb/child_process).
|
|
1457
|
+
* `mobile: listApps` maps package ids → AppRegistryEntry (labels only when the same call carries
|
|
1458
|
+
* them, D-10 — no per-package round-trips). On failure, fall back to `mobile: shell` pm-list ONLY
|
|
1459
|
+
* when the AGENT_QA_ANDROID_USE_MOBILE_SHELL gate is '1' (Pitfall 4). Null = enumeration unavailable.
|
|
1460
|
+
*/
|
|
1461
|
+
async enumerateApps() {
|
|
1462
|
+
const driver = this.driver;
|
|
1463
|
+
try {
|
|
1464
|
+
const raw = await driver.execute("mobile: listApps");
|
|
1465
|
+
return this.mapListApps(raw);
|
|
1466
|
+
} catch {
|
|
1467
|
+
if (process.env.AGENT_QA_ANDROID_USE_MOBILE_SHELL === "1") {
|
|
1468
|
+
try {
|
|
1469
|
+
const out = String(await driver.execute("mobile: shell", { command: "pm", args: ["list", "packages"] }));
|
|
1470
|
+
return this.parsePmListPackages(out);
|
|
1471
|
+
} catch {
|
|
1472
|
+
return null;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
return null;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
/** Coerce a `mobile: listApps` result (package-id-keyed map, or an array of entries) into the registry. */
|
|
1479
|
+
mapListApps(raw) {
|
|
1480
|
+
const labelOf = (value) => {
|
|
1481
|
+
if (!value || typeof value !== "object") return void 0;
|
|
1482
|
+
const v = value;
|
|
1483
|
+
for (const key of ["label", "name", "appName"]) {
|
|
1484
|
+
const candidate = v[key];
|
|
1485
|
+
if (typeof candidate === "string" && candidate.length > 0) return candidate;
|
|
1486
|
+
}
|
|
1487
|
+
return void 0;
|
|
1488
|
+
};
|
|
1489
|
+
const entries = [];
|
|
1490
|
+
if (Array.isArray(raw)) {
|
|
1491
|
+
for (const item of raw) {
|
|
1492
|
+
if (!item || typeof item !== "object") continue;
|
|
1493
|
+
const v = item;
|
|
1494
|
+
const id = v.packageName ?? v.bundleId ?? v.id;
|
|
1495
|
+
if (typeof id !== "string" || id.length === 0) continue;
|
|
1496
|
+
const label = labelOf(item);
|
|
1497
|
+
entries.push(label !== void 0 ? { id, label } : { id });
|
|
1498
|
+
}
|
|
1499
|
+
} else if (raw && typeof raw === "object") {
|
|
1500
|
+
for (const [id, value] of Object.entries(raw)) {
|
|
1501
|
+
if (!id) continue;
|
|
1502
|
+
const label = labelOf(value);
|
|
1503
|
+
entries.push(label !== void 0 ? { id, label } : { id });
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
return entries;
|
|
1507
|
+
}
|
|
1508
|
+
/** Parse `pm list packages` output ("package:com.foo\n…") into id-only registry entries. */
|
|
1509
|
+
parsePmListPackages(output) {
|
|
1510
|
+
const entries = [];
|
|
1511
|
+
for (const line of output.split("\n")) {
|
|
1512
|
+
const id = line.trim().replace(/^package:/, "");
|
|
1513
|
+
if (id) entries.push({ id });
|
|
1514
|
+
}
|
|
1515
|
+
return entries;
|
|
1516
|
+
}
|
|
1517
|
+
// ---------------------------------------------------------------------------
|
|
547
1518
|
// Actions
|
|
548
1519
|
// ---------------------------------------------------------------------------
|
|
549
1520
|
async executeAction(action) {
|
|
@@ -551,8 +1522,7 @@ var AndroidPlatformAdapter = class {
|
|
|
551
1522
|
const resolver = new MobileElementResolver(this.lastRefs);
|
|
552
1523
|
const inWeb = !!this.activeWebContext;
|
|
553
1524
|
switch (action.type) {
|
|
554
|
-
case "tap":
|
|
555
|
-
case "click": {
|
|
1525
|
+
case "tap": {
|
|
556
1526
|
const { center, bounds } = resolver.resolve(action.ref);
|
|
557
1527
|
if (inWeb) {
|
|
558
1528
|
await driver.execute(
|
|
@@ -577,50 +1547,20 @@ var AndroidPlatformAdapter = class {
|
|
|
577
1547
|
error: `Element ${action.ref} is not fillable (role: ${role}). Use a textbox or input element instead.`
|
|
578
1548
|
};
|
|
579
1549
|
}
|
|
1550
|
+
const box = { coordinates: center, boundingBox: bounds };
|
|
580
1551
|
if (inWeb) {
|
|
581
|
-
await
|
|
582
|
-
"var el = document.elementFromPoint(arguments[0], arguments[1]);if (el) { el.focus(); el.click(); }",
|
|
583
|
-
center.x,
|
|
584
|
-
center.y
|
|
585
|
-
);
|
|
586
|
-
await driver.execute(
|
|
587
|
-
'var el = document.activeElement;if (el) { el.value = ""; el.value = arguments[0];el.dispatchEvent(new Event("input", {bubbles:true})); }',
|
|
588
|
-
action.value
|
|
589
|
-
);
|
|
590
|
-
return { success: true, coordinates: center, boundingBox: bounds };
|
|
1552
|
+
return await this.fillWebViewCompound(action, box, center);
|
|
591
1553
|
}
|
|
592
1554
|
await safePerform(
|
|
593
1555
|
driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(center.x), y: Math.round(center.y) }).down({ button: 0 }).up({ button: 0 })
|
|
594
1556
|
);
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
return { success: true, coordinates: center, boundingBox: bounds };
|
|
600
|
-
}
|
|
601
|
-
} catch {
|
|
602
|
-
}
|
|
603
|
-
await driver.keys([action.value]);
|
|
604
|
-
return { success: true, coordinates: center, boundingBox: bounds };
|
|
605
|
-
}
|
|
606
|
-
case "select": {
|
|
607
|
-
const { center, bounds } = resolver.resolve(action.ref);
|
|
608
|
-
if (inWeb) {
|
|
609
|
-
await driver.execute(
|
|
610
|
-
'var el = document.elementFromPoint(arguments[0], arguments[1]);if (el) { el.value = arguments[2];el.dispatchEvent(new Event("change", {bubbles:true})); }',
|
|
611
|
-
center.x,
|
|
612
|
-
center.y,
|
|
613
|
-
action.value
|
|
614
|
-
);
|
|
615
|
-
return { success: true, coordinates: center, boundingBox: bounds };
|
|
616
|
-
} else {
|
|
617
|
-
return {
|
|
618
|
-
success: false,
|
|
619
|
-
error: "select is for HTML dropdowns. Use nativeSelect for Android native picker/dropdown/list controls, or tap/swipe/tapCoordinate for custom controls.",
|
|
620
|
-
coordinates: center,
|
|
621
|
-
boundingBox: bounds
|
|
622
|
-
};
|
|
1557
|
+
const focusedEl = await this.focusedField();
|
|
1558
|
+
const verdict = focusedEl ? await this.focusIsElsewhere(focusedEl, action.ref, bounds) : "target";
|
|
1559
|
+
if (verdict !== "target") {
|
|
1560
|
+
return this.refuseFocus("fill", action.ref, verdict, action.submit, box);
|
|
623
1561
|
}
|
|
1562
|
+
if (focusedEl) return await this.fillNativeCompound(focusedEl, action, box);
|
|
1563
|
+
return await this.fillKeyboardPath(action, box, refData);
|
|
624
1564
|
}
|
|
625
1565
|
case "nativeSelect": {
|
|
626
1566
|
const { center, bounds } = resolver.resolve(action.ref);
|
|
@@ -667,51 +1607,61 @@ var AndroidPlatformAdapter = class {
|
|
|
667
1607
|
};
|
|
668
1608
|
}
|
|
669
1609
|
case "scroll": {
|
|
670
|
-
if (
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
1610
|
+
if (action.mode === "untilVisible") {
|
|
1611
|
+
try {
|
|
1612
|
+
escapeUiSelectorText(action.text);
|
|
1613
|
+
} catch (err) {
|
|
1614
|
+
return { success: false, error: err instanceof Error ? err.message : String(err) };
|
|
1615
|
+
}
|
|
1616
|
+
const primitives2 = await this.buildAndroidScrollPrimitives(action.direction, action.maxSwipes);
|
|
1617
|
+
const { meta: meta2 } = await runUntilVisibleLoop(primitives2, {
|
|
1618
|
+
target: action.text,
|
|
1619
|
+
direction: action.direction,
|
|
1620
|
+
maxSwipes: action.maxSwipes,
|
|
1621
|
+
retryOpposite: action.retryOpposite,
|
|
1622
|
+
...action.ref ? { ref: action.ref } : {}
|
|
1623
|
+
});
|
|
1624
|
+
return { success: true, metadata: { scrollUntilVisible: meta2 } };
|
|
679
1625
|
}
|
|
680
|
-
const
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
const
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
return { success: true };
|
|
1626
|
+
const factor = action.amount === "halfPage" ? 0.5 : 1;
|
|
1627
|
+
const primitives = await this.buildAndroidScrollPrimitives(action.direction, 0, factor);
|
|
1628
|
+
if (action.amount === "toEnd") {
|
|
1629
|
+
const { meta: meta2 } = await runToEndScroll(primitives, action.direction, action.ref);
|
|
1630
|
+
return { success: true, metadata: { scrollUntilVisible: meta2 } };
|
|
1631
|
+
}
|
|
1632
|
+
const { meta } = await runDirectionScroll(primitives, {
|
|
1633
|
+
direction: action.direction,
|
|
1634
|
+
...action.ref ? { ref: action.ref } : {}
|
|
1635
|
+
});
|
|
1636
|
+
return { success: true, metadata: { scrollUntilVisible: meta } };
|
|
692
1637
|
}
|
|
693
1638
|
case "swipe": {
|
|
694
1639
|
if (inWeb) {
|
|
695
1640
|
await this.ensureContext("NATIVE_APP");
|
|
696
1641
|
}
|
|
697
1642
|
const { width, height } = await driver.getWindowSize();
|
|
698
|
-
let
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
1643
|
+
let pts;
|
|
1644
|
+
if (action.mode === "path") {
|
|
1645
|
+
pts = {
|
|
1646
|
+
startX: Math.round(action.from.x),
|
|
1647
|
+
startY: Math.round(action.from.y),
|
|
1648
|
+
endX: Math.round(action.to.x),
|
|
1649
|
+
endY: Math.round(action.to.y)
|
|
1650
|
+
};
|
|
1651
|
+
} else {
|
|
1652
|
+
let sx = Math.round(width / 2);
|
|
1653
|
+
let sy = Math.round(height / 2);
|
|
1654
|
+
if (action.ref) {
|
|
1655
|
+
const { center } = resolver.resolve(action.ref);
|
|
1656
|
+
sx = Math.round(center.x);
|
|
1657
|
+
sy = Math.round(center.y);
|
|
1658
|
+
}
|
|
1659
|
+
const distance = Math.round(height * 0.6);
|
|
1660
|
+
pts = computeSwipe(sx, sy, action.direction, distance);
|
|
1661
|
+
}
|
|
712
1662
|
warnIfOutOfBounds({ x: pts.startX, y: pts.startY }, { width, height }, "swipe");
|
|
713
1663
|
warnIfOutOfBounds({ x: pts.endX, y: pts.endY }, { width, height }, "swipe");
|
|
714
|
-
const swipeDuration = action.
|
|
1664
|
+
const swipeDuration = action.durationMs;
|
|
715
1665
|
await safePerform(
|
|
716
1666
|
driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: pts.startX, y: pts.startY }).down({ button: 0 }).move({ x: pts.endX, y: pts.endY, duration: swipeDuration }).up({ button: 0 })
|
|
717
1667
|
);
|
|
@@ -728,13 +1678,14 @@ var AndroidPlatformAdapter = class {
|
|
|
728
1678
|
const { width: pinchW, height: pinchH } = await driver.getWindowSize();
|
|
729
1679
|
let pcx = Math.round(pinchW / 2);
|
|
730
1680
|
let pcy = Math.round(pinchH / 2);
|
|
731
|
-
if (action
|
|
1681
|
+
if ("ref" in action) {
|
|
732
1682
|
const { center } = resolver.resolve(action.ref);
|
|
733
1683
|
pcx = Math.round(center.x);
|
|
734
1684
|
pcy = Math.round(center.y);
|
|
1685
|
+
} else {
|
|
1686
|
+
pcx = action.x;
|
|
1687
|
+
pcy = action.y;
|
|
735
1688
|
}
|
|
736
|
-
if (action.x !== void 0) pcx = action.x;
|
|
737
|
-
if (action.y !== void 0) pcy = action.y;
|
|
738
1689
|
warnIfOutOfBounds({ x: pcx, y: pcy }, { width: pinchW, height: pinchH }, "pinch");
|
|
739
1690
|
const { finger1Start, finger1End, finger2Start, finger2End } = computePinch(pcx, pcy, action.scale);
|
|
740
1691
|
const pinchDuration = 500;
|
|
@@ -748,13 +1699,14 @@ var AndroidPlatformAdapter = class {
|
|
|
748
1699
|
const { width: mtW, height: mtH } = await driver.getWindowSize();
|
|
749
1700
|
let mtcx = Math.round(mtW / 2);
|
|
750
1701
|
let mtcy = Math.round(mtH / 2);
|
|
751
|
-
if (action
|
|
1702
|
+
if ("ref" in action) {
|
|
752
1703
|
const { center } = resolver.resolve(action.ref);
|
|
753
1704
|
mtcx = Math.round(center.x);
|
|
754
1705
|
mtcy = Math.round(center.y);
|
|
1706
|
+
} else {
|
|
1707
|
+
mtcx = action.x;
|
|
1708
|
+
mtcy = action.y;
|
|
755
1709
|
}
|
|
756
|
-
if (action.x !== void 0) mtcx = action.x;
|
|
757
|
-
if (action.y !== void 0) mtcy = action.y;
|
|
758
1710
|
warnIfOutOfBounds({ x: mtcx, y: mtcy }, { width: mtW, height: mtH }, "multiTap");
|
|
759
1711
|
const positions = computeFingerPositions(mtcx, mtcy, action.fingers);
|
|
760
1712
|
await driver.actions(
|
|
@@ -772,34 +1724,48 @@ var AndroidPlatformAdapter = class {
|
|
|
772
1724
|
return { success: true };
|
|
773
1725
|
}
|
|
774
1726
|
case "waitFor": {
|
|
775
|
-
|
|
776
|
-
|
|
1727
|
+
if (action.on === "selector") {
|
|
1728
|
+
return {
|
|
1729
|
+
success: false,
|
|
1730
|
+
error: 'waitFor {on:"selector"} (CSS/XPath) is a web-only mode. On native Android, wait on visible text with waitFor {on:"text"} or {on:"gone"}.'
|
|
1731
|
+
};
|
|
1732
|
+
}
|
|
1733
|
+
const wantPresent = action.on === "text";
|
|
1734
|
+
const needle = action.text;
|
|
1735
|
+
const deadline = Date.now() + action.timeoutMs;
|
|
1736
|
+
for (; ; ) {
|
|
1737
|
+
let source = "";
|
|
1738
|
+
try {
|
|
1739
|
+
source = await driver.getPageSource();
|
|
1740
|
+
} catch {
|
|
1741
|
+
source = "";
|
|
1742
|
+
}
|
|
1743
|
+
if (source.includes(needle) === wantPresent) return { success: true };
|
|
1744
|
+
if (Date.now() >= deadline) break;
|
|
1745
|
+
await driver.pause(Math.min(250, Math.max(1, deadline - Date.now())));
|
|
1746
|
+
}
|
|
1747
|
+
return {
|
|
1748
|
+
success: false,
|
|
1749
|
+
error: wantPresent ? `waitFor: text "${needle}" did not appear within ${action.timeoutMs}ms.` : `waitFor: text "${needle}" was still present after ${action.timeoutMs}ms.`
|
|
1750
|
+
};
|
|
777
1751
|
}
|
|
778
1752
|
case "delay": {
|
|
779
1753
|
await driver.pause(action.ms);
|
|
780
1754
|
return { success: true };
|
|
781
1755
|
}
|
|
782
1756
|
case "keypress": {
|
|
1757
|
+
if (action.mode !== "press") {
|
|
1758
|
+
return {
|
|
1759
|
+
success: false,
|
|
1760
|
+
error: `keypress {mode:"${action.mode}"} (key hold/release) is web-only. On mobile, emit keypress {mode:"press"} to send the key.`
|
|
1761
|
+
};
|
|
1762
|
+
}
|
|
783
1763
|
for (const rawKey of action.keys) {
|
|
784
1764
|
const mapped = KEY_MAP[rawKey] ?? rawKey;
|
|
785
1765
|
await driver.keys([mapped]);
|
|
786
1766
|
}
|
|
787
1767
|
return { success: true };
|
|
788
1768
|
}
|
|
789
|
-
case "navigate": {
|
|
790
|
-
try {
|
|
791
|
-
await driver.url(action.url);
|
|
792
|
-
return { success: true };
|
|
793
|
-
} catch {
|
|
794
|
-
return { success: false, error: "navigate is not supported for this app. Use tap/fill on UI elements to navigate." };
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
case "hover": {
|
|
798
|
-
return { success: false, error: "hover is a web-only action. Use tap or longpress instead." };
|
|
799
|
-
}
|
|
800
|
-
case "assert": {
|
|
801
|
-
return { success: true };
|
|
802
|
-
}
|
|
803
1769
|
// --- New actions ---
|
|
804
1770
|
case "hideKeyboard": {
|
|
805
1771
|
try {
|
|
@@ -822,41 +1788,113 @@ var AndroidPlatformAdapter = class {
|
|
|
822
1788
|
error: `Element ${action.ref} is not fillable (role: ${clearRole}). clearText only works on text inputs. Use a textbox or input element instead.`
|
|
823
1789
|
};
|
|
824
1790
|
}
|
|
1791
|
+
if (inWeb) {
|
|
1792
|
+
return await this.clearWebViewField(action.ref, center, {
|
|
1793
|
+
coordinates: center,
|
|
1794
|
+
boundingBox: bounds
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
825
1797
|
await safePerform(
|
|
826
1798
|
driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(center.x), y: Math.round(center.y) }).down({ button: 0 }).up({ button: 0 })
|
|
827
1799
|
);
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
} catch {
|
|
1800
|
+
const clearBox = { coordinates: center, boundingBox: bounds };
|
|
1801
|
+
const clearHandle = await this.focusedField();
|
|
1802
|
+
const clearVerdict = clearHandle ? await this.focusIsElsewhere(clearHandle, action.ref, bounds) : "target";
|
|
1803
|
+
if (clearVerdict !== "target") {
|
|
1804
|
+
return this.refuseFocus("clearText", action.ref, clearVerdict, false, clearBox);
|
|
834
1805
|
}
|
|
835
|
-
return
|
|
1806
|
+
return await this.clearNativeOrKeyboard(clearBox, clearHandle);
|
|
1807
|
+
}
|
|
1808
|
+
// --- Android hardware / system verbs (numeric keycodes / typed driver args only) ---
|
|
1809
|
+
case "pressBack":
|
|
1810
|
+
await driver.execute("mobile: pressKey", { keycode: 4 });
|
|
1811
|
+
return { success: true };
|
|
1812
|
+
case "pressHome":
|
|
1813
|
+
await driver.execute("mobile: pressKey", { keycode: 3 });
|
|
1814
|
+
return { success: true };
|
|
1815
|
+
case "pressRecent":
|
|
1816
|
+
await driver.execute("mobile: pressKey", { keycode: 187 });
|
|
1817
|
+
return { success: true };
|
|
1818
|
+
case "openNotifications":
|
|
1819
|
+
await driver.execute("mobile: openNotifications");
|
|
1820
|
+
return { success: true };
|
|
1821
|
+
case "openQuickSettings":
|
|
1822
|
+
return await this.runShell("openQuickSettings", {
|
|
1823
|
+
command: "cmd",
|
|
1824
|
+
args: ["statusbar", "expand-settings"]
|
|
1825
|
+
});
|
|
1826
|
+
case "intent": {
|
|
1827
|
+
const TYPE_TAG = {
|
|
1828
|
+
string: "s",
|
|
1829
|
+
int: "i",
|
|
1830
|
+
long: "l",
|
|
1831
|
+
float: "f",
|
|
1832
|
+
bool: "z"
|
|
1833
|
+
};
|
|
1834
|
+
const intentArgs = { action: action.action };
|
|
1835
|
+
if (action.data !== void 0) intentArgs.uri = action.data;
|
|
1836
|
+
if (action.packageName !== void 0) intentArgs.package = action.packageName;
|
|
1837
|
+
if (action.extras && action.extras.length > 0) {
|
|
1838
|
+
intentArgs.extras = action.extras.map(
|
|
1839
|
+
(e) => [TYPE_TAG[e.type], e.key, e.value]
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
await driver.execute("mobile: startActivity", intentArgs);
|
|
1843
|
+
return { success: true };
|
|
836
1844
|
}
|
|
837
|
-
case "
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1845
|
+
case "keyevent": {
|
|
1846
|
+
if (!/^KEYCODE_[A-Z0-9_]+$/.test(action.keycode)) {
|
|
1847
|
+
return {
|
|
1848
|
+
success: false,
|
|
1849
|
+
error: `keyevent: "${action.keycode}" is not a valid Android key code. Provide a symbolic code matching ^KEYCODE_[A-Z0-9_]+$ (e.g. KEYCODE_ENTER, KEYCODE_TAB).`
|
|
1850
|
+
};
|
|
842
1851
|
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
1852
|
+
return await this.runShell("keyevent", {
|
|
1853
|
+
command: "input",
|
|
1854
|
+
args: ["keyevent", action.keycode]
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
case "assertValue": {
|
|
1858
|
+
if ("expr" in action) {
|
|
1859
|
+
if (!inWeb) {
|
|
1860
|
+
return {
|
|
1861
|
+
success: false,
|
|
1862
|
+
error: "assertValue {expr} evaluates JavaScript and is only available in an Android WebView/browser context. Assert on a native signal, or run against a web target."
|
|
1863
|
+
};
|
|
848
1864
|
}
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1865
|
+
const value = await driver.execute(`return (${action.expr});`);
|
|
1866
|
+
return { success: true, data: value };
|
|
1867
|
+
}
|
|
1868
|
+
switch (action.source) {
|
|
1869
|
+
case "url": {
|
|
1870
|
+
if (!inWeb) {
|
|
1871
|
+
return {
|
|
1872
|
+
success: false,
|
|
1873
|
+
error: 'assertValue {source:"url"} reads the browser URL and is only available in an Android WebView/browser context; native screens have no URL.'
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1876
|
+
const value = await driver.execute("return window.location.href;");
|
|
1877
|
+
return { success: true, data: value };
|
|
853
1878
|
}
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
1879
|
+
case "storage": {
|
|
1880
|
+
if (!inWeb) {
|
|
1881
|
+
return {
|
|
1882
|
+
success: false,
|
|
1883
|
+
error: 'assertValue {source:"storage"} reads Web Storage and is only available in an Android WebView/browser context.'
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
const value = await driver.execute(
|
|
1887
|
+
"return Object.fromEntries(Object.keys(localStorage).map(function(k){return [k, localStorage.getItem(k)];}));"
|
|
1888
|
+
);
|
|
1889
|
+
return { success: true, data: value };
|
|
859
1890
|
}
|
|
1891
|
+
case "network":
|
|
1892
|
+
return {
|
|
1893
|
+
success: false,
|
|
1894
|
+
error: 'assertValue {source:"network"} (HTTP log inspection) is not available on the Android adapter. Use a web target for network assertions, or assert on visible UI text.'
|
|
1895
|
+
};
|
|
1896
|
+
case "expr-result":
|
|
1897
|
+
return { success: true, data: void 0 };
|
|
860
1898
|
}
|
|
861
1899
|
}
|
|
862
1900
|
case "drag": {
|
|
@@ -878,10 +1916,8 @@ var AndroidPlatformAdapter = class {
|
|
|
878
1916
|
);
|
|
879
1917
|
return { success: true, coordinates: center, boundingBox: bounds };
|
|
880
1918
|
}
|
|
881
|
-
case "
|
|
882
|
-
await
|
|
883
|
-
return { success: true };
|
|
884
|
-
}
|
|
1919
|
+
case "openApp":
|
|
1920
|
+
return await this.openApp(action);
|
|
885
1921
|
case "stopApp": {
|
|
886
1922
|
await driver.terminateApp(action.bundleId);
|
|
887
1923
|
return { success: true };
|
|
@@ -912,15 +1948,21 @@ var AndroidPlatformAdapter = class {
|
|
|
912
1948
|
}
|
|
913
1949
|
return { success: true, coordinates: { x, y } };
|
|
914
1950
|
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1951
|
+
// Runner-minted / author-channel verbs (D-01/D-15): reportValue, assertAI, extractAI, and
|
|
1952
|
+
// setVariable are intercepted at the runner dispatch seam and never reach the adapter. A
|
|
1953
|
+
// grouped §7.1 defensive case surfaces a dispatch-routing error if one arrives here;
|
|
1954
|
+
// assertNever remains the backstop for a genuinely unknown schema verb. (uniqueValue is
|
|
1955
|
+
// web-only and is not part of ActionsFor<'android'>.)
|
|
1956
|
+
case "reportValue":
|
|
1957
|
+
case "assertAI":
|
|
1958
|
+
case "extractAI":
|
|
1959
|
+
case "setVariable":
|
|
1960
|
+
return {
|
|
1961
|
+
success: false,
|
|
1962
|
+
error: `Action "${action.type}" is handled by the runner dispatch seam and must not reach the Android adapter (runner-minted/author-channel verb). This indicates a dispatch routing error.`
|
|
1963
|
+
};
|
|
1964
|
+
default:
|
|
1965
|
+
return assertNever(action);
|
|
924
1966
|
}
|
|
925
1967
|
}
|
|
926
1968
|
};
|