@vostride/agent-qa-ios 0.1.20 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +872 -179
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +101 -3
- package/dist/index.d.ts +101 -3
- package/dist/index.js +893 -179
- package/dist/index.js.map +1 -1
- package/package.json +7 -3
package/dist/index.js
CHANGED
|
@@ -1,8 +1,44 @@
|
|
|
1
1
|
// src/adapter.ts
|
|
2
|
-
import {
|
|
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.
|
|
356
|
+
this.readIOSPageSource(),
|
|
221
357
|
this.driver.getWindowSize()
|
|
222
358
|
]);
|
|
223
|
-
const
|
|
224
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
375
|
+
const { state, binding } = buildScreenState({
|
|
376
|
+
platform: "ios",
|
|
377
|
+
nodes,
|
|
378
|
+
viewport,
|
|
379
|
+
scroll,
|
|
380
|
+
coordSpace: "viewport",
|
|
243
381
|
timestamp: Date.now(),
|
|
244
|
-
|
|
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
|
|
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
|
|
379
|
-
const
|
|
380
|
-
if (
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
479
|
-
if (webCtx) {
|
|
1020
|
+
if (action.mode === "untilVisible") {
|
|
480
1021
|
try {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
|
499
|
-
const
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
588
|
-
|
|
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
|
|
627
|
-
const
|
|
628
|
-
if (
|
|
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: `
|
|
1248
|
+
error: `Failed to open universal link ${action.url} through the iOS system handler: ${firstErrorLine(err)}${configuredFailure ? ` (${configuredFailure})` : ""}`
|
|
632
1249
|
};
|
|
633
1250
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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:
|
|
1269
|
+
return { success: false, error };
|
|
656
1270
|
}
|
|
657
|
-
case "
|
|
658
|
-
const
|
|
659
|
-
const
|
|
660
|
-
if (
|
|
661
|
-
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
-
|
|
672
|
-
await driver.
|
|
1293
|
+
await this.ensureContext(webCtx);
|
|
1294
|
+
const value = await driver.execute("return window.location.href;");
|
|
1295
|
+
return { success: true, data: value };
|
|
673
1296
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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 "
|
|
698
|
-
await
|
|
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
|
|
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
|
-
|
|
724
|
-
|
|
725
|
-
const
|
|
726
|
-
|
|
727
|
-
|
|
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
|
-
|
|
730
|
-
|
|
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
|
};
|