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