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