@vostride/agent-qa-ios 0.0.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,739 @@
1
+ // src/adapter.ts
2
+ import { MobileSetupError, parseMobileSource, MobileElementResolver, KEY_MAP, computeSwipe, computePinch, computeFingerPositions, alignToWindow, warnIfOutOfBounds } from "@vostride/agent-qa-core";
3
+
4
+ // src/session.ts
5
+ import { remote } from "webdriverio";
6
+ async function createIOSSession(config, farmSession) {
7
+ const match = config.device?.match ?? {};
8
+ const appiumUrl = process.env.AGENT_QA_APPIUM_URL ?? config.appiumUrl ?? "http://localhost:4723";
9
+ const url = new URL(appiumUrl);
10
+ const browserName = typeof match.browserName === "string" && match.browserName.trim().length > 0 ? match.browserName.trim() : void 0;
11
+ const appStateNoReset = config.appState === "preserve" ? true : config.appState === "reset" ? false : void 0;
12
+ const capabilities = {
13
+ platformName: "iOS",
14
+ "appium:automationName": match.automationName ?? "XCUITest",
15
+ "appium:deviceName": config.device?.name ?? "iPhone Simulator",
16
+ "appium:showIOSLog": true,
17
+ "appium:newCommandTimeout": config.timeouts?.test ? Math.ceil(config.timeouts.test / 1e3) : 300
18
+ };
19
+ if (!browserName && appStateNoReset !== void 0) capabilities["appium:noReset"] = appStateNoReset;
20
+ if (match.platformVersion) capabilities["appium:platformVersion"] = match.platformVersion;
21
+ if (match.bundleId) capabilities["appium:bundleId"] = match.bundleId;
22
+ if (config.bundleId) capabilities["appium:bundleId"] = config.bundleId;
23
+ if (config.appPath) capabilities["appium:app"] = config.appPath;
24
+ if (match.udid) capabilities["appium:udid"] = match.udid;
25
+ if (config.udid) capabilities["appium:udid"] = config.udid;
26
+ for (const key of Object.keys(capabilities)) {
27
+ if (capabilities[key] === void 0) {
28
+ delete capabilities[key];
29
+ }
30
+ }
31
+ if (farmSession) {
32
+ Object.assign(capabilities, farmSession.capabilities);
33
+ return remote({
34
+ protocol: "https",
35
+ hostname: farmSession.hostname,
36
+ port: farmSession.port,
37
+ path: farmSession.path,
38
+ logLevel: config.verbose ? "info" : "silent",
39
+ capabilities
40
+ });
41
+ }
42
+ return remote({
43
+ hostname: url.hostname,
44
+ port: parseInt(url.port || "4723", 10),
45
+ path: url.pathname === "/" ? "/" : url.pathname,
46
+ logLevel: config.verbose ? "info" : "silent",
47
+ capabilities
48
+ });
49
+ }
50
+
51
+ // src/adapter.ts
52
+ var IOS_FILLABLE_ROLES = /* @__PURE__ */ new Set(["textbox", "searchbox", "textfield", "securetextfield", "searchfield", "combobox"]);
53
+ var IOS_NATIVE_SELECT_TYPES = /* @__PURE__ */ new Set([
54
+ "XCUIElementTypePickerWheel",
55
+ "XCUIElementTypePicker",
56
+ "XCUIElementTypeDatePicker",
57
+ "XCUIElementTypePopUpButton",
58
+ "XCUIElementTypeComboBox"
59
+ ]);
60
+ var IOS_NATIVE_SELECT_ROLES = /* @__PURE__ */ new Set(["pickerwheel", "spinbutton", "combobox"]);
61
+ async function safePerform(chain) {
62
+ try {
63
+ await chain.perform();
64
+ } catch (err) {
65
+ const msg = err instanceof Error ? err.message : String(err);
66
+ if (msg.includes("actions") && msg.includes("DELETE")) return;
67
+ throw err;
68
+ }
69
+ }
70
+ var IOS_SYSLOG_REGEX = /^.*?\s+(\S+)\[(\d+)\].*?:\s+(.*)/;
71
+ var IOS_APPIUM_LOG_REGEX = /^\S+\s+\S+\s+\[(\w+)\]\s+\[(\S+)\]\s+(.*)/;
72
+ var IOS_LEVEL_MAP = {
73
+ Debug: "debug",
74
+ Info: "info",
75
+ Warning: "warn",
76
+ Warn: "warn",
77
+ Error: "error",
78
+ Fault: "error",
79
+ Notice: "info"
80
+ };
81
+ function firstNonEmpty(...values) {
82
+ for (const value of values) {
83
+ const trimmed = typeof value === "string" ? value.trim() : "";
84
+ if (trimmed) return trimmed;
85
+ }
86
+ return void 0;
87
+ }
88
+ function isIOSNativeSelectTarget(refData) {
89
+ const role = refData?.role?.toLowerCase();
90
+ return Boolean(
91
+ role && IOS_NATIVE_SELECT_ROLES.has(role) || refData?.nativeType && IOS_NATIVE_SELECT_TYPES.has(refData.nativeType)
92
+ );
93
+ }
94
+ function normalizePickerValue(value) {
95
+ return value.trim().replace(/\s+/g, " ").toLowerCase();
96
+ }
97
+ async function getElementValue(el) {
98
+ for (const getter of [
99
+ () => el.getValue?.(),
100
+ () => el.getText?.(),
101
+ () => el.getAttribute?.("value")
102
+ ]) {
103
+ try {
104
+ const value = await getter();
105
+ if (typeof value === "string" && value.trim()) return value;
106
+ } catch {
107
+ }
108
+ }
109
+ return void 0;
110
+ }
111
+ async function findIOSPickerWheels(driver, refs, targetRef, refData) {
112
+ const wheelSelector = "-ios class chain:**/XCUIElementTypePickerWheel";
113
+ if (refData?.nativeType === "XCUIElementTypePickerWheel") {
114
+ try {
115
+ const observedWheelRefs = Object.entries(refs).filter(([, data]) => data.nativeType === "XCUIElementTypePickerWheel").map(([ref]) => ref);
116
+ const targetIndex = observedWheelRefs.indexOf(targetRef);
117
+ const wheels = Array.from(await driver.$$(wheelSelector));
118
+ if (targetIndex >= 0 && wheels[targetIndex]) return [wheels[targetIndex]];
119
+ if (targetIndex <= 0 && wheels.length === 1) return [wheels[0]];
120
+ } catch {
121
+ }
122
+ try {
123
+ const wheel = await driver.$(wheelSelector);
124
+ return await wheel.isExisting() ? [wheel] : [];
125
+ } catch {
126
+ return [];
127
+ }
128
+ }
129
+ try {
130
+ const wheels = await driver.$$(wheelSelector);
131
+ return Array.from(wheels);
132
+ } catch {
133
+ return [];
134
+ }
135
+ }
136
+ function matchesIOSApp(processName, bundleId) {
137
+ const appName = bundleId.split(".").pop() ?? bundleId;
138
+ return processName === appName || processName.includes(appName);
139
+ }
140
+ function parseIOSSyslogLine(message, timestamp, bundleId) {
141
+ const appiumMatch = message.match(IOS_APPIUM_LOG_REGEX);
142
+ if (appiumMatch) {
143
+ const [, levelStr, processName, text] = appiumMatch;
144
+ if (bundleId && !matchesIOSApp(processName, bundleId)) return null;
145
+ return {
146
+ level: IOS_LEVEL_MAP[levelStr] ?? "info",
147
+ text: text.trim(),
148
+ timestamp
149
+ };
150
+ }
151
+ const syslogMatch = message.match(IOS_SYSLOG_REGEX);
152
+ if (syslogMatch) {
153
+ const [, processName, , text] = syslogMatch;
154
+ if (bundleId && !matchesIOSApp(processName, bundleId)) return null;
155
+ return {
156
+ level: "info",
157
+ text: text.trim(),
158
+ timestamp
159
+ };
160
+ }
161
+ return null;
162
+ }
163
+ var IOSPlatformAdapter = class {
164
+ platform = "ios";
165
+ driver = null;
166
+ lastRefs = {};
167
+ config = null;
168
+ // Web context detected from getContexts() (e.g. 'WEBVIEW_12345').
169
+ // Used for URL extraction in observe() and JS scroll in execute().
170
+ activeWebContext = null;
171
+ webContextChecked = false;
172
+ currentContext = null;
173
+ consoleLogs = [];
174
+ consoleDrainIndex = 0;
175
+ captureConsole = true;
176
+ bundleId = null;
177
+ async setup(config) {
178
+ this.config = config;
179
+ this.captureConsole = config.logCapture?.console !== false;
180
+ this.bundleId = this.config.bundleId ?? config.device?.match?.bundleId ?? null;
181
+ this.driver = await createIOSSession(this.config, config.farmSession);
182
+ const bundleId = this.bundleId;
183
+ if (bundleId) {
184
+ try {
185
+ await this.driver.activateApp(bundleId);
186
+ } catch (err) {
187
+ throw new MobileSetupError({
188
+ category: "app-launch",
189
+ message: `Failed to launch iOS app "${bundleId}": ${err instanceof Error ? err.message : String(err)}`,
190
+ platform: "ios",
191
+ deviceName: config.device?.name,
192
+ appId: bundleId,
193
+ cause: err
194
+ });
195
+ }
196
+ }
197
+ }
198
+ async cleanup() {
199
+ try {
200
+ if (this.driver) {
201
+ await this.driver.deleteSession();
202
+ }
203
+ } catch {
204
+ }
205
+ this.driver = null;
206
+ this.lastRefs = {};
207
+ this.config = null;
208
+ this.consoleLogs = [];
209
+ this.bundleId = null;
210
+ this.activeWebContext = null;
211
+ this.webContextChecked = false;
212
+ this.currentContext = null;
213
+ }
214
+ async observe(_options) {
215
+ if (!this.driver) {
216
+ throw new Error("IOSPlatformAdapter not initialized \u2014 call setup() first");
217
+ }
218
+ await this.ensureContext("NATIVE_APP");
219
+ const [xml, viewport] = await Promise.all([
220
+ this.driver.getPageSource(),
221
+ this.driver.getWindowSize()
222
+ ]);
223
+ const parsed = parseMobileSource(xml, "ios", viewport);
224
+ this.lastRefs = parsed.refs;
225
+ let url;
226
+ const webCtx = await this.getWebContext();
227
+ if (webCtx) {
228
+ try {
229
+ await this.ensureContext(webCtx);
230
+ url = await this.driver.getUrl();
231
+ await this.ensureContext("NATIVE_APP");
232
+ } catch {
233
+ try {
234
+ await this.ensureContext("NATIVE_APP");
235
+ } catch {
236
+ }
237
+ }
238
+ }
239
+ return {
240
+ tree: parsed.tree,
241
+ elements: parsed.elements,
242
+ url,
243
+ timestamp: Date.now(),
244
+ metadata: { coordSpace: "viewport", viewportWidth: viewport.width, viewportHeight: viewport.height, refMap: parsed.refs }
245
+ };
246
+ }
247
+ async execute(action) {
248
+ if (!this.driver) {
249
+ throw new Error("IOSPlatformAdapter not initialized \u2014 call setup() first");
250
+ }
251
+ try {
252
+ return await this.executeAction(action);
253
+ } catch (err) {
254
+ let screenshot;
255
+ try {
256
+ screenshot = await this.captureScreenshot();
257
+ } catch {
258
+ }
259
+ return {
260
+ success: false,
261
+ error: err instanceof Error ? err.message : String(err),
262
+ screenshot
263
+ };
264
+ }
265
+ }
266
+ async screenshot() {
267
+ try {
268
+ await this.ensureContext("NATIVE_APP");
269
+ } catch {
270
+ }
271
+ const raw = await this.captureScreenshot();
272
+ if (!raw || !this.driver) return raw;
273
+ try {
274
+ const { width, height } = await this.driver.getWindowSize();
275
+ return await alignToWindow(raw, { width, height });
276
+ } catch {
277
+ return raw;
278
+ }
279
+ }
280
+ // ---------------------------------------------------------------------------
281
+ // Device log capture
282
+ // ---------------------------------------------------------------------------
283
+ async pollDeviceLogs() {
284
+ if (!this.captureConsole) return;
285
+ if (!this.driver) return;
286
+ try {
287
+ const logs = await this.driver.getLogs("syslog");
288
+ for (const entry of logs) {
289
+ const parsed = parseIOSSyslogLine(entry.message, entry.timestamp ?? Date.now(), this.bundleId);
290
+ if (parsed) {
291
+ this.consoleLogs.push(parsed);
292
+ continue;
293
+ }
294
+ if (entry.message?.trim()) {
295
+ this.consoleLogs.push({
296
+ level: IOS_LEVEL_MAP[entry.level ?? ""] ?? "info",
297
+ text: entry.message.trim(),
298
+ timestamp: entry.timestamp ?? Date.now()
299
+ });
300
+ }
301
+ }
302
+ if (this.consoleLogs.length > 1e3) {
303
+ this.consoleLogs = this.consoleLogs.slice(-500);
304
+ }
305
+ } catch {
306
+ }
307
+ }
308
+ drainConsoleLogs(level) {
309
+ const newLogs = this.consoleLogs.slice(this.consoleDrainIndex);
310
+ this.consoleDrainIndex = this.consoleLogs.length;
311
+ if (!level) return newLogs;
312
+ return newLogs.filter((e) => e.level === level);
313
+ }
314
+ // ---------------------------------------------------------------------------
315
+ // Web context helpers
316
+ // ---------------------------------------------------------------------------
317
+ /** Lazily detect the WEBVIEW context. Cached after first lookup. */
318
+ async getWebContext() {
319
+ if (!this.webContextChecked) {
320
+ this.webContextChecked = true;
321
+ try {
322
+ const contexts = await this.driver.getContexts();
323
+ this.activeWebContext = contexts.find(
324
+ (c) => typeof c === "string" && c.includes("WEBVIEW")
325
+ ) ?? null;
326
+ } catch {
327
+ this.activeWebContext = null;
328
+ }
329
+ }
330
+ return this.activeWebContext;
331
+ }
332
+ /** Invalidate the cached web context so next getWebContext() re-queries. */
333
+ invalidateWebContext() {
334
+ this.webContextChecked = false;
335
+ this.activeWebContext = null;
336
+ }
337
+ async ensureContext(target) {
338
+ if (this.currentContext === target) return;
339
+ try {
340
+ await this.driver.switchContext(target);
341
+ this.currentContext = target;
342
+ } catch {
343
+ this.currentContext = null;
344
+ }
345
+ }
346
+ // ---------------------------------------------------------------------------
347
+ // Screenshots
348
+ // ---------------------------------------------------------------------------
349
+ async captureScreenshot() {
350
+ try {
351
+ if (this.driver) {
352
+ const base64 = await this.driver.takeScreenshot();
353
+ return Buffer.from(base64, "base64");
354
+ }
355
+ return void 0;
356
+ } catch {
357
+ return void 0;
358
+ }
359
+ }
360
+ // ---------------------------------------------------------------------------
361
+ // Actions
362
+ // ---------------------------------------------------------------------------
363
+ async executeAction(action) {
364
+ const driver = this.driver;
365
+ const resolver = new MobileElementResolver(this.lastRefs);
366
+ await this.ensureContext("NATIVE_APP");
367
+ switch (action.type) {
368
+ case "tap":
369
+ case "click": {
370
+ const { center, bounds } = resolver.resolve(action.ref);
371
+ await safePerform(
372
+ driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(center.x), y: Math.round(center.y) }).down({ button: 0 }).up({ button: 0 })
373
+ );
374
+ return { success: true, coordinates: center, boundingBox: bounds };
375
+ }
376
+ case "fill": {
377
+ const { center, bounds } = resolver.resolve(action.ref);
378
+ const refData = this.lastRefs[action.ref];
379
+ const role = refData?.role?.toLowerCase();
380
+ if (role && !IOS_FILLABLE_ROLES.has(role)) {
381
+ return {
382
+ success: false,
383
+ error: `Element ${action.ref} is not fillable (role: ${role}). Use a textbox or input element instead.`
384
+ };
385
+ }
386
+ await safePerform(
387
+ driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(center.x), y: Math.round(center.y) }).down({ button: 0 }).up({ button: 0 })
388
+ );
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
+ }
421
+ case "nativeSelect": {
422
+ const { center, bounds } = resolver.resolve(action.ref);
423
+ const refData = this.lastRefs[action.ref];
424
+ if (!isIOSNativeSelectTarget(refData)) {
425
+ return {
426
+ success: false,
427
+ error: "nativeSelect only supports native iOS picker wheels and picker controls. Use tap, swipe, or tapCoordinate for custom controls.",
428
+ coordinates: center,
429
+ boundingBox: bounds
430
+ };
431
+ }
432
+ const wheels = await findIOSPickerWheels(driver, this.lastRefs, action.ref, refData);
433
+ if (wheels.length === 0) {
434
+ return {
435
+ success: false,
436
+ error: "Could not find an iOS picker wheel for nativeSelect. Use tap, swipe, or tapCoordinate for custom controls.",
437
+ coordinates: center,
438
+ boundingBox: bounds
439
+ };
440
+ }
441
+ if (wheels.length > 1) {
442
+ return {
443
+ success: false,
444
+ error: "Found multiple iOS picker wheels for nativeSelect. Do not guess which wheel to change; target a specific picker wheel or use tap/swipe/tapCoordinate.",
445
+ coordinates: center,
446
+ boundingBox: bounds
447
+ };
448
+ }
449
+ const wheel = wheels[0];
450
+ await wheel.setValue(action.value);
451
+ const selectedValue = await getElementValue(wheel);
452
+ let matchStrategy = "setValue-accepted";
453
+ if (selectedValue === action.value) {
454
+ matchStrategy = "exact";
455
+ } else if (selectedValue && normalizePickerValue(selectedValue) === normalizePickerValue(action.value)) {
456
+ matchStrategy = "normalized";
457
+ } else if (selectedValue) {
458
+ return {
459
+ success: false,
460
+ error: `iOS picker wheel value is "${selectedValue}" after nativeSelect, expected "${action.value}".`,
461
+ coordinates: center,
462
+ boundingBox: bounds,
463
+ data: { requestedValue: action.value, matchedValue: selectedValue }
464
+ };
465
+ }
466
+ return {
467
+ success: true,
468
+ coordinates: center,
469
+ boundingBox: bounds,
470
+ data: {
471
+ requestedValue: action.value,
472
+ matchedValue: selectedValue ?? action.value,
473
+ matchStrategy
474
+ }
475
+ };
476
+ }
477
+ case "scroll": {
478
+ const webCtx = await this.getWebContext();
479
+ if (webCtx) {
480
+ try {
481
+ await this.ensureContext(webCtx);
482
+ const dx = action.scrollType === "horizontal" ? action.value : 0;
483
+ const dy = action.scrollType === "vertical" ? action.value : 0;
484
+ await driver.execute(
485
+ "window.scrollBy(arguments[0], arguments[1]);",
486
+ dx,
487
+ dy
488
+ );
489
+ await this.ensureContext("NATIVE_APP");
490
+ return { success: true };
491
+ } catch {
492
+ try {
493
+ await this.ensureContext("NATIVE_APP");
494
+ } catch {
495
+ }
496
+ }
497
+ }
498
+ const { width, height } = await driver.getWindowSize();
499
+ const centerX = Math.round(width / 2);
500
+ const centerY = Math.round(height / 2);
501
+ const scrollStartX = centerX;
502
+ const scrollStartY = centerY;
503
+ const scrollEndX = action.scrollType === "horizontal" ? centerX - action.value : centerX;
504
+ const scrollEndY = action.scrollType === "vertical" ? centerY - action.value : centerY;
505
+ const scrollDuration = action.duration ?? 300;
506
+ await safePerform(
507
+ driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: scrollStartX, y: scrollStartY }).down({ button: 0 }).move({ x: scrollEndX, y: scrollEndY, duration: scrollDuration }).up({ button: 0 })
508
+ );
509
+ return { success: true };
510
+ }
511
+ case "swipe": {
512
+ const { width, height } = await driver.getWindowSize();
513
+ let sx = Math.round(width / 2);
514
+ let sy = Math.round(height / 2);
515
+ if (action.ref) {
516
+ const { center } = resolver.resolve(action.ref);
517
+ sx = Math.round(center.x);
518
+ sy = Math.round(center.y);
519
+ }
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
+ warnIfOutOfBounds({ x: pts.startX, y: pts.startY }, { width, height }, "swipe");
528
+ warnIfOutOfBounds({ x: pts.endX, y: pts.endY }, { width, height }, "swipe");
529
+ const swipeDuration = action.duration ?? 300;
530
+ await safePerform(
531
+ 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
+ );
533
+ return {
534
+ success: true,
535
+ startCoordinates: { x: pts.startX, y: pts.startY },
536
+ endCoordinates: { x: pts.endX, y: pts.endY }
537
+ };
538
+ }
539
+ case "pinch": {
540
+ const { width: pinchW, height: pinchH } = await driver.getWindowSize();
541
+ let pcx = Math.round(pinchW / 2);
542
+ let pcy = Math.round(pinchH / 2);
543
+ if (action.ref) {
544
+ const { center } = resolver.resolve(action.ref);
545
+ pcx = Math.round(center.x);
546
+ pcy = Math.round(center.y);
547
+ }
548
+ if (action.x !== void 0) pcx = action.x;
549
+ if (action.y !== void 0) pcy = action.y;
550
+ warnIfOutOfBounds({ x: pcx, y: pcy }, { width: pinchW, height: pinchH }, "pinch");
551
+ const { finger1Start, finger1End, finger2Start, finger2End } = computePinch(pcx, pcy, action.scale);
552
+ const pinchDuration = 500;
553
+ await driver.actions([
554
+ driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(finger1Start.x), y: Math.round(finger1Start.y) }).down({ button: 0 }).move({ x: Math.round(finger1End.x), y: Math.round(finger1End.y), duration: pinchDuration }).up({ button: 0 }),
555
+ driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(finger2Start.x), y: Math.round(finger2Start.y) }).down({ button: 0 }).move({ x: Math.round(finger2End.x), y: Math.round(finger2End.y), duration: pinchDuration }).up({ button: 0 })
556
+ ]);
557
+ return { success: true, coordinates: { x: pcx, y: pcy } };
558
+ }
559
+ case "multiTap": {
560
+ const { width: mtW, height: mtH } = await driver.getWindowSize();
561
+ let mtcx = Math.round(mtW / 2);
562
+ let mtcy = Math.round(mtH / 2);
563
+ if (action.ref) {
564
+ const { center } = resolver.resolve(action.ref);
565
+ mtcx = Math.round(center.x);
566
+ mtcy = Math.round(center.y);
567
+ }
568
+ if (action.x !== void 0) mtcx = action.x;
569
+ if (action.y !== void 0) mtcy = action.y;
570
+ warnIfOutOfBounds({ x: mtcx, y: mtcy }, { width: mtW, height: mtH }, "multiTap");
571
+ const positions = computeFingerPositions(mtcx, mtcy, action.fingers);
572
+ await driver.actions(
573
+ positions.map(
574
+ (pos) => driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(pos.x), y: Math.round(pos.y) }).down({ button: 0 }).pause(100).up({ button: 0 })
575
+ )
576
+ );
577
+ return { success: true };
578
+ }
579
+ case "longpress": {
580
+ const { center } = resolver.resolve(action.ref);
581
+ await safePerform(
582
+ driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(center.x), y: Math.round(center.y) }).down({ button: 0 }).pause(action.duration ?? 1e3).up({ button: 0 })
583
+ );
584
+ return { success: true };
585
+ }
586
+ case "waitFor": {
587
+ await driver.pause(action.timeout ?? 2e3);
588
+ return { success: true };
589
+ }
590
+ case "delay": {
591
+ await driver.pause(action.ms);
592
+ return { success: true };
593
+ }
594
+ case "keypress": {
595
+ for (const rawKey of action.keys) {
596
+ const mapped = KEY_MAP[rawKey] ?? rawKey;
597
+ await driver.keys([mapped]);
598
+ }
599
+ return { success: true };
600
+ }
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
+ // --- New actions ---
617
+ case "hideKeyboard": {
618
+ try {
619
+ await driver.hideKeyboard();
620
+ } catch {
621
+ }
622
+ return { success: true };
623
+ }
624
+ case "clearText": {
625
+ const { center, bounds } = resolver.resolve(action.ref);
626
+ const clearRefData = this.lastRefs[action.ref];
627
+ const clearRole = clearRefData?.role?.toLowerCase();
628
+ if (clearRole && !IOS_FILLABLE_ROLES.has(clearRole)) {
629
+ return {
630
+ success: false,
631
+ error: `Element ${action.ref} is not fillable (role: ${clearRole}). clearText only works on text inputs. Use a textbox or input element instead.`
632
+ };
633
+ }
634
+ await safePerform(
635
+ driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(center.x), y: Math.round(center.y) }).down({ button: 0 }).up({ button: 0 })
636
+ );
637
+ const clearSelectors = [
638
+ "-ios class chain:**/XCUIElementTypeTextField[`hasFocus == 1`]",
639
+ "-ios class chain:**/XCUIElementTypeSecureTextField[`hasFocus == 1`]",
640
+ "-ios class chain:**/XCUIElementTypeSearchField[`hasFocus == 1`]",
641
+ "-ios class chain:**/XCUIElementTypeSearchField",
642
+ "-ios class chain:**/XCUIElementTypeTextField",
643
+ "-ios class chain:**/XCUIElementTypeSecureTextField"
644
+ ];
645
+ for (const sel of clearSelectors) {
646
+ try {
647
+ const el = await driver.$(sel);
648
+ if (await el.isExisting()) {
649
+ await el.clearValue();
650
+ break;
651
+ }
652
+ } catch {
653
+ }
654
+ }
655
+ return { success: true, coordinates: center, boundingBox: bounds };
656
+ }
657
+ case "openLink": {
658
+ const bundleId = firstNonEmpty(action.appId, action.bundleId, this.bundleId);
659
+ const isWebUrl = /^https?:\/\//i.test(action.url);
660
+ if (!bundleId && !isWebUrl) {
661
+ return { success: false, error: `Missing iOS bundle id for native deep link: ${action.url}` };
662
+ }
663
+ try {
664
+ if (bundleId) {
665
+ const deepLink = driver.deepLink;
666
+ if (typeof deepLink === "function") {
667
+ await deepLink.call(driver, action.url, bundleId, action.waitForLaunch ?? true);
668
+ } else {
669
+ await driver.execute("mobile: deepLink", { url: action.url, bundleId });
670
+ }
671
+ } else {
672
+ await driver.url(action.url);
673
+ }
674
+ this.invalidateWebContext();
675
+ return { success: true };
676
+ } catch (err) {
677
+ return { success: false, error: `Failed to open link ${action.url}${bundleId ? ` in iOS app ${bundleId}` : ""}: ${err instanceof Error ? err.message : String(err)}` };
678
+ }
679
+ }
680
+ case "drag": {
681
+ const from = resolver.resolve(action.fromRef);
682
+ const to = resolver.resolve(action.toRef);
683
+ await safePerform(
684
+ driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x: Math.round(from.center.x), y: Math.round(from.center.y) }).down({ button: 0 }).pause(300).move({ x: Math.round(to.center.x), y: Math.round(to.center.y), duration: 500 }).up({ button: 0 })
685
+ );
686
+ return { success: true };
687
+ }
688
+ case "doubleTap": {
689
+ const { center, bounds } = resolver.resolve(action.ref);
690
+ const x = Math.round(center.x);
691
+ const y = Math.round(center.y);
692
+ await safePerform(
693
+ driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x, y }).down({ button: 0 }).up({ button: 0 }).pause(50).down({ button: 0 }).up({ button: 0 })
694
+ );
695
+ return { success: true, coordinates: center, boundingBox: bounds };
696
+ }
697
+ case "launchApp": {
698
+ await driver.activateApp(action.bundleId);
699
+ return { success: true };
700
+ }
701
+ case "stopApp": {
702
+ await driver.terminateApp(action.bundleId);
703
+ return { success: true };
704
+ }
705
+ case "setOrientation": {
706
+ await driver.setOrientation(action.orientation === "landscape" ? "LANDSCAPE" : "PORTRAIT");
707
+ return { success: true };
708
+ }
709
+ case "executeScript": {
710
+ const result = await driver.execute(action.command, action.args ?? {});
711
+ return { success: true, data: result };
712
+ }
713
+ case "tapCoordinate": {
714
+ const x = action.x;
715
+ const y = action.y;
716
+ const tcVp = await driver.getWindowSize();
717
+ warnIfOutOfBounds({ x, y }, { width: tcVp.width, height: tcVp.height }, "tapCoordinate");
718
+ await safePerform(
719
+ driver.action("pointer", { parameters: { pointerType: "touch" } }).move({ x, y }).down({ button: 0 }).up({ button: 0 })
720
+ );
721
+ return { success: true, coordinates: { x, y } };
722
+ }
723
+ case "readConsoleLogs": {
724
+ await this.pollDeviceLogs();
725
+ const all = [...this.consoleLogs];
726
+ const entries = action.level ? all.filter((e) => e.level === action.level) : all;
727
+ return { success: true, data: entries };
728
+ }
729
+ default: {
730
+ return { success: false, error: `Unknown action type: ${action.type}` };
731
+ }
732
+ }
733
+ }
734
+ };
735
+ export {
736
+ IOSPlatformAdapter,
737
+ createIOSSession
738
+ };
739
+ //# sourceMappingURL=index.js.map