@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.js CHANGED
@@ -1,5 +1,32 @@
1
1
  // src/adapter.ts
2
- import { MobileSetupError as MobileSetupError2, parseMobileSource, MobileElementResolver, KEY_MAP, computeSwipe, computePinch, computeFingerPositions, warnIfOutOfBounds } from "@vostride/agent-qa-core";
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
- function firstNonEmpty(...values) {
100
- for (const value of values) {
101
- const trimmed = typeof value === "string" ? value.trim() : "";
102
- if (trimmed) return trimmed;
103
- }
104
- return void 0;
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 vh = window.innerHeight;
245
+ var doc = document;
162
246
  var vw = window.innerWidth;
163
- var refCounter = 0;
164
- var elements = [];
165
- var refs = {};
166
- var lines = [];
167
- var seen = {};
247
+ var vh = window.innerHeight;
248
+ var active = doc.activeElement;
168
249
 
169
- var all = document.querySelectorAll(
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] || null;
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 (tag === 'INPUT' || tag === 'TEXTAREA') {
193
- name = el.getAttribute('placeholder') || el.value || '';
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 && role !== 'textbox') continue;
200
-
201
- var key = role + ':' + name;
202
- if (seen[key]) continue;
203
- seen[key] = true;
337
+ if (!name && !editable && !textual) continue;
204
338
 
205
- refCounter++;
206
- var ref = 'e' + refCounter;
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
- var offscreen = bounds.y + bounds.height < 0 || bounds.y > vh;
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
- elements.push({ ref: ref, role: role, name: name, attributes: {} });
218
- refs[ref] = { role: role, name: name, bounds: bounds };
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 line = '- ' + role + ' "' + name.replace(/"/g, "'") + '" [ref=' + ref + ']';
221
- if (offscreen) line += ' [offscreen]';
222
- lines.push(line);
223
- }
373
+ var attributes = {};
374
+ if (el.type) attributes.type = String(el.type);
224
375
 
225
- var visCount = 0, aboveCount = 0, belowCount = 0;
226
- var refKeys = Object.keys(refs);
227
- for (var j = 0; j < refKeys.length; j++) {
228
- var b = refs[refKeys[j]].bounds;
229
- if (b.y + b.height <= 0) aboveCount++;
230
- else if (b.y >= vh) belowCount++;
231
- else visCount++;
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
- var header = 'Current page: ' + location.href + '\\n';
235
- header += '[Viewport: ' + vw + 'x' + vh + ']';
236
- if (aboveCount > 0 || belowCount > 0) {
237
- header += ' [' + visCount + ' visible, ' + aboveCount + ' above, ' + belowCount + ' below]';
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
- tree: header + '\\n' + lines.join('\\n'),
242
- elements: elements,
243
- refs: refs,
244
- url: location.href,
245
- viewportHeight: vh,
246
- viewportWidth: vw
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
- // Video recording state
261
- recordingEnabled = false;
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.activateApp(appPackage);
278
- } catch (err) {
279
- throw new MobileSetupError2({
280
- category: "app-launch",
281
- message: `Failed to launch Android app "${appPackage}": ${err instanceof Error ? err.message : String(err)}`,
282
- platform: "android",
283
- deviceName: config.device?.name,
284
- appId: appPackage,
285
- cause: err
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
- if (this.recordingEnabled && this.driver) {
318
- try {
319
- await this.stopAndSaveRecording();
320
- } catch {
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: { coordSpace: "viewport", viewportWidth: 0, viewportHeight: 0 }
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-core");
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
- // Video recording
443
- // ---------------------------------------------------------------------------
444
- async getVideoPath() {
445
- if (this.recordingEnabled && !this.videoPath && this.driver) {
446
- await this.stopAndSaveRecording();
447
- }
448
- return this.videoPath;
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
- async stopAndSaveRecording() {
451
- if (!this.driver || !this.videoDir) return;
452
- this.recordingEnabled = false;
453
- let base64Video;
454
- try {
455
- base64Video = await this.driver.stopRecordingScreen();
456
- } catch {
457
- try {
458
- base64Video = await this.driver.execute("mobile: stopScreenRecording");
459
- } catch {
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
- if (!base64Video) return;
463
- const { mkdirSync, writeFileSync } = await import("fs");
464
- const { join } = await import("path");
465
- mkdirSync(this.videoDir, { recursive: true });
466
- const filename = `android-${Date.now()}.mp4`;
467
- const filePath = join(this.videoDir, filename);
468
- writeFileSync(filePath, Buffer.from(base64Video, "base64"));
469
- this.videoPath = filePath;
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 result = await driver.execute(WEB_OBSERVER_SCRIPT);
485
- if (!result || !result.elements || result.elements.length === 0) return null;
486
- this.lastRefs = result.refs;
487
- this.activeWebContext = webCtx;
488
- return {
489
- tree: result.tree,
490
- elements: result.elements,
491
- url: result.url,
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
- metadata: {
494
- coordSpace: "viewport",
495
- viewportWidth: result.viewportWidth,
496
- viewportHeight: result.viewportHeight,
497
- refMap: result.refs
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 parsed = parseMobileSource(xml, "android", viewport);
512
- this.lastRefs = parsed.refs;
513
- return {
514
- tree: parsed.tree,
515
- elements: parsed.elements,
516
- timestamp: Date.now(),
517
- metadata: { coordSpace: "viewport", viewportWidth: viewport.width, viewportHeight: viewport.height, refMap: parsed.refs }
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
- return Buffer.from(base64, "base64");
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 driver.execute(
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
- try {
596
- const el = await driver.$('//*[@focused="true"]');
597
- if (await el.isExisting()) {
598
- await el.setValue(action.value);
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 (inWeb) {
671
- const dx = action.scrollType === "horizontal" ? action.value : 0;
672
- const dy = action.scrollType === "vertical" ? action.value : 0;
673
- await driver.execute(
674
- "window.scrollBy(arguments[0], arguments[1]);",
675
- dx,
676
- dy
677
- );
678
- return { success: true };
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 { width, height } = await driver.getWindowSize();
681
- const centerX = Math.round(width / 2);
682
- const centerY = Math.round(height / 2);
683
- const scrollStartX = centerX;
684
- const scrollStartY = centerY;
685
- const scrollEndX = action.scrollType === "horizontal" ? centerX - action.value : centerX;
686
- const scrollEndY = action.scrollType === "vertical" ? centerY - action.value : centerY;
687
- const scrollDuration = action.duration ?? 300;
688
- await safePerform(
689
- driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: scrollStartX, y: scrollStartY }).down({ button: 0 }).move({ x: scrollEndX, y: scrollEndY, duration: scrollDuration }).up({ button: 0 })
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 sx = Math.round(width / 2);
699
- let sy = Math.round(height / 2);
700
- if (action.ref) {
701
- const { center } = resolver.resolve(action.ref);
702
- sx = Math.round(center.x);
703
- sy = Math.round(center.y);
704
- }
705
- const distance = Math.round(height * 0.6);
706
- const pts = action.startX !== void 0 && action.startY !== void 0 && action.endX !== void 0 && action.endY !== void 0 ? {
707
- startX: action.startX,
708
- startY: action.startY,
709
- endX: action.endX,
710
- endY: action.endY
711
- } : computeSwipe(sx, sy, action.direction, distance);
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.duration ?? 300;
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.ref) {
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.ref) {
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
- await driver.pause(action.timeout ?? 2e3);
776
- return { success: true };
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
- try {
829
- const el = await driver.$('//*[@focused="true"]');
830
- if (await el.isExisting()) {
831
- await el.clearValue();
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 { success: true, coordinates: center, boundingBox: bounds };
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 "openLink": {
838
- const appPackage = firstNonEmpty(action.appId, action.appPackage, this.appPackage);
839
- const isWebUrl = /^https?:\/\//i.test(action.url);
840
- if (!appPackage && !isWebUrl) {
841
- return { success: false, error: `Missing Android app package for native deep link: ${action.url}` };
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
- try {
844
- if (appPackage) {
845
- await driver.execute("mobile: deepLink", { url: action.url, package: appPackage });
846
- } else {
847
- await driver.url(action.url);
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
- return { success: true };
850
- } catch (err) {
851
- if (!isWebUrl) {
852
- return { success: false, error: `Failed to open link ${action.url} in Android app ${appPackage}: ${err instanceof Error ? err.message : String(err)}` };
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
- try {
855
- await driver.url(action.url);
856
- return { success: true };
857
- } catch (fallbackErr) {
858
- return { success: false, error: `Failed to open link: ${action.url}: ${fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)}` };
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 "launchApp": {
882
- await driver.activateApp(action.bundleId);
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
- case "readConsoleLogs": {
916
- await this.pollDeviceLogs();
917
- const all = [...this.consoleLogs];
918
- const entries = action.level ? all.filter((e) => e.level === action.level) : all;
919
- return { success: true, data: entries };
920
- }
921
- default: {
922
- return { success: false, error: `Unknown action type: ${action.type}` };
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
  };