@vostride/agent-qa-ios 0.1.21 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -26,10 +26,25 @@ __export(index_exports, {
26
26
  module.exports = __toCommonJS(index_exports);
27
27
 
28
28
  // src/adapter.ts
29
+ var import_agent_qa_runner = require("@vostride/agent-qa-runner");
29
30
  var import_agent_qa_core = require("@vostride/agent-qa-core");
31
+ var import_agent_qa_runner_local = require("@vostride/agent-qa-runner-local");
30
32
 
31
33
  // src/session.ts
32
34
  var import_webdriverio = require("webdriverio");
35
+ var IOS_SNAPSHOT_MAX_DEPTH = 62;
36
+ var IOS_SNAPSHOT_FALLBACK_DEPTH = 50;
37
+ var IOS_SNAPSHOT_DEPTH_ENV = "AGENT_QA_IOS_SNAPSHOT_MAX_DEPTH";
38
+ var IOS_SNAPSHOT_DEPTH_CAPABILITY = "appium:settings[snapshotMaxDepth]";
39
+ function positiveInteger(value) {
40
+ if (typeof value === "number") return Number.isSafeInteger(value) && value > 0 ? value : void 0;
41
+ if (typeof value !== "string" || !/^\d+$/.test(value.trim())) return void 0;
42
+ const parsed = Number(value.trim());
43
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : void 0;
44
+ }
45
+ function resolveIOSSnapshotDepth(farmCapabilities) {
46
+ return positiveInteger(farmCapabilities?.[IOS_SNAPSHOT_DEPTH_CAPABILITY]) ?? positiveInteger(process.env[IOS_SNAPSHOT_DEPTH_ENV]) ?? IOS_SNAPSHOT_MAX_DEPTH;
47
+ }
33
48
  async function createIOSSession(config, farmSession) {
34
49
  const match = config.device?.match ?? {};
35
50
  const appiumUrl = process.env.AGENT_QA_APPIUM_URL ?? config.appiumUrl ?? "http://localhost:4723";
@@ -41,7 +56,8 @@ async function createIOSSession(config, farmSession) {
41
56
  "appium:automationName": match.automationName ?? "XCUITest",
42
57
  "appium:deviceName": config.device?.name ?? "iPhone Simulator",
43
58
  "appium:showIOSLog": true,
44
- "appium:newCommandTimeout": config.timeouts?.test ? Math.ceil(config.timeouts.test / 1e3) : 300
59
+ "appium:newCommandTimeout": config.timeouts?.test ? Math.ceil(config.timeouts.test / 1e3) : 300,
60
+ [IOS_SNAPSHOT_DEPTH_CAPABILITY]: resolveIOSSnapshotDepth()
45
61
  };
46
62
  if (!browserName && appStateNoReset !== void 0) capabilities["appium:noReset"] = appStateNoReset;
47
63
  if (match.platformVersion) capabilities["appium:platformVersion"] = match.platformVersion;
@@ -76,7 +92,6 @@ async function createIOSSession(config, farmSession) {
76
92
  }
77
93
 
78
94
  // src/adapter.ts
79
- var IOS_FILLABLE_ROLES = /* @__PURE__ */ new Set(["textbox", "searchbox", "textfield", "securetextfield", "searchfield", "combobox"]);
80
95
  var IOS_NATIVE_SELECT_TYPES = /* @__PURE__ */ new Set([
81
96
  "XCUIElementTypePickerWheel",
82
97
  "XCUIElementTypePicker",
@@ -85,6 +100,36 @@ var IOS_NATIVE_SELECT_TYPES = /* @__PURE__ */ new Set([
85
100
  "XCUIElementTypeComboBox"
86
101
  ]);
87
102
  var IOS_NATIVE_SELECT_ROLES = /* @__PURE__ */ new Set(["pickerwheel", "spinbutton", "combobox"]);
103
+ var W3C_ELEMENT_KEY = "element-6066-11e4-a52e-4f735a1b0f2d";
104
+ var IOS_SNAPSHOT_PROBE_INTERVAL = 20;
105
+ function errorNameAndMessage(err) {
106
+ return err instanceof Error ? `${err.name} ${err.message}` : String(err);
107
+ }
108
+ function isIOSSnapshotDepthError(err) {
109
+ return /kAXErrorIllegalArgument/i.test(errorNameAndMessage(err));
110
+ }
111
+ function isIOSSnapshotReadError(err) {
112
+ return /getting snapshot|stale element/i.test(errorNameAndMessage(err));
113
+ }
114
+ var FOCUS_POLLS = 3;
115
+ var FOCUS_POLL_PAUSE_MS = 300;
116
+ var RECT_TOLERANCE = 1;
117
+ 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";
118
+ var IOS_BUTTON_NAME = {
119
+ home: "home",
120
+ volumeUp: "volumeup",
121
+ action: "action"
122
+ };
123
+ var VOLUME_BUTTONS = /* @__PURE__ */ new Set(["volumeUp"]);
124
+ var MAX_PICKER_INDEX = 20;
125
+ var IOS_SAFARI_BUNDLE_ID = "com.apple.mobilesafari";
126
+ var IOS_SPRINGBOARD_BUNDLE_ID = "com.apple.springboard";
127
+ var IOS_LINK_HANDOFF_POLLS = 12;
128
+ var IOS_LINK_HANDOFF_PAUSE_MS = 500;
129
+ function firstErrorLine(err) {
130
+ return (err instanceof Error ? err.message : String(err)).split("\n")[0];
131
+ }
132
+ var IOS_TAP_HOLD_MS = 100;
88
133
  async function safePerform(chain) {
89
134
  try {
90
135
  await chain.perform();
@@ -118,6 +163,18 @@ function isIOSNativeSelectTarget(refData) {
118
163
  role && IOS_NATIVE_SELECT_ROLES.has(role) || refData?.nativeType && IOS_NATIVE_SELECT_TYPES.has(refData.nativeType)
119
164
  );
120
165
  }
166
+ function rectMatches(a, b) {
167
+ return Math.abs(a.x - b.x) <= RECT_TOLERANCE && Math.abs(a.y - b.y) <= RECT_TOLERANCE && Math.abs(a.width - b.width) <= RECT_TOLERANCE && Math.abs(a.height - b.height) <= RECT_TOLERANCE;
168
+ }
169
+ function rectWithin(inner, outer) {
170
+ return inner.x >= outer.x - RECT_TOLERANCE && inner.y >= outer.y - RECT_TOLERANCE && inner.x + inner.width <= outer.x + outer.width + RECT_TOLERANCE && inner.y + inner.height <= outer.y + outer.height + RECT_TOLERANCE;
171
+ }
172
+ function elementIdOf(ref) {
173
+ if (!ref || typeof ref !== "object") return void 0;
174
+ const record = ref;
175
+ const id = record[W3C_ELEMENT_KEY] ?? record.ELEMENT;
176
+ return typeof id === "string" && id ? id : void 0;
177
+ }
121
178
  function normalizePickerValue(value) {
122
179
  return value.trim().replace(/\s+/g, " ").toLowerCase();
123
180
  }
@@ -164,6 +221,31 @@ function matchesIOSApp(processName, bundleId) {
164
221
  const appName = bundleId.split(".").pop() ?? bundleId;
165
222
  return processName === appName || processName.includes(appName);
166
223
  }
224
+ var CONTROL_CHAR_RE = /[\u0000-\u001F\u007F]/;
225
+ function escapeIOSPredicateText(value) {
226
+ if (CONTROL_CHAR_RE.test(value)) {
227
+ throw new Error(
228
+ `scroll target contains a control character and cannot be used as an XCUITest predicate \u2014 provide a plain-text target for untilVisible.`
229
+ );
230
+ }
231
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
232
+ }
233
+ function buildIOSPredicate(target) {
234
+ const escaped = escapeIOSPredicateText(target);
235
+ return `label CONTAINS "${escaped}" OR name CONTAINS "${escaped}" OR value CONTAINS "${escaped}"`;
236
+ }
237
+ function fingerDirectionFor(scroll) {
238
+ switch (scroll) {
239
+ case "up":
240
+ return "down";
241
+ case "down":
242
+ return "up";
243
+ case "left":
244
+ return "right";
245
+ case "right":
246
+ return "left";
247
+ }
248
+ }
167
249
  function parseIOSSyslogLine(message, timestamp, bundleId) {
168
250
  const appiumMatch = message.match(IOS_APPIUM_LOG_REGEX);
169
251
  if (appiumMatch) {
@@ -197,14 +279,26 @@ var IOSPlatformAdapter = class {
197
279
  activeWebContext = null;
198
280
  webContextChecked = false;
199
281
  currentContext = null;
282
+ liveStreamCapability;
200
283
  consoleLogs = [];
201
284
  consoleDrainIndex = 0;
202
285
  captureConsole = true;
203
286
  bundleId = null;
287
+ // EXEC-03 session-cached app registry (D-10/D-12): enumerated once per session. null = not yet
288
+ // enumerated OR enumeration unavailable (iOS simulator — mobile: listApps throws). Reset in cleanup().
289
+ appRegistry = null;
290
+ sessionSnapshotDepth = IOS_SNAPSHOT_MAX_DEPTH;
291
+ snapshotDepth = IOS_SNAPSHOT_MAX_DEPTH;
292
+ readsAtLoweredDepth = 0;
293
+ snapshotDepthWarned = false;
204
294
  async setup(config) {
205
295
  this.config = config;
206
296
  this.captureConsole = config.logCapture?.console !== false;
207
297
  this.bundleId = this.config.bundleId ?? config.device?.match?.bundleId ?? null;
298
+ this.sessionSnapshotDepth = resolveIOSSnapshotDepth(config.farmSession?.capabilities);
299
+ this.snapshotDepth = this.sessionSnapshotDepth;
300
+ this.readsAtLoweredDepth = 0;
301
+ this.snapshotDepthWarned = false;
208
302
  this.driver = await createIOSSession(this.config, config.farmSession);
209
303
  const bundleId = this.bundleId;
210
304
  if (bundleId) {
@@ -223,6 +317,11 @@ var IOSPlatformAdapter = class {
223
317
  }
224
318
  }
225
319
  async cleanup() {
320
+ try {
321
+ await this.liveStreamCapability?.stop();
322
+ } catch {
323
+ }
324
+ this.liveStreamCapability = void 0;
226
325
  try {
227
326
  if (this.driver) {
228
327
  await this.driver.deleteSession();
@@ -234,9 +333,25 @@ var IOSPlatformAdapter = class {
234
333
  this.config = null;
235
334
  this.consoleLogs = [];
236
335
  this.bundleId = null;
336
+ this.appRegistry = null;
237
337
  this.activeWebContext = null;
238
338
  this.webContextChecked = false;
239
339
  this.currentContext = null;
340
+ this.sessionSnapshotDepth = IOS_SNAPSHOT_MAX_DEPTH;
341
+ this.snapshotDepth = IOS_SNAPSHOT_MAX_DEPTH;
342
+ this.readsAtLoweredDepth = 0;
343
+ this.snapshotDepthWarned = false;
344
+ }
345
+ /**
346
+ * 428 D-11: the labelled 1s snapshot preview, over `captureScreenshot()` — `takeScreenshot()`
347
+ * and nothing else. Never `screenshot()`: that one switches the Appium context, and a tick
348
+ * landing between a step's context switch and its command would run the command in the wrong
349
+ * context. Declared before the driver exists (a capture then answers `undefined`), so the
350
+ * composition's selection never falls through to `screenshot()`.
351
+ */
352
+ get liveStream() {
353
+ this.liveStreamCapability ??= (0, import_agent_qa_runner.createSnapshotLiveStream)({ capture: () => this.captureScreenshot() });
354
+ return this.liveStreamCapability;
240
355
  }
241
356
  async observe(_options) {
242
357
  if (!this.driver) {
@@ -244,11 +359,11 @@ var IOSPlatformAdapter = class {
244
359
  }
245
360
  await this.ensureContext("NATIVE_APP");
246
361
  const [xml, viewport] = await Promise.all([
247
- this.driver.getPageSource(),
362
+ this.readIOSPageSource(),
248
363
  this.driver.getWindowSize()
249
364
  ]);
250
- const parsed = (0, import_agent_qa_core.parseMobileSource)(xml, "ios", viewport);
251
- this.lastRefs = parsed.refs;
365
+ const { nodes } = (0, import_agent_qa_core.parseMobileSource)(xml, "ios", viewport);
366
+ const scroll = (0, import_agent_qa_core.deriveMobileScroll)(nodes, viewport);
252
367
  let url;
253
368
  const webCtx = await this.getWebContext();
254
369
  if (webCtx) {
@@ -263,13 +378,17 @@ var IOSPlatformAdapter = class {
263
378
  }
264
379
  }
265
380
  }
266
- return {
267
- tree: parsed.tree,
268
- elements: parsed.elements,
269
- url,
381
+ const { state, binding } = (0, import_agent_qa_runner.buildScreenState)({
382
+ platform: "ios",
383
+ nodes,
384
+ viewport,
385
+ scroll,
386
+ coordSpace: "viewport",
270
387
  timestamp: Date.now(),
271
- metadata: { coordSpace: "viewport", viewportWidth: viewport.width, viewportHeight: viewport.height, refMap: parsed.refs }
272
- };
388
+ ...url !== void 0 ? { url } : {}
389
+ });
390
+ this.lastRefs = (0, import_agent_qa_core.buildRefEntries)(nodes, binding);
391
+ return state;
273
392
  }
274
393
  async execute(action) {
275
394
  if (!this.driver) {
@@ -299,7 +418,7 @@ var IOSPlatformAdapter = class {
299
418
  if (!raw || !this.driver) return raw;
300
419
  try {
301
420
  const { width, height } = await this.driver.getWindowSize();
302
- return await (0, import_agent_qa_core.alignToWindow)(raw, { width, height });
421
+ return await (0, import_agent_qa_runner_local.alignToWindow)(raw, { width, height });
303
422
  } catch {
304
423
  return raw;
305
424
  }
@@ -361,6 +480,20 @@ var IOSPlatformAdapter = class {
361
480
  this.webContextChecked = false;
362
481
  this.activeWebContext = null;
363
482
  }
483
+ async readForegroundBundleId() {
484
+ try {
485
+ const info = await this.driver.execute("mobile: activeAppInfo");
486
+ if (!info || typeof info !== "object") return void 0;
487
+ const bundleId = info.bundleId;
488
+ return firstNonEmpty(typeof bundleId === "string" ? bundleId : void 0);
489
+ } catch {
490
+ return void 0;
491
+ }
492
+ }
493
+ isBrowserSession() {
494
+ const browserName = this.config?.device?.match?.browserName;
495
+ return typeof browserName === "string" && browserName.trim().length > 0;
496
+ }
364
497
  async ensureContext(target) {
365
498
  if (this.currentContext === target) return;
366
499
  try {
@@ -385,6 +518,427 @@ var IOSPlatformAdapter = class {
385
518
  }
386
519
  }
387
520
  // ---------------------------------------------------------------------------
521
+ // Compound behaviors (EXEC-01/02/03)
522
+ // ---------------------------------------------------------------------------
523
+ async readIOSPageSource() {
524
+ const driver = this.driver;
525
+ if (this.snapshotDepth < this.sessionSnapshotDepth && this.readsAtLoweredDepth >= IOS_SNAPSHOT_PROBE_INTERVAL) {
526
+ this.readsAtLoweredDepth = 0;
527
+ try {
528
+ await driver.updateSettings({ snapshotMaxDepth: this.sessionSnapshotDepth });
529
+ this.snapshotDepth = this.sessionSnapshotDepth;
530
+ } catch {
531
+ }
532
+ }
533
+ try {
534
+ return this.countLoweredRead(await driver.getPageSource());
535
+ } catch (readErr) {
536
+ const depthError = isIOSSnapshotDepthError(readErr);
537
+ if (!depthError && !isIOSSnapshotReadError(readErr)) throw readErr;
538
+ if (depthError && this.snapshotDepth > IOS_SNAPSHOT_FALLBACK_DEPTH) {
539
+ try {
540
+ await driver.updateSettings({ snapshotMaxDepth: IOS_SNAPSHOT_FALLBACK_DEPTH });
541
+ } catch {
542
+ throw readErr;
543
+ }
544
+ const failedDepth = this.snapshotDepth;
545
+ this.snapshotDepth = IOS_SNAPSHOT_FALLBACK_DEPTH;
546
+ this.readsAtLoweredDepth = 0;
547
+ if (!this.snapshotDepthWarned) {
548
+ this.snapshotDepthWarned = true;
549
+ const detail = (readErr instanceof Error ? readErr.message : String(readErr)).split("\n")[0].slice(0, 160);
550
+ console.warn(
551
+ `[ios-adapter] page source failed at snapshotMaxDepth ${failedDepth}; later reads stay at ${IOS_SNAPSHOT_FALLBACK_DEPTH} and retry ${this.sessionSnapshotDepth} every ${IOS_SNAPSHOT_PROBE_INTERVAL} reads (${detail})`
552
+ );
553
+ }
554
+ }
555
+ return this.countLoweredRead(await driver.getPageSource());
556
+ }
557
+ }
558
+ countLoweredRead(xml) {
559
+ if (this.snapshotDepth < this.sessionSnapshotDepth) this.readsAtLoweredDepth++;
560
+ return xml;
561
+ }
562
+ async tapAt(point) {
563
+ await safePerform(
564
+ this.driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(point.x), y: Math.round(point.y) }).down({ button: 0 }).pause(IOS_TAP_HOLD_MS).up({ button: 0 })
565
+ );
566
+ }
567
+ async readRect(elementId) {
568
+ try {
569
+ const rect = await this.driver.getElementRect(elementId);
570
+ return rect && typeof rect.x === "number" ? rect : void 0;
571
+ } catch {
572
+ return void 0;
573
+ }
574
+ }
575
+ /**
576
+ * The ref's own text-entry element: candidates of its XCUI type (selector built from the fixed
577
+ * type list only, never from app-supplied text) whose rect matches the observed bounds. Only a
578
+ * unique match is used; two or more matches, or none, ⇒ undefined. Position never selects.
579
+ */
580
+ async locateIOSTextTarget(ref) {
581
+ const refData = this.lastRefs[ref];
582
+ const nativeType = refData?.nativeType;
583
+ const bounds = refData?.bounds;
584
+ if (!nativeType || !import_agent_qa_core.IOS_EDITABLE_TYPES.has(nativeType) || !bounds) return void 0;
585
+ let candidates;
586
+ try {
587
+ candidates = Array.from(await this.driver.$$(`-ios class chain:**/${nativeType}`));
588
+ } catch {
589
+ return void 0;
590
+ }
591
+ const matches = [];
592
+ for (const el of candidates) {
593
+ if (!el?.elementId) continue;
594
+ const rect = await this.readRect(el.elementId);
595
+ if (!rect || !rectMatches(rect, bounds)) continue;
596
+ matches.push({ el, rect });
597
+ }
598
+ return matches.length === 1 ? matches[0] : void 0;
599
+ }
600
+ async readIOSFocusedIds() {
601
+ const driver = this.driver;
602
+ try {
603
+ const id = elementIdOf(await driver.getActiveElement());
604
+ if (id) return [id];
605
+ } catch {
606
+ }
607
+ const ids = [];
608
+ for (const type of import_agent_qa_core.IOS_EDITABLE_TYPES) {
609
+ try {
610
+ const focused = Array.from(await driver.$$(`-ios class chain:**/${type}[\`hasFocus == 1\`]`));
611
+ for (const el of focused) if (el?.elementId) ids.push(el.elementId);
612
+ } catch {
613
+ }
614
+ }
615
+ return ids;
616
+ }
617
+ /**
618
+ * WDA's typing taps an element that lacks keyboard focus and then sends keystrokes without
619
+ * re-checking focus, so they land wherever focus is. Text is typed only once the target (or
620
+ * a descendant) holds keyboard focus.
621
+ */
622
+ async confirmIOSKeyboardFocus(target) {
623
+ let outcome = "none";
624
+ for (let poll = 0; poll < FOCUS_POLLS; poll++) {
625
+ if (poll > 0) await this.driver.pause(FOCUS_POLL_PAUSE_MS);
626
+ const ids = await this.readIOSFocusedIds();
627
+ if (ids.length === 0) {
628
+ outcome = "none";
629
+ continue;
630
+ }
631
+ for (const id of ids) {
632
+ if (id === target.el.elementId) return "focused";
633
+ const rect = await this.readRect(id);
634
+ if (rect && rectWithin(rect, target.rect)) return "focused";
635
+ }
636
+ outcome = "elsewhere";
637
+ }
638
+ return outcome;
639
+ }
640
+ textInputRefusal(verb, ref, evidence, box) {
641
+ const nothing = verb === "fill" ? "Nothing was typed." : "Nothing was cleared.";
642
+ let error;
643
+ switch (evidence) {
644
+ case "no-editable-signal": {
645
+ const picker = isIOSNativeSelectTarget(this.lastRefs[ref]) ? " Use nativeSelect for native pickers." : "";
646
+ error = `Element ${ref} is not editable: the observation carries no editable signal for it.${picker} ${nothing}`;
647
+ break;
648
+ }
649
+ case "no-keyboard-focus":
650
+ error = `Element ${ref} is not editable: it did not take keyboard focus when tapped. ${nothing}`;
651
+ break;
652
+ case "focus-elsewhere":
653
+ error = `Element ${ref} is not editable here: after tapping it, keyboard focus is on another element. Input is never sent to an element other than the target. ${nothing}`;
654
+ break;
655
+ case "stale-target":
656
+ error = `Element ${ref} was not found at its observed position; the screen changed. Re-observe before typing. ${nothing}`;
657
+ break;
658
+ default:
659
+ return (0, import_agent_qa_runner.assertNever)(evidence);
660
+ }
661
+ const cause = evidence === "stale-target" ? "target-not-found" : "not-editable";
662
+ const fillRefusal = { verb, cause, evidence };
663
+ return { success: false, error, ...box ?? {}, metadata: { fillRefusal } };
664
+ }
665
+ /** Gate → own-element lookup → tap → keyboard-focus check shared by fill and clearText. */
666
+ async focusIOSTextTarget(verb, ref, box) {
667
+ if (this.lastRefs[ref]?.editable !== true) {
668
+ return { refusal: this.textInputRefusal(verb, ref, "no-editable-signal") };
669
+ }
670
+ const target = await this.locateIOSTextTarget(ref);
671
+ if (!target) return { refusal: this.textInputRefusal(verb, ref, "stale-target") };
672
+ await this.tapAt(box.coordinates);
673
+ const focus = await this.confirmIOSKeyboardFocus(target);
674
+ if (focus === "focused") return { target };
675
+ return {
676
+ refusal: this.textInputRefusal(verb, ref, focus === "elsewhere" ? "focus-elsewhere" : "no-keyboard-focus", box)
677
+ };
678
+ }
679
+ /** A non-empty empty-field hint (XCUITest placeholderValue); undefined when absent. */
680
+ async readIOSPlaceholder(el) {
681
+ try {
682
+ const hint = await el.getAttribute("placeholderValue");
683
+ return typeof hint === "string" && hint !== "" ? hint : void 0;
684
+ } catch {
685
+ return void 0;
686
+ }
687
+ }
688
+ /** Read a focused element's `value` attribute; undefined when nothing is readable. */
689
+ async readIOSValue(el) {
690
+ try {
691
+ const value = await el.getAttribute("value");
692
+ return typeof value === "string" ? value : void 0;
693
+ } catch {
694
+ return void 0;
695
+ }
696
+ }
697
+ /**
698
+ * EXEC-01 iOS fill compound (D-05..D-08). From the SAME focused-element handle: (append) capture
699
+ * the prior value FIRST via getAttribute('value') then addValue, or (replace) setValue; RE-READ
700
+ * via getAttribute('value') on that same handle; verdict via compareFillReread. Secure fields
701
+ * (nativeType XCUIElementTypeSecureTextField) take the non-empty-only lane — the comparator yields
702
+ * length sentinels so no raw value enters the result (T-359-02); bullet masking is tolerated
703
+ * (A3/Pitfall 7 — never a length compare). On a passing re-read AND submit, a return/newline is
704
+ * delivered AFTER the re-read (D-07). Single typing attempt; a mismatch is success:false + §7.1.
705
+ *
706
+ * Append (437 FA-1, owner 2026-10-08): the XCUITest `value` attribute often lags after addValue,
707
+ * so a re-read equal to the prior cannot show the appended text. That fill is recorded unverified:
708
+ * never exact, and never a mismatch. Every fill records the shared verdict as `fillVerdict`.
709
+ * An empty field reads as its placeholder, so an append prior equal to it counts as empty.
710
+ *
711
+ * Return in a multi-line field adds a new line instead of submitting, so a multi-line submit is
712
+ * never sent and is recorded as such.
713
+ *
714
+ * An unreadable re-read is unverified and does not stop a requested Return, as on Android (D-09,
715
+ * D-10; owner 2026-10-09), but focus is confirmed once first and a Return is held as focus-lost
716
+ * when the field no longer has it (444.1 D-10).
717
+ */
718
+ async fillIOSCompound(el, action, box, secure, multiline, target) {
719
+ const driver = this.driver;
720
+ const isAppend = action.clearMode === "append";
721
+ let prior = isAppend ? await this.readIOSValue(el) ?? "" : "";
722
+ if (prior !== "" && prior === await this.readIOSPlaceholder(el)) prior = "";
723
+ if (isAppend) {
724
+ await el.addValue(action.text);
725
+ } else {
726
+ await el.setValue(action.text);
727
+ }
728
+ const expected = isAppend ? prior + action.text : action.text;
729
+ const raw = await this.readIOSValue(el);
730
+ const actual = isAppend && raw === prior ? void 0 : raw;
731
+ const verdict = (0, import_agent_qa_runner.compareFillReread)({ expected, actual, secure });
732
+ const fillVerdict = {
733
+ verb: "fill",
734
+ verdict: verdict.verdict,
735
+ ...verdict.secure ? { secure: true } : {}
736
+ };
737
+ const unsent = multiline ? { sent: false, reason: "multi-line-field" } : { sent: false };
738
+ const notice = action.submit && multiline ? { data: MULTI_LINE_SUBMIT_NOTICE } : {};
739
+ let fillSubmit = unsent;
740
+ if (verdict.ok && action.submit && !multiline) {
741
+ if (raw === void 0 && await this.confirmIOSKeyboardFocus(target) !== "focused") {
742
+ fillSubmit = { sent: false, reason: "focus-lost" };
743
+ } else {
744
+ await driver.keys(["\n"]);
745
+ fillSubmit = { sent: true };
746
+ }
747
+ }
748
+ const metadata = {
749
+ ...verdict.meta ? { fillReread: verdict.meta } : {},
750
+ fillVerdict,
751
+ ...action.submit ? { fillSubmit } : {}
752
+ };
753
+ return verdict.ok ? { success: true, ...box, ...notice, metadata } : { success: false, error: verdict.selfCorrectingError, ...box, ...notice, metadata };
754
+ }
755
+ /**
756
+ * EXEC-02 platform primitives injected into the shared runner scroll loops (D-02/D-03). The swipe
757
+ * is a page-sized directional gesture (inverted to the finger direction) that reports unknown
758
+ * movement; nativeSearchScroll drives
759
+ * the XCUITest `mobile: scroll` predicateString rung over an injection-escaped target; readInterimTree
760
+ * is a light page-source parse — NO refMap mint, NO screenshot, lastRefs untouched.
761
+ */
762
+ async buildIOSScrollPrimitives(scrollDirection, maxSwipes, factor = 1) {
763
+ const driver = this.driver;
764
+ const viewport = await driver.getWindowSize();
765
+ const axisLength = scrollDirection === "left" || scrollDirection === "right" ? viewport.width : viewport.height;
766
+ const distance = Math.max(1, Math.round(axisLength * 0.6 * factor));
767
+ return {
768
+ swipeOnce: async (direction, ref) => {
769
+ let sx = Math.round(viewport.width / 2);
770
+ let sy = Math.round(viewport.height / 2);
771
+ if (ref) {
772
+ const { center } = new import_agent_qa_core.MobileElementResolver(this.lastRefs).resolve(ref);
773
+ sx = Math.round(center.x);
774
+ sy = Math.round(center.y);
775
+ }
776
+ const pts = (0, import_agent_qa_core.computeSwipe)(sx, sy, fingerDirectionFor(direction), distance);
777
+ await safePerform(
778
+ 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 })
779
+ );
780
+ return "unknown";
781
+ },
782
+ nativeSearchScroll: async (target) => {
783
+ const predicateString = buildIOSPredicate(target);
784
+ try {
785
+ await driver.execute("mobile: scroll", { predicateString });
786
+ return true;
787
+ } catch {
788
+ return false;
789
+ }
790
+ },
791
+ readInterimTree: async () => {
792
+ const xml = await this.readIOSPageSource();
793
+ const { nodes } = (0, import_agent_qa_core.parseMobileSource)(xml, "ios", viewport);
794
+ return nodes;
795
+ }
796
+ };
797
+ }
798
+ /**
799
+ * EXEC-03 openApp (D-10/D-12, Pitfall 5). Resolve a human name to a bundle id (or validate an
800
+ * explicit bundleId) over the session-cached registry, then launch via activateApp. On real devices
801
+ * `mobile: listApps` enumerates the registry; on simulators it throws, leaving the registry
802
+ * unavailable — the appName lane then mints a structured §7.1 error directing to an explicit
803
+ * bundleId, and the bundleId lane validates without the registry (isAppInstalled / activateApp
804
+ * error conversion, A2). Never a best-guess launch (T-359-04); an invalid bundle never succeeds.
805
+ */
806
+ async openApp(action) {
807
+ const driver = this.driver;
808
+ const registry = await this.ensureAppRegistry();
809
+ if ("bundleId" in action) {
810
+ return await this.openByBundleId(action.bundleId, registry);
811
+ }
812
+ if (registry === null) {
813
+ return {
814
+ success: false,
815
+ error: `openApp could not enumerate installed apps by name: "mobile: listApps" is unavailable on iOS simulators. Retry with an explicit openApp {bundleId} using the app's exact bundle identifier.`
816
+ };
817
+ }
818
+ let reg = registry;
819
+ let resolution = (0, import_agent_qa_runner.resolveAppName)(action.appName, reg);
820
+ if (resolution.kind === "unknown" || resolution.kind === "ambiguous") {
821
+ const fresh = await this.enumerateApps();
822
+ if (fresh !== null) {
823
+ this.appRegistry = fresh;
824
+ reg = fresh;
825
+ resolution = (0, import_agent_qa_runner.resolveAppName)(action.appName, reg);
826
+ }
827
+ }
828
+ switch (resolution.kind) {
829
+ case "exact":
830
+ case "fuzzy":
831
+ await driver.activateApp(resolution.id);
832
+ return { success: true, metadata: { openApp: { resolvedId: resolution.id, matchKind: resolution.kind } } };
833
+ case "ambiguous":
834
+ case "unknown": {
835
+ const ids = resolution.candidates.map((c) => c.id).join(", ");
836
+ const lead = resolution.kind === "ambiguous" ? "is ambiguous between installed apps" : "did not match an installed app";
837
+ return {
838
+ success: false,
839
+ error: `openApp: "${action.appName}" ${lead}. Closest candidates: ${ids || "(none)"}. Retry openApp {bundleId} with the exact bundle identifier.`
840
+ };
841
+ }
842
+ }
843
+ }
844
+ /**
845
+ * Explicit `{bundleId}` launch (D-12, A2). With a registry, validate membership; without one
846
+ * (simulator) validate via `driver.isAppInstalled` when the driver exposes it, otherwise attempt
847
+ * `activateApp` and convert a driver rejection into the structured §7.1 error. An invalid bundle
848
+ * NEVER returns success:true.
849
+ */
850
+ async openByBundleId(bundleId, registry) {
851
+ const driver = this.driver;
852
+ if (registry !== null) {
853
+ if (!registry.some((entry) => entry.id === bundleId)) {
854
+ return {
855
+ success: false,
856
+ error: `openApp: bundleId "${bundleId}" is not installed (not in the app registry). Verify the bundle identifier or use openApp {appName}.`
857
+ };
858
+ }
859
+ await driver.activateApp(bundleId);
860
+ return { success: true, metadata: { openApp: { resolvedId: bundleId, matchKind: "explicit" } } };
861
+ }
862
+ const isAppInstalled = driver.isAppInstalled;
863
+ if (typeof isAppInstalled === "function") {
864
+ let installed = false;
865
+ try {
866
+ installed = await isAppInstalled.call(driver, bundleId);
867
+ } catch {
868
+ installed = false;
869
+ }
870
+ if (!installed) {
871
+ return {
872
+ success: false,
873
+ error: `openApp: bundleId "${bundleId}" is not installed on this device. Verify the bundle identifier.`
874
+ };
875
+ }
876
+ await driver.activateApp(bundleId);
877
+ return { success: true, metadata: { openApp: { resolvedId: bundleId, matchKind: "explicit" } } };
878
+ }
879
+ try {
880
+ await driver.activateApp(bundleId);
881
+ return { success: true, metadata: { openApp: { resolvedId: bundleId, matchKind: "explicit" } } };
882
+ } catch (err) {
883
+ return {
884
+ success: false,
885
+ error: `openApp: could not launch bundleId "${bundleId}": ${err instanceof Error ? err.message : String(err)}. Verify the bundle identifier.`
886
+ };
887
+ }
888
+ }
889
+ /** Return the cached registry, enumerating once on first use. Failed enumeration returns null (uncached). */
890
+ async ensureAppRegistry() {
891
+ if (this.appRegistry !== null) return this.appRegistry;
892
+ const enumerated = await this.enumerateApps();
893
+ if (enumerated !== null) this.appRegistry = enumerated;
894
+ return enumerated;
895
+ }
896
+ /**
897
+ * Enumerate installed apps through the driver only (ownership contract — webdriverio only, no
898
+ * child_process/simctl). `mobile: listApps` maps bundle ids → AppRegistryEntry with CFBundleName
899
+ * labels when present (D-10 — no per-app round-trips). It throws on simulators (Pitfall 5) ⇒ null,
900
+ * leaving enumeration unavailable WITHOUT crashing.
901
+ */
902
+ async enumerateApps() {
903
+ const driver = this.driver;
904
+ try {
905
+ const raw = await driver.execute("mobile: listApps");
906
+ return this.mapListApps(raw);
907
+ } catch {
908
+ return null;
909
+ }
910
+ }
911
+ /** Coerce a `mobile: listApps` result (bundle-id-keyed map, or an array of entries) into the registry. */
912
+ mapListApps(raw) {
913
+ const labelOf = (value) => {
914
+ if (!value || typeof value !== "object") return void 0;
915
+ const v = value;
916
+ for (const key of ["CFBundleName", "CFBundleDisplayName", "label", "name"]) {
917
+ const candidate = v[key];
918
+ if (typeof candidate === "string" && candidate.length > 0) return candidate;
919
+ }
920
+ return void 0;
921
+ };
922
+ const entries = [];
923
+ if (Array.isArray(raw)) {
924
+ for (const item of raw) {
925
+ if (!item || typeof item !== "object") continue;
926
+ const v = item;
927
+ const id = v.bundleId ?? v.CFBundleIdentifier ?? v.id;
928
+ if (typeof id !== "string" || id.length === 0) continue;
929
+ const label = labelOf(item);
930
+ entries.push(label !== void 0 ? { id, label } : { id });
931
+ }
932
+ } else if (raw && typeof raw === "object") {
933
+ for (const [id, value] of Object.entries(raw)) {
934
+ if (!id) continue;
935
+ const label = labelOf(value);
936
+ entries.push(label !== void 0 ? { id, label } : { id });
937
+ }
938
+ }
939
+ return entries;
940
+ }
941
+ // ---------------------------------------------------------------------------
388
942
  // Actions
389
943
  // ---------------------------------------------------------------------------
390
944
  async executeAction(action) {
@@ -392,58 +946,25 @@ var IOSPlatformAdapter = class {
392
946
  const resolver = new import_agent_qa_core.MobileElementResolver(this.lastRefs);
393
947
  await this.ensureContext("NATIVE_APP");
394
948
  switch (action.type) {
395
- case "tap":
396
- case "click": {
949
+ case "tap": {
397
950
  const { center, bounds } = resolver.resolve(action.ref);
398
- await safePerform(
399
- driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(center.x), y: Math.round(center.y) }).down({ button: 0 }).up({ button: 0 })
400
- );
951
+ await this.tapAt(center);
401
952
  return { success: true, coordinates: center, boundingBox: bounds };
402
953
  }
403
954
  case "fill": {
404
955
  const { center, bounds } = resolver.resolve(action.ref);
405
- const refData = this.lastRefs[action.ref];
406
- const role = refData?.role?.toLowerCase();
407
- if (role && !IOS_FILLABLE_ROLES.has(role)) {
408
- return {
409
- success: false,
410
- error: `Element ${action.ref} is not fillable (role: ${role}). Use a textbox or input element instead.`
411
- };
412
- }
413
- await safePerform(
414
- driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(center.x), y: Math.round(center.y) }).down({ button: 0 }).up({ button: 0 })
956
+ const box = { coordinates: center, boundingBox: bounds };
957
+ const focused = await this.focusIOSTextTarget("fill", action.ref, box);
958
+ if ("refusal" in focused) return focused.refusal;
959
+ const nativeType = this.lastRefs[action.ref]?.nativeType;
960
+ return await this.fillIOSCompound(
961
+ focused.target.el,
962
+ action,
963
+ box,
964
+ nativeType === "XCUIElementTypeSecureTextField",
965
+ nativeType === "XCUIElementTypeTextView",
966
+ focused.target
415
967
  );
416
- const selectors = [
417
- // Focused element (most precise)
418
- "-ios class chain:**/XCUIElementTypeTextField[`hasFocus == 1`]",
419
- "-ios class chain:**/XCUIElementTypeSecureTextField[`hasFocus == 1`]",
420
- "-ios class chain:**/XCUIElementTypeSearchField[`hasFocus == 1`]",
421
- // Any visible element of the type (fallback — some apps don't report hasFocus)
422
- "-ios class chain:**/XCUIElementTypeSearchField",
423
- "-ios class chain:**/XCUIElementTypeTextField",
424
- "-ios class chain:**/XCUIElementTypeSecureTextField"
425
- ];
426
- for (const sel of selectors) {
427
- try {
428
- const el = await driver.$(sel);
429
- if (await el.isExisting()) {
430
- await el.setValue(action.value);
431
- return { success: true, coordinates: center, boundingBox: bounds };
432
- }
433
- } catch {
434
- }
435
- }
436
- await driver.keys([action.value]);
437
- return { success: true, coordinates: center, boundingBox: bounds };
438
- }
439
- case "select": {
440
- const { center, bounds } = resolver.resolve(action.ref);
441
- return {
442
- success: false,
443
- error: "select is for HTML dropdowns. Use nativeSelect for iOS native picker wheels, or tap/swipe/tapCoordinate for custom controls.",
444
- coordinates: center,
445
- boundingBox: bounds
446
- };
447
968
  }
448
969
  case "nativeSelect": {
449
970
  const { center, bounds } = resolver.resolve(action.ref);
@@ -502,58 +1023,58 @@ var IOSPlatformAdapter = class {
502
1023
  };
503
1024
  }
504
1025
  case "scroll": {
505
- const webCtx = await this.getWebContext();
506
- if (webCtx) {
1026
+ if (action.mode === "untilVisible") {
507
1027
  try {
508
- await this.ensureContext(webCtx);
509
- const dx = action.scrollType === "horizontal" ? action.value : 0;
510
- const dy = action.scrollType === "vertical" ? action.value : 0;
511
- await driver.execute(
512
- "window.scrollBy(arguments[0], arguments[1]);",
513
- dx,
514
- dy
515
- );
516
- await this.ensureContext("NATIVE_APP");
517
- return { success: true };
518
- } catch {
519
- try {
520
- await this.ensureContext("NATIVE_APP");
521
- } catch {
522
- }
1028
+ escapeIOSPredicateText(action.text);
1029
+ } catch (err) {
1030
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
523
1031
  }
1032
+ const primitives2 = await this.buildIOSScrollPrimitives(action.direction, action.maxSwipes);
1033
+ const { meta: meta2 } = await (0, import_agent_qa_runner.runUntilVisibleLoop)(primitives2, {
1034
+ target: action.text,
1035
+ direction: action.direction,
1036
+ maxSwipes: action.maxSwipes,
1037
+ retryOpposite: action.retryOpposite,
1038
+ ...action.ref ? { ref: action.ref } : {}
1039
+ });
1040
+ return { success: true, metadata: { scrollUntilVisible: meta2 } };
524
1041
  }
525
- const { width, height } = await driver.getWindowSize();
526
- const centerX = Math.round(width / 2);
527
- const centerY = Math.round(height / 2);
528
- const scrollStartX = centerX;
529
- const scrollStartY = centerY;
530
- const scrollEndX = action.scrollType === "horizontal" ? centerX - action.value : centerX;
531
- const scrollEndY = action.scrollType === "vertical" ? centerY - action.value : centerY;
532
- const scrollDuration = action.duration ?? 300;
533
- await safePerform(
534
- driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: scrollStartX, y: scrollStartY }).down({ button: 0 }).move({ x: scrollEndX, y: scrollEndY, duration: scrollDuration }).up({ button: 0 })
535
- );
536
- return { success: true };
1042
+ const factor = action.amount === "halfPage" ? 0.5 : 1;
1043
+ const primitives = await this.buildIOSScrollPrimitives(action.direction, 0, factor);
1044
+ if (action.amount === "toEnd") {
1045
+ const { meta: meta2 } = await (0, import_agent_qa_runner.runToEndScroll)(primitives, action.direction, action.ref);
1046
+ return { success: true, metadata: { scrollUntilVisible: meta2 } };
1047
+ }
1048
+ const { meta } = await (0, import_agent_qa_runner.runDirectionScroll)(primitives, {
1049
+ direction: action.direction,
1050
+ ...action.ref ? { ref: action.ref } : {}
1051
+ });
1052
+ return { success: true, metadata: { scrollUntilVisible: meta } };
537
1053
  }
538
1054
  case "swipe": {
539
1055
  const { width, height } = await driver.getWindowSize();
540
- let sx = Math.round(width / 2);
541
- let sy = Math.round(height / 2);
542
- if (action.ref) {
543
- const { center } = resolver.resolve(action.ref);
544
- sx = Math.round(center.x);
545
- sy = Math.round(center.y);
1056
+ let pts;
1057
+ if (action.mode === "path") {
1058
+ pts = {
1059
+ startX: Math.round(action.from.x),
1060
+ startY: Math.round(action.from.y),
1061
+ endX: Math.round(action.to.x),
1062
+ endY: Math.round(action.to.y)
1063
+ };
1064
+ } else {
1065
+ let sx = Math.round(width / 2);
1066
+ let sy = Math.round(height / 2);
1067
+ if (action.ref) {
1068
+ const { center } = resolver.resolve(action.ref);
1069
+ sx = Math.round(center.x);
1070
+ sy = Math.round(center.y);
1071
+ }
1072
+ const distance = Math.round(height * 0.6);
1073
+ pts = (0, import_agent_qa_core.computeSwipe)(sx, sy, action.direction, distance);
546
1074
  }
547
- const distance = Math.round(height * 0.6);
548
- const pts = action.startX !== void 0 && action.startY !== void 0 && action.endX !== void 0 && action.endY !== void 0 ? {
549
- startX: action.startX,
550
- startY: action.startY,
551
- endX: action.endX,
552
- endY: action.endY
553
- } : (0, import_agent_qa_core.computeSwipe)(sx, sy, action.direction, distance);
554
1075
  (0, import_agent_qa_core.warnIfOutOfBounds)({ x: pts.startX, y: pts.startY }, { width, height }, "swipe");
555
1076
  (0, import_agent_qa_core.warnIfOutOfBounds)({ x: pts.endX, y: pts.endY }, { width, height }, "swipe");
556
- const swipeDuration = action.duration ?? 300;
1077
+ const swipeDuration = action.durationMs;
557
1078
  await safePerform(
558
1079
  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 })
559
1080
  );
@@ -567,13 +1088,14 @@ var IOSPlatformAdapter = class {
567
1088
  const { width: pinchW, height: pinchH } = await driver.getWindowSize();
568
1089
  let pcx = Math.round(pinchW / 2);
569
1090
  let pcy = Math.round(pinchH / 2);
570
- if (action.ref) {
1091
+ if ("ref" in action) {
571
1092
  const { center } = resolver.resolve(action.ref);
572
1093
  pcx = Math.round(center.x);
573
1094
  pcy = Math.round(center.y);
1095
+ } else {
1096
+ pcx = action.x;
1097
+ pcy = action.y;
574
1098
  }
575
- if (action.x !== void 0) pcx = action.x;
576
- if (action.y !== void 0) pcy = action.y;
577
1099
  (0, import_agent_qa_core.warnIfOutOfBounds)({ x: pcx, y: pcy }, { width: pinchW, height: pinchH }, "pinch");
578
1100
  const { finger1Start, finger1End, finger2Start, finger2End } = (0, import_agent_qa_core.computePinch)(pcx, pcy, action.scale);
579
1101
  const pinchDuration = 500;
@@ -587,13 +1109,14 @@ var IOSPlatformAdapter = class {
587
1109
  const { width: mtW, height: mtH } = await driver.getWindowSize();
588
1110
  let mtcx = Math.round(mtW / 2);
589
1111
  let mtcy = Math.round(mtH / 2);
590
- if (action.ref) {
1112
+ if ("ref" in action) {
591
1113
  const { center } = resolver.resolve(action.ref);
592
1114
  mtcx = Math.round(center.x);
593
1115
  mtcy = Math.round(center.y);
1116
+ } else {
1117
+ mtcx = action.x;
1118
+ mtcy = action.y;
594
1119
  }
595
- if (action.x !== void 0) mtcx = action.x;
596
- if (action.y !== void 0) mtcy = action.y;
597
1120
  (0, import_agent_qa_core.warnIfOutOfBounds)({ x: mtcx, y: mtcy }, { width: mtW, height: mtH }, "multiTap");
598
1121
  const positions = (0, import_agent_qa_core.computeFingerPositions)(mtcx, mtcy, action.fingers);
599
1122
  await driver.actions(
@@ -611,35 +1134,48 @@ var IOSPlatformAdapter = class {
611
1134
  return { success: true };
612
1135
  }
613
1136
  case "waitFor": {
614
- await driver.pause(action.timeout ?? 2e3);
615
- return { success: true };
1137
+ if (action.on === "selector") {
1138
+ return {
1139
+ success: false,
1140
+ error: 'waitFor {on:"selector"} (CSS/XPath) is a web-only mode. On native iOS, wait on visible text with waitFor {on:"text"} or {on:"gone"}.'
1141
+ };
1142
+ }
1143
+ const wantPresent = action.on === "text";
1144
+ const needle = action.text;
1145
+ const deadline = Date.now() + action.timeoutMs;
1146
+ for (; ; ) {
1147
+ let source = "";
1148
+ try {
1149
+ source = await this.readIOSPageSource();
1150
+ } catch {
1151
+ source = "";
1152
+ }
1153
+ if (source.includes(needle) === wantPresent) return { success: true };
1154
+ if (Date.now() >= deadline) break;
1155
+ await driver.pause(Math.min(250, Math.max(1, deadline - Date.now())));
1156
+ }
1157
+ return {
1158
+ success: false,
1159
+ error: wantPresent ? `waitFor: text "${needle}" did not appear within ${action.timeoutMs}ms.` : `waitFor: text "${needle}" was still present after ${action.timeoutMs}ms.`
1160
+ };
616
1161
  }
617
1162
  case "delay": {
618
1163
  await driver.pause(action.ms);
619
1164
  return { success: true };
620
1165
  }
621
1166
  case "keypress": {
1167
+ if (action.mode !== "press") {
1168
+ return {
1169
+ success: false,
1170
+ error: `keypress {mode:"${action.mode}"} (key hold/release) is web-only. On mobile, emit keypress {mode:"press"} to send the key.`
1171
+ };
1172
+ }
622
1173
  for (const rawKey of action.keys) {
623
1174
  const mapped = import_agent_qa_core.KEY_MAP[rawKey] ?? rawKey;
624
1175
  await driver.keys([mapped]);
625
1176
  }
626
1177
  return { success: true };
627
1178
  }
628
- case "navigate": {
629
- try {
630
- await driver.url(action.url);
631
- this.invalidateWebContext();
632
- return { success: true };
633
- } catch {
634
- return { success: false, error: "navigate is not supported for this app. Use tap/fill on UI elements to navigate." };
635
- }
636
- }
637
- case "hover": {
638
- return { success: false, error: "hover is a web-only action. Use tap or longpress instead." };
639
- }
640
- case "assert": {
641
- return { success: true };
642
- }
643
1179
  // --- New actions ---
644
1180
  case "hideKeyboard": {
645
1181
  try {
@@ -650,58 +1186,140 @@ var IOSPlatformAdapter = class {
650
1186
  }
651
1187
  case "clearText": {
652
1188
  const { center, bounds } = resolver.resolve(action.ref);
653
- const clearRefData = this.lastRefs[action.ref];
654
- const clearRole = clearRefData?.role?.toLowerCase();
655
- if (clearRole && !IOS_FILLABLE_ROLES.has(clearRole)) {
1189
+ const box = { coordinates: center, boundingBox: bounds };
1190
+ const focused = await this.focusIOSTextTarget("clearText", action.ref, box);
1191
+ if ("refusal" in focused) return focused.refusal;
1192
+ const { el } = focused.target;
1193
+ await el.clearValue();
1194
+ const remaining = await this.readIOSValue(el) ?? "";
1195
+ if (remaining === "" || remaining === await this.readIOSPlaceholder(el)) {
1196
+ return { success: true, ...box };
1197
+ }
1198
+ const secure = this.lastRefs[action.ref]?.nativeType === "XCUIElementTypeSecureTextField";
1199
+ const residue = secure ? ` (${remaining.length} characters remain)` : "";
1200
+ return {
1201
+ success: false,
1202
+ error: `Element ${action.ref}: the field still has text after clearing${residue} \u2014 re-observe and retry.`,
1203
+ ...box
1204
+ };
1205
+ }
1206
+ case "openUniversalLink": {
1207
+ const explicit = firstNonEmpty(action.bundleId);
1208
+ const configured = firstNonEmpty(this.bundleId);
1209
+ const openInApp = async (appId) => {
1210
+ await driver.execute("mobile: deepLink", { url: action.url, bundleId: appId });
1211
+ };
1212
+ const openScoped = async (appId) => {
1213
+ try {
1214
+ await openInApp(appId);
1215
+ this.invalidateWebContext();
1216
+ return { success: true };
1217
+ } catch (err) {
1218
+ return { success: false, error: `Failed to open universal link ${action.url} in iOS app ${appId}: ${err instanceof Error ? err.message : String(err)}` };
1219
+ }
1220
+ };
1221
+ if (!/^https?:\/\//i.test(action.url)) {
1222
+ const bundleId = explicit ?? configured;
1223
+ if (!bundleId) {
1224
+ return { success: false, error: `Missing iOS bundle id for native deep link: ${action.url}` };
1225
+ }
1226
+ return openScoped(bundleId);
1227
+ }
1228
+ if (explicit) return openScoped(explicit);
1229
+ if (this.isBrowserSession()) {
1230
+ try {
1231
+ await driver.url(action.url);
1232
+ this.invalidateWebContext();
1233
+ return { success: true };
1234
+ } catch (err) {
1235
+ return { success: false, error: `Failed to open universal link ${action.url}: ${err instanceof Error ? err.message : String(err)}` };
1236
+ }
1237
+ }
1238
+ let configuredFailure;
1239
+ if (configured) {
1240
+ try {
1241
+ await openInApp(configured);
1242
+ this.invalidateWebContext();
1243
+ return { success: true };
1244
+ } catch (err) {
1245
+ configuredFailure = `opening it in the configured iOS app ${configured} failed first: ${firstErrorLine(err)}`;
1246
+ }
1247
+ }
1248
+ const before = await this.readForegroundBundleId();
1249
+ try {
1250
+ await driver.url(action.url);
1251
+ } catch (err) {
656
1252
  return {
657
1253
  success: false,
658
- error: `Element ${action.ref} is not fillable (role: ${clearRole}). clearText only works on text inputs. Use a textbox or input element instead.`
1254
+ error: `Failed to open universal link ${action.url} through the iOS system handler: ${firstErrorLine(err)}${configuredFailure ? ` (${configuredFailure})` : ""}`
659
1255
  };
660
1256
  }
661
- await safePerform(
662
- driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(center.x), y: Math.round(center.y) }).down({ button: 0 }).up({ button: 0 })
663
- );
664
- const clearSelectors = [
665
- "-ios class chain:**/XCUIElementTypeTextField[`hasFocus == 1`]",
666
- "-ios class chain:**/XCUIElementTypeSecureTextField[`hasFocus == 1`]",
667
- "-ios class chain:**/XCUIElementTypeSearchField[`hasFocus == 1`]",
668
- "-ios class chain:**/XCUIElementTypeSearchField",
669
- "-ios class chain:**/XCUIElementTypeTextField",
670
- "-ios class chain:**/XCUIElementTypeSecureTextField"
671
- ];
672
- for (const sel of clearSelectors) {
1257
+ this.invalidateWebContext();
1258
+ if (!before || before === IOS_SAFARI_BUNDLE_ID) return { success: true };
1259
+ let after;
1260
+ for (let poll = 0; poll < IOS_LINK_HANDOFF_POLLS; poll++) {
1261
+ await driver.pause(IOS_LINK_HANDOFF_PAUSE_MS);
1262
+ after = await this.readForegroundBundleId();
1263
+ if (after && after !== before && after !== IOS_SPRINGBOARD_BUNDLE_ID) break;
1264
+ }
1265
+ if (after !== IOS_SAFARI_BUNDLE_ID) return { success: true };
1266
+ let error = `iOS opened ${action.url} in Safari: no installed app claimed the link (the foreground app was ${before}). Pass bundleId to open it in a specific app, or com.apple.mobilesafari to open it in Safari.`;
1267
+ if (before !== IOS_SPRINGBOARD_BUNDLE_ID) {
673
1268
  try {
674
- const el = await driver.$(sel);
675
- if (await el.isExisting()) {
676
- await el.clearValue();
677
- break;
678
- }
679
- } catch {
1269
+ await driver.activateApp(before);
1270
+ error += ` Returned to ${before}.`;
1271
+ } catch (err) {
1272
+ error += ` Could not return to ${before}: ${firstErrorLine(err)}.`;
680
1273
  }
681
1274
  }
682
- return { success: true, coordinates: center, boundingBox: bounds };
1275
+ return { success: false, error };
683
1276
  }
684
- case "openLink": {
685
- const bundleId = firstNonEmpty(action.appId, action.bundleId, this.bundleId);
686
- const isWebUrl = /^https?:\/\//i.test(action.url);
687
- if (!bundleId && !isWebUrl) {
688
- return { success: false, error: `Missing iOS bundle id for native deep link: ${action.url}` };
1277
+ case "assertValue": {
1278
+ const webCtx = await this.getWebContext();
1279
+ const inWeb = webCtx !== null;
1280
+ if ("expr" in action) {
1281
+ if (!inWeb) {
1282
+ return {
1283
+ success: false,
1284
+ error: "assertValue {expr} evaluates JavaScript and is only available in an iOS WebView/browser context. Assert on a native signal, or run against a web target."
1285
+ };
1286
+ }
1287
+ await this.ensureContext(webCtx);
1288
+ const value = await driver.execute(`return (${action.expr});`);
1289
+ return { success: true, data: value };
689
1290
  }
690
- try {
691
- if (bundleId) {
692
- const deepLink = driver.deepLink;
693
- if (typeof deepLink === "function") {
694
- await deepLink.call(driver, action.url, bundleId, action.waitForLaunch ?? true);
695
- } else {
696
- await driver.execute("mobile: deepLink", { url: action.url, bundleId });
1291
+ switch (action.source) {
1292
+ case "url": {
1293
+ if (!inWeb) {
1294
+ return {
1295
+ success: false,
1296
+ error: 'assertValue {source:"url"} reads the browser URL and is only available in an iOS WebView/browser context; native screens have no URL.'
1297
+ };
697
1298
  }
698
- } else {
699
- await driver.url(action.url);
1299
+ await this.ensureContext(webCtx);
1300
+ const value = await driver.execute("return window.location.href;");
1301
+ return { success: true, data: value };
700
1302
  }
701
- this.invalidateWebContext();
702
- return { success: true };
703
- } catch (err) {
704
- return { success: false, error: `Failed to open link ${action.url}${bundleId ? ` in iOS app ${bundleId}` : ""}: ${err instanceof Error ? err.message : String(err)}` };
1303
+ case "storage": {
1304
+ if (!inWeb) {
1305
+ return {
1306
+ success: false,
1307
+ error: 'assertValue {source:"storage"} reads Web Storage and is only available in an iOS WebView/browser context.'
1308
+ };
1309
+ }
1310
+ await this.ensureContext(webCtx);
1311
+ const value = await driver.execute(
1312
+ "return Object.fromEntries(Object.keys(localStorage).map(function(k){return [k, localStorage.getItem(k)];}));"
1313
+ );
1314
+ return { success: true, data: value };
1315
+ }
1316
+ case "network":
1317
+ return {
1318
+ success: false,
1319
+ error: 'assertValue {source:"network"} (HTTP log inspection) is not available on the iOS adapter. Use a web target for network assertions, or assert on visible UI text.'
1320
+ };
1321
+ case "expr-result":
1322
+ return { success: true, data: void 0 };
705
1323
  }
706
1324
  }
707
1325
  case "drag": {
@@ -721,10 +1339,8 @@ var IOSPlatformAdapter = class {
721
1339
  );
722
1340
  return { success: true, coordinates: center, boundingBox: bounds };
723
1341
  }
724
- case "launchApp": {
725
- await driver.activateApp(action.bundleId);
726
- return { success: true };
727
- }
1342
+ case "openApp":
1343
+ return await this.openApp(action);
728
1344
  case "stopApp": {
729
1345
  await driver.terminateApp(action.bundleId);
730
1346
  return { success: true };
@@ -742,20 +1358,97 @@ var IOSPlatformAdapter = class {
742
1358
  const y = action.y;
743
1359
  const tcVp = await driver.getWindowSize();
744
1360
  (0, import_agent_qa_core.warnIfOutOfBounds)({ x, y }, { width: tcVp.width, height: tcVp.height }, "tapCoordinate");
745
- await safePerform(
746
- driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x, y }).down({ button: 0 }).up({ button: 0 })
747
- );
1361
+ await this.tapAt({ x, y });
748
1362
  return { success: true, coordinates: { x, y } };
749
1363
  }
750
- case "readConsoleLogs": {
751
- await this.pollDeviceLogs();
752
- const all = [...this.consoleLogs];
753
- const entries = action.level ? all.filter((e) => e.level === action.level) : all;
754
- return { success: true, data: entries };
1364
+ // --- iOS hardware / native verbs ---
1365
+ case "pressButton": {
1366
+ const name = IOS_BUTTON_NAME[action.button];
1367
+ if (VOLUME_BUTTONS.has(action.button)) {
1368
+ const registry = await this.enumerateApps();
1369
+ if (registry === null) {
1370
+ return {
1371
+ success: false,
1372
+ error: `pressButton "${action.button}" (a volume hardware button) is only supported on real devices; this appears to be an iOS simulator session, where volume buttons are unavailable. Run against a real device to use volume controls.`
1373
+ };
1374
+ }
1375
+ }
1376
+ await driver.execute("mobile: pressButton", { name });
1377
+ return { success: true };
755
1378
  }
756
- default: {
757
- return { success: false, error: `Unknown action type: ${action.type}` };
1379
+ case "pickerSelect": {
1380
+ const wheel = await driver.$("-ios class chain:**/XCUIElementTypePickerWheel");
1381
+ if (!await wheel.isExisting()) {
1382
+ return { success: false, error: "pickerSelect: no picker wheel is present on screen." };
1383
+ }
1384
+ const elementId = wheel.elementId;
1385
+ if ("value" in action) {
1386
+ await driver.execute("mobile: selectPickerWheelValue", {
1387
+ elementId,
1388
+ order: "next",
1389
+ value: action.value,
1390
+ maxAttempts: 25
1391
+ });
1392
+ return { success: true };
1393
+ }
1394
+ if (!Number.isInteger(action.order) || action.order < 0 || action.order > MAX_PICKER_INDEX) {
1395
+ return {
1396
+ success: false,
1397
+ error: `pickerSelect: order ${action.order} is out of range (expected a 0-based wheel index in [0, ${MAX_PICKER_INDEX}]). Prefer pickerSelect {value} to select by the visible option text.`
1398
+ };
1399
+ }
1400
+ for (let i = 0; i < MAX_PICKER_INDEX; i++) {
1401
+ await driver.execute("mobile: selectPickerWheelValue", { elementId, order: "previous", offset: 0.15 });
1402
+ }
1403
+ for (let i = 0; i < action.order; i++) {
1404
+ await driver.execute("mobile: selectPickerWheelValue", { elementId, order: "next", offset: 0.15 });
1405
+ }
1406
+ return { success: true };
758
1407
  }
1408
+ case "swipeFromEdge": {
1409
+ const { width, height } = await driver.getWindowSize();
1410
+ const midX = Math.round(width / 2);
1411
+ const midY = Math.round(height / 2);
1412
+ let start;
1413
+ let end;
1414
+ switch (action.edge) {
1415
+ case "left":
1416
+ start = { x: 0, y: midY };
1417
+ end = { x: Math.round(width * 0.6), y: midY };
1418
+ break;
1419
+ case "right":
1420
+ start = { x: width - 1, y: midY };
1421
+ end = { x: Math.round(width * 0.4), y: midY };
1422
+ break;
1423
+ case "top":
1424
+ start = { x: midX, y: 0 };
1425
+ end = { x: midX, y: Math.round(height * 0.6) };
1426
+ break;
1427
+ case "bottom":
1428
+ start = { x: midX, y: height - 1 };
1429
+ end = { x: midX, y: Math.round(height * 0.4) };
1430
+ break;
1431
+ }
1432
+ await safePerform(
1433
+ driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: start.x, y: start.y }).down({ button: 0 }).move({ x: end.x, y: end.y, duration: 300 }).up({ button: 0 })
1434
+ );
1435
+ return { success: true, startCoordinates: start, endCoordinates: end };
1436
+ }
1437
+ // Runner-minted / author-channel verbs (D-01/D-15): reportValue, assertAI, extractAI, and
1438
+ // setVariable are intercepted at the runner dispatch seam and never reach the adapter. A
1439
+ // grouped §7.1 defensive case surfaces a dispatch-routing error if one arrives here;
1440
+ // assertNever remains the backstop for a genuinely unknown schema verb. (uniqueValue is
1441
+ // web-only and is not part of ActionsFor<'ios'>.)
1442
+ case "reportValue":
1443
+ case "assertAI":
1444
+ case "extractAI":
1445
+ case "setVariable":
1446
+ return {
1447
+ success: false,
1448
+ error: `Action "${action.type}" is handled by the runner dispatch seam and must not reach the iOS adapter (runner-minted/author-channel verb). This indicates a dispatch routing error.`
1449
+ };
1450
+ default:
1451
+ return (0, import_agent_qa_runner.assertNever)(action);
759
1452
  }
760
1453
  }
761
1454
  };