@vostride/agent-qa-android 0.1.20 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs 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
- function firstNonEmpty(...values) {
137
- for (const value of values) {
138
- const trimmed = typeof value === "string" ? value.trim() : "";
139
- if (trimmed) return trimmed;
140
- }
141
- return void 0;
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 vh = window.innerHeight;
259
+ var doc = document;
199
260
  var vw = window.innerWidth;
200
- var refCounter = 0;
201
- var elements = [];
202
- var refs = {};
203
- var lines = [];
204
- var seen = {};
261
+ var vh = window.innerHeight;
262
+ var active = doc.activeElement;
205
263
 
206
- var all = document.querySelectorAll(
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] || null;
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 (tag === 'INPUT' || tag === 'TEXTAREA') {
230
- name = el.getAttribute('placeholder') || el.value || '';
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 && role !== 'textbox') continue;
237
-
238
- var key = role + ':' + name;
239
- if (seen[key]) continue;
240
- seen[key] = true;
351
+ if (!name && !editable && !textual) continue;
241
352
 
242
- refCounter++;
243
- var ref = 'e' + refCounter;
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
- var offscreen = bounds.y + bounds.height < 0 || bounds.y > vh;
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
- elements.push({ ref: ref, role: role, name: name, attributes: {} });
255
- refs[ref] = { role: role, name: name, bounds: bounds };
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 line = '- ' + role + ' "' + name.replace(/"/g, "'") + '" [ref=' + ref + ']';
258
- if (offscreen) line += ' [offscreen]';
259
- lines.push(line);
260
- }
387
+ var attributes = {};
388
+ if (el.type) attributes.type = String(el.type);
261
389
 
262
- var visCount = 0, aboveCount = 0, belowCount = 0;
263
- var refKeys = Object.keys(refs);
264
- for (var j = 0; j < refKeys.length; j++) {
265
- var b = refs[refKeys[j]].bounds;
266
- if (b.y + b.height <= 0) aboveCount++;
267
- else if (b.y >= vh) belowCount++;
268
- else visCount++;
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
- var header = 'Current page: ' + location.href + '\\n';
272
- header += '[Viewport: ' + vw + 'x' + vh + ']';
273
- if (aboveCount > 0 || belowCount > 0) {
274
- header += ' [' + visCount + ' visible, ' + aboveCount + ' above, ' + belowCount + ' below]';
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
- tree: header + '\\n' + lines.join('\\n'),
279
- elements: elements,
280
- refs: refs,
281
- url: location.href,
282
- viewportHeight: vh,
283
- viewportWidth: vw
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
- // Video recording state
298
- recordingEnabled = false;
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.activateApp(appPackage);
315
- } catch (err) {
316
- throw new import_agent_qa_core2.MobileSetupError({
317
- category: "app-launch",
318
- message: `Failed to launch Android app "${appPackage}": ${err instanceof Error ? err.message : String(err)}`,
319
- platform: "android",
320
- deviceName: config.device?.name,
321
- appId: appPackage,
322
- cause: err
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
- if (this.recordingEnabled && this.driver) {
355
- try {
356
- await this.stopAndSaveRecording();
357
- } catch {
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: { coordSpace: "viewport", viewportWidth: 0, viewportHeight: 0 }
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-core");
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
- // Video recording
480
- // ---------------------------------------------------------------------------
481
- async getVideoPath() {
482
- if (this.recordingEnabled && !this.videoPath && this.driver) {
483
- await this.stopAndSaveRecording();
484
- }
485
- return this.videoPath;
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
- async stopAndSaveRecording() {
488
- if (!this.driver || !this.videoDir) return;
489
- this.recordingEnabled = false;
490
- let base64Video;
491
- try {
492
- base64Video = await this.driver.stopRecordingScreen();
493
- } catch {
494
- try {
495
- base64Video = await this.driver.execute("mobile: stopScreenRecording");
496
- } catch {
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
- if (!base64Video) return;
500
- const { mkdirSync, writeFileSync } = await import("fs");
501
- const { join } = await import("path");
502
- mkdirSync(this.videoDir, { recursive: true });
503
- const filename = `android-${Date.now()}.mp4`;
504
- const filePath = join(this.videoDir, filename);
505
- writeFileSync(filePath, Buffer.from(base64Video, "base64"));
506
- this.videoPath = filePath;
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 result = await driver.execute(WEB_OBSERVER_SCRIPT);
522
- if (!result || !result.elements || result.elements.length === 0) return null;
523
- this.lastRefs = result.refs;
524
- this.activeWebContext = webCtx;
525
- return {
526
- tree: result.tree,
527
- elements: result.elements,
528
- url: result.url,
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
- metadata: {
531
- coordSpace: "viewport",
532
- viewportWidth: result.viewportWidth,
533
- viewportHeight: result.viewportHeight,
534
- refMap: result.refs
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 parsed = (0, import_agent_qa_core2.parseMobileSource)(xml, "android", viewport);
549
- this.lastRefs = parsed.refs;
550
- return {
551
- tree: parsed.tree,
552
- elements: parsed.elements,
553
- timestamp: Date.now(),
554
- metadata: { coordSpace: "viewport", viewportWidth: viewport.width, viewportHeight: viewport.height, refMap: parsed.refs }
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
- return Buffer.from(base64, "base64");
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 driver.execute(
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
- try {
633
- const el = await driver.$('//*[@focused="true"]');
634
- if (await el.isExisting()) {
635
- await el.setValue(action.value);
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 (inWeb) {
708
- const dx = action.scrollType === "horizontal" ? action.value : 0;
709
- const dy = action.scrollType === "vertical" ? action.value : 0;
710
- await driver.execute(
711
- "window.scrollBy(arguments[0], arguments[1]);",
712
- dx,
713
- dy
714
- );
715
- return { success: true };
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 { width, height } = await driver.getWindowSize();
718
- const centerX = Math.round(width / 2);
719
- const centerY = Math.round(height / 2);
720
- const scrollStartX = centerX;
721
- const scrollStartY = centerY;
722
- const scrollEndX = action.scrollType === "horizontal" ? centerX - action.value : centerX;
723
- const scrollEndY = action.scrollType === "vertical" ? centerY - action.value : centerY;
724
- const scrollDuration = action.duration ?? 300;
725
- await safePerform(
726
- driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: scrollStartX, y: scrollStartY }).down({ button: 0 }).move({ x: scrollEndX, y: scrollEndY, duration: scrollDuration }).up({ button: 0 })
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 sx = Math.round(width / 2);
736
- let sy = Math.round(height / 2);
737
- if (action.ref) {
738
- const { center } = resolver.resolve(action.ref);
739
- sx = Math.round(center.x);
740
- sy = Math.round(center.y);
741
- }
742
- const distance = Math.round(height * 0.6);
743
- const pts = action.startX !== void 0 && action.startY !== void 0 && action.endX !== void 0 && action.endY !== void 0 ? {
744
- startX: action.startX,
745
- startY: action.startY,
746
- endX: action.endX,
747
- endY: action.endY
748
- } : (0, import_agent_qa_core2.computeSwipe)(sx, sy, action.direction, distance);
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.duration ?? 300;
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.ref) {
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.ref) {
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
- await driver.pause(action.timeout ?? 2e3);
813
- return { success: true };
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
- try {
866
- const el = await driver.$('//*[@focused="true"]');
867
- if (await el.isExisting()) {
868
- await el.clearValue();
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 { success: true, coordinates: center, boundingBox: bounds };
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 "openLink": {
875
- const appPackage = firstNonEmpty(action.appId, action.appPackage, this.appPackage);
876
- const isWebUrl = /^https?:\/\//i.test(action.url);
877
- if (!appPackage && !isWebUrl) {
878
- return { success: false, error: `Missing Android app package for native deep link: ${action.url}` };
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
- try {
881
- if (appPackage) {
882
- await driver.execute("mobile: deepLink", { url: action.url, package: appPackage });
883
- } else {
884
- await driver.url(action.url);
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
- return { success: true };
887
- } catch (err) {
888
- if (!isWebUrl) {
889
- return { success: false, error: `Failed to open link ${action.url} in Android app ${appPackage}: ${err instanceof Error ? err.message : String(err)}` };
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
- try {
892
- await driver.url(action.url);
893
- return { success: true };
894
- } catch (fallbackErr) {
895
- return { success: false, error: `Failed to open link: ${action.url}: ${fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)}` };
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 "launchApp": {
919
- await driver.activateApp(action.bundleId);
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
- case "readConsoleLogs": {
953
- await this.pollDeviceLogs();
954
- const all = [...this.consoleLogs];
955
- const entries = action.level ? all.filter((e) => e.level === action.level) : all;
956
- return { success: true, data: entries };
957
- }
958
- default: {
959
- return { success: false, error: `Unknown action type: ${action.type}` };
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
  };