@vostride/agent-qa-ios 0.1.21 → 0.2.2

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