react-native-ai-devtools 1.1.4
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/LICENSE +32 -0
- package/README.md +1250 -0
- package/build/__tests__/helpers/fake-cdp-server.d.ts +56 -0
- package/build/__tests__/helpers/fake-cdp-server.d.ts.map +1 -0
- package/build/__tests__/helpers/fake-cdp-server.js +108 -0
- package/build/__tests__/helpers/fake-cdp-server.js.map +1 -0
- package/build/__tests__/integration/connection-health.test.d.ts +2 -0
- package/build/__tests__/integration/connection-health.test.d.ts.map +1 -0
- package/build/__tests__/integration/connection-health.test.js +151 -0
- package/build/__tests__/integration/connection-health.test.js.map +1 -0
- package/build/__tests__/integration/execute-in-app.test.d.ts +2 -0
- package/build/__tests__/integration/execute-in-app.test.d.ts.map +1 -0
- package/build/__tests__/integration/execute-in-app.test.js +115 -0
- package/build/__tests__/integration/execute-in-app.test.js.map +1 -0
- package/build/__tests__/integration/tools.test.d.ts +2 -0
- package/build/__tests__/integration/tools.test.d.ts.map +1 -0
- package/build/__tests__/integration/tools.test.js +228 -0
- package/build/__tests__/integration/tools.test.js.map +1 -0
- package/build/__tests__/setup.d.ts +2 -0
- package/build/__tests__/setup.d.ts.map +1 -0
- package/build/__tests__/setup.js +11 -0
- package/build/__tests__/setup.js.map +1 -0
- package/build/__tests__/unit/bundle.test.d.ts +2 -0
- package/build/__tests__/unit/bundle.test.d.ts.map +1 -0
- package/build/__tests__/unit/bundle.test.js +53 -0
- package/build/__tests__/unit/bundle.test.js.map +1 -0
- package/build/__tests__/unit/connection-health.test.d.ts +2 -0
- package/build/__tests__/unit/connection-health.test.d.ts.map +1 -0
- package/build/__tests__/unit/connection-health.test.js +28 -0
- package/build/__tests__/unit/connection-health.test.js.map +1 -0
- package/build/__tests__/unit/executor.test.d.ts +2 -0
- package/build/__tests__/unit/executor.test.d.ts.map +1 -0
- package/build/__tests__/unit/executor.test.js +79 -0
- package/build/__tests__/unit/executor.test.js.map +1 -0
- package/build/__tests__/unit/logs.test.d.ts +2 -0
- package/build/__tests__/unit/logs.test.d.ts.map +1 -0
- package/build/__tests__/unit/logs.test.js +81 -0
- package/build/__tests__/unit/logs.test.js.map +1 -0
- package/build/__tests__/unit/metro.test.d.ts +2 -0
- package/build/__tests__/unit/metro.test.d.ts.map +1 -0
- package/build/__tests__/unit/metro.test.js +61 -0
- package/build/__tests__/unit/metro.test.js.map +1 -0
- package/build/__tests__/unit/network.test.d.ts +2 -0
- package/build/__tests__/unit/network.test.d.ts.map +1 -0
- package/build/__tests__/unit/network.test.js +102 -0
- package/build/__tests__/unit/network.test.js.map +1 -0
- package/build/__tests__/unit/tap.test.d.ts +2 -0
- package/build/__tests__/unit/tap.test.d.ts.map +1 -0
- package/build/__tests__/unit/tap.test.js +157 -0
- package/build/__tests__/unit/tap.test.js.map +1 -0
- package/build/core/android.d.ts +265 -0
- package/build/core/android.d.ts.map +1 -0
- package/build/core/android.js +1413 -0
- package/build/core/android.js.map +1 -0
- package/build/core/bundle.d.ts +49 -0
- package/build/core/bundle.d.ts.map +1 -0
- package/build/core/bundle.js +368 -0
- package/build/core/bundle.js.map +1 -0
- package/build/core/connection.d.ts +43 -0
- package/build/core/connection.d.ts.map +1 -0
- package/build/core/connection.js +963 -0
- package/build/core/connection.js.map +1 -0
- package/build/core/connectionState.d.ts +108 -0
- package/build/core/connectionState.d.ts.map +1 -0
- package/build/core/connectionState.js +284 -0
- package/build/core/connectionState.js.map +1 -0
- package/build/core/errorScreenParser.d.ts +30 -0
- package/build/core/errorScreenParser.d.ts.map +1 -0
- package/build/core/errorScreenParser.js +198 -0
- package/build/core/errorScreenParser.js.map +1 -0
- package/build/core/executor.d.ts +113 -0
- package/build/core/executor.d.ts.map +1 -0
- package/build/core/executor.js +1877 -0
- package/build/core/executor.js.map +1 -0
- package/build/core/format.d.ts +8 -0
- package/build/core/format.d.ts.map +1 -0
- package/build/core/format.js +34 -0
- package/build/core/format.js.map +1 -0
- package/build/core/guides.d.ts +14 -0
- package/build/core/guides.d.ts.map +1 -0
- package/build/core/guides.js +261 -0
- package/build/core/guides.js.map +1 -0
- package/build/core/httpServer.d.ts +14 -0
- package/build/core/httpServer.d.ts.map +1 -0
- package/build/core/httpServer.js +2459 -0
- package/build/core/httpServer.js.map +1 -0
- package/build/core/httpServerProcess.d.ts +25 -0
- package/build/core/httpServerProcess.d.ts.map +1 -0
- package/build/core/httpServerProcess.js +153 -0
- package/build/core/httpServerProcess.js.map +1 -0
- package/build/core/index.d.ts +25 -0
- package/build/core/index.d.ts.map +1 -0
- package/build/core/index.js +53 -0
- package/build/core/index.js.map +1 -0
- package/build/core/ios.d.ts +214 -0
- package/build/core/ios.d.ts.map +1 -0
- package/build/core/ios.js +1232 -0
- package/build/core/ios.js.map +1 -0
- package/build/core/logs.d.ts +43 -0
- package/build/core/logs.d.ts.map +1 -0
- package/build/core/logs.js +144 -0
- package/build/core/logs.js.map +1 -0
- package/build/core/metro.d.ts +23 -0
- package/build/core/metro.d.ts.map +1 -0
- package/build/core/metro.js +96 -0
- package/build/core/metro.js.map +1 -0
- package/build/core/network.d.ts +43 -0
- package/build/core/network.d.ts.map +1 -0
- package/build/core/network.js +217 -0
- package/build/core/network.js.map +1 -0
- package/build/core/networkInterceptor.d.ts +3 -0
- package/build/core/networkInterceptor.d.ts.map +1 -0
- package/build/core/networkInterceptor.js +203 -0
- package/build/core/networkInterceptor.js.map +1 -0
- package/build/core/ocr.d.ts +69 -0
- package/build/core/ocr.d.ts.map +1 -0
- package/build/core/ocr.js +212 -0
- package/build/core/ocr.js.map +1 -0
- package/build/core/state.d.ts +17 -0
- package/build/core/state.d.ts.map +1 -0
- package/build/core/state.js +50 -0
- package/build/core/state.js.map +1 -0
- package/build/core/tap.d.ts +91 -0
- package/build/core/tap.d.ts.map +1 -0
- package/build/core/tap.js +542 -0
- package/build/core/tap.js.map +1 -0
- package/build/core/telemetry.d.ts +4 -0
- package/build/core/telemetry.d.ts.map +1 -0
- package/build/core/telemetry.js +289 -0
- package/build/core/telemetry.js.map +1 -0
- package/build/core/types.d.ts +134 -0
- package/build/core/types.d.ts.map +1 -0
- package/build/core/types.js +2 -0
- package/build/core/types.js.map +1 -0
- package/build/httpServerStandalone.d.ts +7 -0
- package/build/httpServerStandalone.d.ts.map +1 -0
- package/build/httpServerStandalone.js +31 -0
- package/build/httpServerStandalone.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +3012 -0
- package/build/index.js.map +1 -0
- package/build/pro/tap.d.ts +91 -0
- package/build/pro/tap.d.ts.map +1 -0
- package/build/pro/tap.js +542 -0
- package/build/pro/tap.js.map +1 -0
- package/package.json +63 -0
|
@@ -0,0 +1,1232 @@
|
|
|
1
|
+
import { exec, execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { existsSync } from "fs";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import os from "os";
|
|
6
|
+
import sharp from "sharp";
|
|
7
|
+
import { getActiveSimulatorUdid } from "./state.js";
|
|
8
|
+
const execAsync = promisify(exec);
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
// simctl command timeout in milliseconds
|
|
11
|
+
const SIMCTL_TIMEOUT = 30000;
|
|
12
|
+
// IDB command timeout in milliseconds
|
|
13
|
+
const IDB_TIMEOUT = 30000;
|
|
14
|
+
// Valid button types for IDB ui button command
|
|
15
|
+
export const IOS_BUTTON_TYPES = ["HOME", "LOCK", "SIDE_BUTTON", "SIRI", "APPLE_PAY"];
|
|
16
|
+
// Track connected IDB simulators to avoid redundant connect calls
|
|
17
|
+
const connectedIdbSimulators = new Set();
|
|
18
|
+
/**
|
|
19
|
+
* Get the IDB executable path
|
|
20
|
+
* Supports IDB_PATH environment variable for custom installations
|
|
21
|
+
*/
|
|
22
|
+
function getIdbPath() {
|
|
23
|
+
return process.env.IDB_PATH || "idb";
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Run IDB command with execFile (no shell) for proper argument handling
|
|
27
|
+
* This matches the original ios-simulator-mcp implementation
|
|
28
|
+
*/
|
|
29
|
+
async function runIdb(...args) {
|
|
30
|
+
const idbPath = getIdbPath();
|
|
31
|
+
const { stdout, stderr } = await execFileAsync(idbPath, args, {
|
|
32
|
+
timeout: IDB_TIMEOUT
|
|
33
|
+
});
|
|
34
|
+
return {
|
|
35
|
+
stdout: stdout.trim(),
|
|
36
|
+
stderr: stderr.trim()
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Check if IDB is available
|
|
41
|
+
*/
|
|
42
|
+
export async function isIdbAvailable() {
|
|
43
|
+
try {
|
|
44
|
+
await runIdb("--help");
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Ensure IDB is connected to the specified simulator
|
|
53
|
+
* IDB requires `idb connect <UDID>` before any UI commands work
|
|
54
|
+
*/
|
|
55
|
+
async function ensureIdbConnected(udid) {
|
|
56
|
+
// Skip if already connected in this session
|
|
57
|
+
if (connectedIdbSimulators.has(udid)) {
|
|
58
|
+
return { success: true };
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
await runIdb("connect", udid);
|
|
62
|
+
connectedIdbSimulators.add(udid);
|
|
63
|
+
return { success: true };
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
67
|
+
// "Already connected" is not an error
|
|
68
|
+
if (errorMessage.includes("already connected") || errorMessage.includes("Connected")) {
|
|
69
|
+
connectedIdbSimulators.add(udid);
|
|
70
|
+
return { success: true };
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
success: false,
|
|
74
|
+
error: `Failed to connect IDB to simulator: ${errorMessage}`
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Check if simctl is available
|
|
80
|
+
*/
|
|
81
|
+
export async function isSimctlAvailable() {
|
|
82
|
+
try {
|
|
83
|
+
await execAsync("xcrun simctl help", { timeout: 5000 });
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* List iOS simulators
|
|
92
|
+
*/
|
|
93
|
+
export async function listIOSSimulators(onlyBooted = false) {
|
|
94
|
+
try {
|
|
95
|
+
const simctlAvailable = await isSimctlAvailable();
|
|
96
|
+
if (!simctlAvailable) {
|
|
97
|
+
return {
|
|
98
|
+
success: false,
|
|
99
|
+
error: "Xcode command line tools not available. Install Xcode from the App Store."
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const { stdout } = await execAsync("xcrun simctl list devices -j", {
|
|
103
|
+
timeout: SIMCTL_TIMEOUT
|
|
104
|
+
});
|
|
105
|
+
const data = JSON.parse(stdout);
|
|
106
|
+
const simulators = [];
|
|
107
|
+
// Parse devices from each runtime
|
|
108
|
+
for (const [runtime, devices] of Object.entries(data.devices)) {
|
|
109
|
+
if (!Array.isArray(devices))
|
|
110
|
+
continue;
|
|
111
|
+
for (const device of devices) {
|
|
112
|
+
if (!device.isAvailable)
|
|
113
|
+
continue;
|
|
114
|
+
if (onlyBooted && device.state !== "Booted")
|
|
115
|
+
continue;
|
|
116
|
+
// Extract iOS version from runtime string
|
|
117
|
+
const runtimeMatch = runtime.match(/iOS[- ](\d+[.-]\d+)/i);
|
|
118
|
+
const runtimeVersion = runtimeMatch ? `iOS ${runtimeMatch[1].replace("-", ".")}` : runtime;
|
|
119
|
+
simulators.push({
|
|
120
|
+
udid: device.udid,
|
|
121
|
+
name: device.name,
|
|
122
|
+
state: device.state,
|
|
123
|
+
runtime: runtimeVersion,
|
|
124
|
+
deviceType: device.deviceTypeIdentifier,
|
|
125
|
+
isAvailable: device.isAvailable
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (simulators.length === 0) {
|
|
130
|
+
return {
|
|
131
|
+
success: true,
|
|
132
|
+
result: onlyBooted
|
|
133
|
+
? "No booted iOS simulators. Start a simulator first."
|
|
134
|
+
: "No available iOS simulators found.",
|
|
135
|
+
simulators: []
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
// Sort: Booted first, then by name
|
|
139
|
+
simulators.sort((a, b) => {
|
|
140
|
+
if (a.state === "Booted" && b.state !== "Booted")
|
|
141
|
+
return -1;
|
|
142
|
+
if (a.state !== "Booted" && b.state === "Booted")
|
|
143
|
+
return 1;
|
|
144
|
+
return a.name.localeCompare(b.name);
|
|
145
|
+
});
|
|
146
|
+
const formatted = simulators
|
|
147
|
+
.map((s) => {
|
|
148
|
+
const status = s.state === "Booted" ? "🟢 Booted" : "⚪ Shutdown";
|
|
149
|
+
return `${s.name} (${s.runtime}) - ${status}\n UDID: ${s.udid}`;
|
|
150
|
+
})
|
|
151
|
+
.join("\n\n");
|
|
152
|
+
return {
|
|
153
|
+
success: true,
|
|
154
|
+
result: `iOS Simulators:\n\n${formatted}`,
|
|
155
|
+
simulators
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
return {
|
|
160
|
+
success: false,
|
|
161
|
+
error: `Failed to list simulators: ${error instanceof Error ? error.message : String(error)}`
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Get the booted simulator UDID
|
|
167
|
+
*/
|
|
168
|
+
export async function getBootedSimulatorUdid() {
|
|
169
|
+
try {
|
|
170
|
+
const { stdout } = await execAsync("xcrun simctl list devices booted -j", {
|
|
171
|
+
timeout: SIMCTL_TIMEOUT
|
|
172
|
+
});
|
|
173
|
+
const data = JSON.parse(stdout);
|
|
174
|
+
for (const devices of Object.values(data.devices)) {
|
|
175
|
+
if (!Array.isArray(devices))
|
|
176
|
+
continue;
|
|
177
|
+
for (const device of devices) {
|
|
178
|
+
if (device.state === "Booted") {
|
|
179
|
+
return device.udid;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Find a booted simulator's UDID by its device name
|
|
191
|
+
* Matches Metro's deviceName against simulator names from simctl
|
|
192
|
+
*/
|
|
193
|
+
export async function findSimulatorByName(deviceName) {
|
|
194
|
+
try {
|
|
195
|
+
const { stdout } = await execAsync("xcrun simctl list devices booted -j", {
|
|
196
|
+
timeout: SIMCTL_TIMEOUT
|
|
197
|
+
});
|
|
198
|
+
const data = JSON.parse(stdout);
|
|
199
|
+
const normalizedDeviceName = deviceName.toLowerCase().trim();
|
|
200
|
+
for (const devices of Object.values(data.devices)) {
|
|
201
|
+
if (!Array.isArray(devices))
|
|
202
|
+
continue;
|
|
203
|
+
for (const device of devices) {
|
|
204
|
+
if (device.state !== "Booted")
|
|
205
|
+
continue;
|
|
206
|
+
const normalizedSimName = device.name.toLowerCase().trim();
|
|
207
|
+
// Exact match
|
|
208
|
+
if (normalizedSimName === normalizedDeviceName) {
|
|
209
|
+
return device.udid;
|
|
210
|
+
}
|
|
211
|
+
// Partial match (deviceName contains simulator name or vice versa)
|
|
212
|
+
if (normalizedSimName.includes(normalizedDeviceName) ||
|
|
213
|
+
normalizedDeviceName.includes(normalizedSimName)) {
|
|
214
|
+
return device.udid;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Get the active simulator UDID (Metro-connected) or fall back to first booted simulator
|
|
226
|
+
* This enables automatic device scoping based on Metro connection
|
|
227
|
+
*/
|
|
228
|
+
export async function getActiveOrBootedSimulatorUdid() {
|
|
229
|
+
// First, check if there's an active Metro-connected simulator
|
|
230
|
+
const activeUdid = getActiveSimulatorUdid();
|
|
231
|
+
if (activeUdid) {
|
|
232
|
+
return activeUdid;
|
|
233
|
+
}
|
|
234
|
+
// Fall back to first booted simulator
|
|
235
|
+
return getBootedSimulatorUdid();
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Build device selector for simctl command
|
|
239
|
+
*/
|
|
240
|
+
function buildDeviceArg(udid) {
|
|
241
|
+
return udid || "booted";
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Take a screenshot from an iOS simulator
|
|
245
|
+
*/
|
|
246
|
+
export async function iosScreenshot(outputPath, udid) {
|
|
247
|
+
try {
|
|
248
|
+
const simctlAvailable = await isSimctlAvailable();
|
|
249
|
+
if (!simctlAvailable) {
|
|
250
|
+
return {
|
|
251
|
+
success: false,
|
|
252
|
+
error: "Xcode command line tools not available. Install Xcode from the App Store."
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
// Resolve target UDID (prefer Metro-connected simulator)
|
|
256
|
+
const targetUdid = udid || (await getActiveOrBootedSimulatorUdid());
|
|
257
|
+
if (!targetUdid) {
|
|
258
|
+
return {
|
|
259
|
+
success: false,
|
|
260
|
+
error: "No iOS simulator is currently running. Start a simulator first."
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
// Generate output path if not provided
|
|
264
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
265
|
+
const finalOutputPath = outputPath || path.join(os.tmpdir(), `ios-screenshot-${timestamp}.png`);
|
|
266
|
+
await execAsync(`xcrun simctl io ${targetUdid} screenshot "${finalOutputPath}"`, {
|
|
267
|
+
timeout: SIMCTL_TIMEOUT
|
|
268
|
+
});
|
|
269
|
+
// Resize image if needed (API limit: 2000px max for multi-image requests)
|
|
270
|
+
// Return scale factor so AI can convert image coords to device coords
|
|
271
|
+
const MAX_DIMENSION = 2000;
|
|
272
|
+
const image = sharp(finalOutputPath);
|
|
273
|
+
const metadata = await image.metadata();
|
|
274
|
+
const originalWidth = metadata.width || 0;
|
|
275
|
+
const originalHeight = metadata.height || 0;
|
|
276
|
+
let imageData;
|
|
277
|
+
let scaleFactor = 1;
|
|
278
|
+
if (originalWidth > MAX_DIMENSION || originalHeight > MAX_DIMENSION) {
|
|
279
|
+
// Calculate scale to fit within MAX_DIMENSION
|
|
280
|
+
scaleFactor = Math.max(originalWidth, originalHeight) / MAX_DIMENSION;
|
|
281
|
+
imageData = await image
|
|
282
|
+
.resize(MAX_DIMENSION, MAX_DIMENSION, {
|
|
283
|
+
fit: "inside",
|
|
284
|
+
withoutEnlargement: true
|
|
285
|
+
})
|
|
286
|
+
.jpeg({ quality: 85 })
|
|
287
|
+
.toBuffer();
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
imageData = await image
|
|
291
|
+
.jpeg({ quality: 85 })
|
|
292
|
+
.toBuffer();
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
success: true,
|
|
296
|
+
result: finalOutputPath,
|
|
297
|
+
data: imageData,
|
|
298
|
+
scaleFactor,
|
|
299
|
+
originalWidth,
|
|
300
|
+
originalHeight
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
return {
|
|
305
|
+
success: false,
|
|
306
|
+
error: `Failed to capture screenshot: ${error instanceof Error ? error.message : String(error)}`
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Install an app on an iOS simulator
|
|
312
|
+
*/
|
|
313
|
+
export async function iosInstallApp(appPath, udid) {
|
|
314
|
+
try {
|
|
315
|
+
const simctlAvailable = await isSimctlAvailable();
|
|
316
|
+
if (!simctlAvailable) {
|
|
317
|
+
return {
|
|
318
|
+
success: false,
|
|
319
|
+
error: "Xcode command line tools not available. Install Xcode from the App Store."
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
// Verify app exists
|
|
323
|
+
if (!existsSync(appPath)) {
|
|
324
|
+
return {
|
|
325
|
+
success: false,
|
|
326
|
+
error: `App bundle not found: ${appPath}`
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
// Resolve target UDID (prefer Metro-connected simulator)
|
|
330
|
+
const targetUdid = udid || (await getActiveOrBootedSimulatorUdid());
|
|
331
|
+
if (!targetUdid) {
|
|
332
|
+
return {
|
|
333
|
+
success: false,
|
|
334
|
+
error: "No iOS simulator is currently running. Start a simulator first."
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
await execAsync(`xcrun simctl install ${targetUdid} "${appPath}"`, {
|
|
338
|
+
timeout: 120000 // 2 minute timeout for install
|
|
339
|
+
});
|
|
340
|
+
return {
|
|
341
|
+
success: true,
|
|
342
|
+
result: `Successfully installed ${path.basename(appPath)}`
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
return {
|
|
347
|
+
success: false,
|
|
348
|
+
error: `Failed to install app: ${error instanceof Error ? error.message : String(error)}`
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Launch an app on an iOS simulator
|
|
354
|
+
*/
|
|
355
|
+
export async function iosLaunchApp(bundleId, udid) {
|
|
356
|
+
try {
|
|
357
|
+
const simctlAvailable = await isSimctlAvailable();
|
|
358
|
+
if (!simctlAvailable) {
|
|
359
|
+
return {
|
|
360
|
+
success: false,
|
|
361
|
+
error: "Xcode command line tools not available. Install Xcode from the App Store."
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
// Resolve target UDID (prefer Metro-connected simulator)
|
|
365
|
+
const targetUdid = udid || (await getActiveOrBootedSimulatorUdid());
|
|
366
|
+
if (!targetUdid) {
|
|
367
|
+
return {
|
|
368
|
+
success: false,
|
|
369
|
+
error: "No iOS simulator is currently running. Start a simulator first."
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
await execAsync(`xcrun simctl launch ${targetUdid} ${bundleId}`, {
|
|
373
|
+
timeout: SIMCTL_TIMEOUT
|
|
374
|
+
});
|
|
375
|
+
return {
|
|
376
|
+
success: true,
|
|
377
|
+
result: `Launched ${bundleId}`
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
return {
|
|
382
|
+
success: false,
|
|
383
|
+
error: `Failed to launch app: ${error instanceof Error ? error.message : String(error)}`
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Open a URL in the iOS simulator
|
|
389
|
+
*/
|
|
390
|
+
export async function iosOpenUrl(url, udid) {
|
|
391
|
+
try {
|
|
392
|
+
const simctlAvailable = await isSimctlAvailable();
|
|
393
|
+
if (!simctlAvailable) {
|
|
394
|
+
return {
|
|
395
|
+
success: false,
|
|
396
|
+
error: "Xcode command line tools not available. Install Xcode from the App Store."
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
// Resolve target UDID (prefer Metro-connected simulator)
|
|
400
|
+
const targetUdid = udid || (await getActiveOrBootedSimulatorUdid());
|
|
401
|
+
if (!targetUdid) {
|
|
402
|
+
return {
|
|
403
|
+
success: false,
|
|
404
|
+
error: "No iOS simulator is currently running. Start a simulator first."
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
await execAsync(`xcrun simctl openurl ${targetUdid} "${url}"`, {
|
|
408
|
+
timeout: SIMCTL_TIMEOUT
|
|
409
|
+
});
|
|
410
|
+
return {
|
|
411
|
+
success: true,
|
|
412
|
+
result: `Opened URL: ${url}`
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
return {
|
|
417
|
+
success: false,
|
|
418
|
+
error: `Failed to open URL: ${error instanceof Error ? error.message : String(error)}`
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Terminate an app on an iOS simulator
|
|
424
|
+
*/
|
|
425
|
+
export async function iosTerminateApp(bundleId, udid) {
|
|
426
|
+
try {
|
|
427
|
+
const simctlAvailable = await isSimctlAvailable();
|
|
428
|
+
if (!simctlAvailable) {
|
|
429
|
+
return {
|
|
430
|
+
success: false,
|
|
431
|
+
error: "Xcode command line tools not available. Install Xcode from the App Store."
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
// Resolve target UDID (prefer Metro-connected simulator)
|
|
435
|
+
const targetUdid = udid || (await getActiveOrBootedSimulatorUdid());
|
|
436
|
+
if (!targetUdid) {
|
|
437
|
+
return {
|
|
438
|
+
success: false,
|
|
439
|
+
error: "No iOS simulator is currently running. Start a simulator first."
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
await execAsync(`xcrun simctl terminate ${targetUdid} ${bundleId}`, {
|
|
443
|
+
timeout: SIMCTL_TIMEOUT
|
|
444
|
+
});
|
|
445
|
+
return {
|
|
446
|
+
success: true,
|
|
447
|
+
result: `Terminated ${bundleId}`
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
return {
|
|
452
|
+
success: false,
|
|
453
|
+
error: `Failed to terminate app: ${error instanceof Error ? error.message : String(error)}`
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Boot an iOS simulator
|
|
459
|
+
*/
|
|
460
|
+
export async function iosBootSimulator(udid) {
|
|
461
|
+
try {
|
|
462
|
+
const simctlAvailable = await isSimctlAvailable();
|
|
463
|
+
if (!simctlAvailable) {
|
|
464
|
+
return {
|
|
465
|
+
success: false,
|
|
466
|
+
error: "Xcode command line tools not available. Install Xcode from the App Store."
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
await execAsync(`xcrun simctl boot ${udid}`, {
|
|
470
|
+
timeout: 60000 // 1 minute timeout for boot
|
|
471
|
+
});
|
|
472
|
+
// Open Simulator app
|
|
473
|
+
await execAsync("open -a Simulator", { timeout: 10000 }).catch(() => {
|
|
474
|
+
// Ignore if Simulator app doesn't open
|
|
475
|
+
});
|
|
476
|
+
return {
|
|
477
|
+
success: true,
|
|
478
|
+
result: `Simulator ${udid} is now booting`
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
483
|
+
// Already booted is not an error
|
|
484
|
+
if (errorMessage.includes("Unable to boot device in current state: Booted")) {
|
|
485
|
+
return {
|
|
486
|
+
success: true,
|
|
487
|
+
result: "Simulator is already booted"
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
success: false,
|
|
492
|
+
error: `Failed to boot simulator: ${errorMessage}`
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// ============================================================================
|
|
497
|
+
// IDB-Based UI Interaction Tools
|
|
498
|
+
// These tools require Facebook IDB (iOS Development Bridge) to be installed
|
|
499
|
+
// Install with: brew install idb-companion
|
|
500
|
+
// ============================================================================
|
|
501
|
+
/**
|
|
502
|
+
* Tap at coordinates on an iOS simulator using IDB
|
|
503
|
+
*/
|
|
504
|
+
export async function iosTap(x, y, options) {
|
|
505
|
+
try {
|
|
506
|
+
const idbAvailable = await isIdbAvailable();
|
|
507
|
+
if (!idbAvailable) {
|
|
508
|
+
return {
|
|
509
|
+
success: false,
|
|
510
|
+
error: "IDB is not installed. Install with: brew install idb-companion"
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
// Get simulator UDID (prefer Metro-connected, then fall back to booted)
|
|
514
|
+
const targetUdid = options?.udid || (await getActiveOrBootedSimulatorUdid());
|
|
515
|
+
if (!targetUdid) {
|
|
516
|
+
return {
|
|
517
|
+
success: false,
|
|
518
|
+
error: "No iOS simulator is currently running. Start a simulator first."
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
// Ensure IDB is connected to the simulator
|
|
522
|
+
const connectResult = await ensureIdbConnected(targetUdid);
|
|
523
|
+
if (!connectResult.success) {
|
|
524
|
+
return { success: false, error: connectResult.error };
|
|
525
|
+
}
|
|
526
|
+
const xRounded = Math.round(x);
|
|
527
|
+
const yRounded = Math.round(y);
|
|
528
|
+
// Build args array for execFile (no shell)
|
|
529
|
+
const args = ["ui", "tap", "--udid", targetUdid];
|
|
530
|
+
if (options?.duration !== undefined) {
|
|
531
|
+
args.push("--duration", String(options.duration));
|
|
532
|
+
}
|
|
533
|
+
args.push("--json", "--", String(xRounded), String(yRounded));
|
|
534
|
+
const { stderr } = await runIdb(...args);
|
|
535
|
+
if (stderr)
|
|
536
|
+
throw new Error(stderr);
|
|
537
|
+
return {
|
|
538
|
+
success: true,
|
|
539
|
+
result: `Tapped at (${xRounded}, ${yRounded})`
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
catch (error) {
|
|
543
|
+
return {
|
|
544
|
+
success: false,
|
|
545
|
+
error: `Failed to tap: ${error instanceof Error ? error.message : String(error)}`
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Swipe gesture on an iOS simulator using IDB
|
|
551
|
+
*/
|
|
552
|
+
export async function iosSwipe(startX, startY, endX, endY, options) {
|
|
553
|
+
try {
|
|
554
|
+
const idbAvailable = await isIdbAvailable();
|
|
555
|
+
if (!idbAvailable) {
|
|
556
|
+
return {
|
|
557
|
+
success: false,
|
|
558
|
+
error: "IDB is not installed. Install with: brew install idb-companion"
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
const targetUdid = options?.udid || (await getActiveOrBootedSimulatorUdid());
|
|
562
|
+
if (!targetUdid) {
|
|
563
|
+
return {
|
|
564
|
+
success: false,
|
|
565
|
+
error: "No iOS simulator is currently running. Start a simulator first."
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
// Ensure IDB is connected to the simulator
|
|
569
|
+
const connectResult = await ensureIdbConnected(targetUdid);
|
|
570
|
+
if (!connectResult.success) {
|
|
571
|
+
return { success: false, error: connectResult.error };
|
|
572
|
+
}
|
|
573
|
+
const x1 = Math.round(startX);
|
|
574
|
+
const y1 = Math.round(startY);
|
|
575
|
+
const x2 = Math.round(endX);
|
|
576
|
+
const y2 = Math.round(endY);
|
|
577
|
+
// Build args array for execFile (no shell)
|
|
578
|
+
const args = ["ui", "swipe", "--udid", targetUdid];
|
|
579
|
+
if (options?.duration !== undefined) {
|
|
580
|
+
args.push("--duration", String(options.duration));
|
|
581
|
+
}
|
|
582
|
+
if (options?.delta !== undefined) {
|
|
583
|
+
args.push("--delta", String(options.delta));
|
|
584
|
+
}
|
|
585
|
+
args.push("--json", "--", String(x1), String(y1), String(x2), String(y2));
|
|
586
|
+
const { stderr } = await runIdb(...args);
|
|
587
|
+
if (stderr)
|
|
588
|
+
throw new Error(stderr);
|
|
589
|
+
return {
|
|
590
|
+
success: true,
|
|
591
|
+
result: `Swiped from (${x1}, ${y1}) to (${x2}, ${y2})`
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
return {
|
|
596
|
+
success: false,
|
|
597
|
+
error: `Failed to swipe: ${error instanceof Error ? error.message : String(error)}`
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Input text into the active field on an iOS simulator using IDB
|
|
603
|
+
*/
|
|
604
|
+
export async function iosInputText(text, udid) {
|
|
605
|
+
try {
|
|
606
|
+
const idbAvailable = await isIdbAvailable();
|
|
607
|
+
if (!idbAvailable) {
|
|
608
|
+
return {
|
|
609
|
+
success: false,
|
|
610
|
+
error: "IDB is not installed. Install with: brew install idb-companion"
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
const targetUdid = udid || (await getActiveOrBootedSimulatorUdid());
|
|
614
|
+
if (!targetUdid) {
|
|
615
|
+
return {
|
|
616
|
+
success: false,
|
|
617
|
+
error: "No iOS simulator is currently running. Start a simulator first."
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
// Ensure IDB is connected to the simulator
|
|
621
|
+
const connectResult = await ensureIdbConnected(targetUdid);
|
|
622
|
+
if (!connectResult.success) {
|
|
623
|
+
return { success: false, error: connectResult.error };
|
|
624
|
+
}
|
|
625
|
+
// Use execFile with args array (no shell escaping needed)
|
|
626
|
+
const { stderr } = await runIdb("ui", "text", "--udid", targetUdid, text);
|
|
627
|
+
if (stderr)
|
|
628
|
+
throw new Error(stderr);
|
|
629
|
+
return {
|
|
630
|
+
success: true,
|
|
631
|
+
result: `Typed text: "${text}"`
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
catch (error) {
|
|
635
|
+
return {
|
|
636
|
+
success: false,
|
|
637
|
+
error: `Failed to input text: ${error instanceof Error ? error.message : String(error)}`
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Press a hardware button on an iOS simulator using IDB
|
|
643
|
+
*/
|
|
644
|
+
export async function iosButton(button, options) {
|
|
645
|
+
try {
|
|
646
|
+
const idbAvailable = await isIdbAvailable();
|
|
647
|
+
if (!idbAvailable) {
|
|
648
|
+
return {
|
|
649
|
+
success: false,
|
|
650
|
+
error: "IDB is not installed. Install with: brew install idb-companion"
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
// Validate button type
|
|
654
|
+
if (!IOS_BUTTON_TYPES.includes(button)) {
|
|
655
|
+
return {
|
|
656
|
+
success: false,
|
|
657
|
+
error: `Invalid button type: ${button}. Valid options: ${IOS_BUTTON_TYPES.join(", ")}`
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
const targetUdid = options?.udid || (await getActiveOrBootedSimulatorUdid());
|
|
661
|
+
if (!targetUdid) {
|
|
662
|
+
return {
|
|
663
|
+
success: false,
|
|
664
|
+
error: "No iOS simulator is currently running. Start a simulator first."
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
// Ensure IDB is connected to the simulator
|
|
668
|
+
const connectResult = await ensureIdbConnected(targetUdid);
|
|
669
|
+
if (!connectResult.success) {
|
|
670
|
+
return { success: false, error: connectResult.error };
|
|
671
|
+
}
|
|
672
|
+
// Build args array for execFile (no shell)
|
|
673
|
+
const args = ["ui", "button", "--udid", targetUdid];
|
|
674
|
+
if (options?.duration !== undefined) {
|
|
675
|
+
args.push("--duration", String(options.duration));
|
|
676
|
+
}
|
|
677
|
+
args.push(button);
|
|
678
|
+
const { stderr } = await runIdb(...args);
|
|
679
|
+
if (stderr)
|
|
680
|
+
throw new Error(stderr);
|
|
681
|
+
return {
|
|
682
|
+
success: true,
|
|
683
|
+
result: `Pressed ${button} button`
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
catch (error) {
|
|
687
|
+
return {
|
|
688
|
+
success: false,
|
|
689
|
+
error: `Failed to press button: ${error instanceof Error ? error.message : String(error)}`
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Send a key event to an iOS simulator using IDB
|
|
695
|
+
*/
|
|
696
|
+
export async function iosKeyEvent(keycode, options) {
|
|
697
|
+
try {
|
|
698
|
+
const idbAvailable = await isIdbAvailable();
|
|
699
|
+
if (!idbAvailable) {
|
|
700
|
+
return {
|
|
701
|
+
success: false,
|
|
702
|
+
error: "IDB is not installed. Install with: brew install idb-companion"
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
const targetUdid = options?.udid || (await getActiveOrBootedSimulatorUdid());
|
|
706
|
+
if (!targetUdid) {
|
|
707
|
+
return {
|
|
708
|
+
success: false,
|
|
709
|
+
error: "No iOS simulator is currently running. Start a simulator first."
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
// Ensure IDB is connected to the simulator
|
|
713
|
+
const connectResult = await ensureIdbConnected(targetUdid);
|
|
714
|
+
if (!connectResult.success) {
|
|
715
|
+
return { success: false, error: connectResult.error };
|
|
716
|
+
}
|
|
717
|
+
// Build args array for execFile (no shell)
|
|
718
|
+
const args = ["ui", "key", "--udid", targetUdid];
|
|
719
|
+
if (options?.duration !== undefined) {
|
|
720
|
+
args.push("--duration", String(options.duration));
|
|
721
|
+
}
|
|
722
|
+
args.push(String(keycode));
|
|
723
|
+
const { stderr } = await runIdb(...args);
|
|
724
|
+
if (stderr)
|
|
725
|
+
throw new Error(stderr);
|
|
726
|
+
return {
|
|
727
|
+
success: true,
|
|
728
|
+
result: `Sent key event: ${keycode}`
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
catch (error) {
|
|
732
|
+
return {
|
|
733
|
+
success: false,
|
|
734
|
+
error: `Failed to send key event: ${error instanceof Error ? error.message : String(error)}`
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Send a sequence of key events to an iOS simulator using IDB
|
|
740
|
+
*/
|
|
741
|
+
export async function iosKeySequence(keycodes, udid) {
|
|
742
|
+
try {
|
|
743
|
+
const idbAvailable = await isIdbAvailable();
|
|
744
|
+
if (!idbAvailable) {
|
|
745
|
+
return {
|
|
746
|
+
success: false,
|
|
747
|
+
error: "IDB is not installed. Install with: brew install idb-companion"
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
if (!keycodes || keycodes.length === 0) {
|
|
751
|
+
return {
|
|
752
|
+
success: false,
|
|
753
|
+
error: "At least one keycode is required"
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
const targetUdid = udid || (await getActiveOrBootedSimulatorUdid());
|
|
757
|
+
if (!targetUdid) {
|
|
758
|
+
return {
|
|
759
|
+
success: false,
|
|
760
|
+
error: "No iOS simulator is currently running. Start a simulator first."
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
// Ensure IDB is connected to the simulator
|
|
764
|
+
const connectResult = await ensureIdbConnected(targetUdid);
|
|
765
|
+
if (!connectResult.success) {
|
|
766
|
+
return { success: false, error: connectResult.error };
|
|
767
|
+
}
|
|
768
|
+
// Build args array for execFile (no shell)
|
|
769
|
+
const args = ["ui", "key-sequence", "--udid", targetUdid, ...keycodes.map(String)];
|
|
770
|
+
const { stderr } = await runIdb(...args);
|
|
771
|
+
if (stderr)
|
|
772
|
+
throw new Error(stderr);
|
|
773
|
+
return {
|
|
774
|
+
success: true,
|
|
775
|
+
result: `Sent key sequence: ${keycodes.join(", ")}`
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
catch (error) {
|
|
779
|
+
return {
|
|
780
|
+
success: false,
|
|
781
|
+
error: `Failed to send key sequence: ${error instanceof Error ? error.message : String(error)}`
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Format accessibility tree for human-readable output
|
|
787
|
+
*/
|
|
788
|
+
function formatAccessibilityTree(elements, depth = 0) {
|
|
789
|
+
const lines = [];
|
|
790
|
+
const indent = " ".repeat(depth);
|
|
791
|
+
for (const element of elements) {
|
|
792
|
+
const parts = [];
|
|
793
|
+
if (element.type)
|
|
794
|
+
parts.push(`[${element.type}]`);
|
|
795
|
+
if (element.AXLabel)
|
|
796
|
+
parts.push(`"${element.AXLabel}"`);
|
|
797
|
+
if (element.AXValue)
|
|
798
|
+
parts.push(`value="${element.AXValue}"`);
|
|
799
|
+
if (element.frame) {
|
|
800
|
+
const f = element.frame;
|
|
801
|
+
const centerX = Math.round(f.x + f.width / 2);
|
|
802
|
+
const centerY = Math.round(f.y + f.height / 2);
|
|
803
|
+
parts.push(`frame=(${f.x}, ${f.y}, ${f.width}x${f.height}) tap=(${centerX}, ${centerY})`);
|
|
804
|
+
}
|
|
805
|
+
if (parts.length > 0) {
|
|
806
|
+
lines.push(`${indent}${parts.join(" ")}`);
|
|
807
|
+
}
|
|
808
|
+
if (element.children && element.children.length > 0) {
|
|
809
|
+
lines.push(formatAccessibilityTree(element.children, depth + 1));
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return lines.join("\n");
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Get accessibility info for the entire screen using IDB
|
|
816
|
+
*/
|
|
817
|
+
export async function iosDescribeAll(udid) {
|
|
818
|
+
try {
|
|
819
|
+
const idbAvailable = await isIdbAvailable();
|
|
820
|
+
if (!idbAvailable) {
|
|
821
|
+
return {
|
|
822
|
+
success: false,
|
|
823
|
+
error: "IDB is not installed. Install with: brew install idb-companion"
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
const targetUdid = udid || (await getActiveOrBootedSimulatorUdid());
|
|
827
|
+
if (!targetUdid) {
|
|
828
|
+
return {
|
|
829
|
+
success: false,
|
|
830
|
+
error: "No iOS simulator is currently running. Start a simulator first."
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
// Ensure IDB is connected to the simulator
|
|
834
|
+
const connectResult = await ensureIdbConnected(targetUdid);
|
|
835
|
+
if (!connectResult.success) {
|
|
836
|
+
return { success: false, error: connectResult.error };
|
|
837
|
+
}
|
|
838
|
+
// Use execFile with args array (no shell)
|
|
839
|
+
const { stdout, stderr } = await runIdb("ui", "describe-all", "--udid", targetUdid, "--json", "--nested");
|
|
840
|
+
if (stderr)
|
|
841
|
+
throw new Error(stderr);
|
|
842
|
+
// Parse JSON response
|
|
843
|
+
const elements = JSON.parse(stdout);
|
|
844
|
+
// Format for human-readable output
|
|
845
|
+
const formatted = formatAccessibilityTree(elements);
|
|
846
|
+
return {
|
|
847
|
+
success: true,
|
|
848
|
+
result: formatted || "No accessibility elements found",
|
|
849
|
+
elements
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
catch (error) {
|
|
853
|
+
return {
|
|
854
|
+
success: false,
|
|
855
|
+
error: `Failed to describe screen: ${error instanceof Error ? error.message : String(error)}`
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* Get accessibility info at a specific point using IDB
|
|
861
|
+
*/
|
|
862
|
+
export async function iosDescribePoint(x, y, udid) {
|
|
863
|
+
try {
|
|
864
|
+
const idbAvailable = await isIdbAvailable();
|
|
865
|
+
if (!idbAvailable) {
|
|
866
|
+
return {
|
|
867
|
+
success: false,
|
|
868
|
+
error: "IDB is not installed. Install with: brew install idb-companion"
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
const targetUdid = udid || (await getActiveOrBootedSimulatorUdid());
|
|
872
|
+
if (!targetUdid) {
|
|
873
|
+
return {
|
|
874
|
+
success: false,
|
|
875
|
+
error: "No iOS simulator is currently running. Start a simulator first."
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
// Ensure IDB is connected to the simulator
|
|
879
|
+
const connectResult = await ensureIdbConnected(targetUdid);
|
|
880
|
+
if (!connectResult.success) {
|
|
881
|
+
return { success: false, error: connectResult.error };
|
|
882
|
+
}
|
|
883
|
+
const xRounded = Math.round(x);
|
|
884
|
+
const yRounded = Math.round(y);
|
|
885
|
+
// Use execFile with args array (no shell)
|
|
886
|
+
const { stdout, stderr } = await runIdb("ui", "describe-point", "--udid", targetUdid, "--json", "--", String(xRounded), String(yRounded));
|
|
887
|
+
if (stderr)
|
|
888
|
+
throw new Error(stderr);
|
|
889
|
+
// Parse JSON response - may be single element or array
|
|
890
|
+
let element;
|
|
891
|
+
try {
|
|
892
|
+
const parsed = JSON.parse(stdout);
|
|
893
|
+
element = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
894
|
+
}
|
|
895
|
+
catch {
|
|
896
|
+
return {
|
|
897
|
+
success: true,
|
|
898
|
+
result: `No accessibility element found at (${xRounded}, ${yRounded})`,
|
|
899
|
+
elements: []
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
// Format for human-readable output
|
|
903
|
+
const parts = [];
|
|
904
|
+
if (element.type)
|
|
905
|
+
parts.push(`Type: ${element.type}`);
|
|
906
|
+
if (element.AXLabel)
|
|
907
|
+
parts.push(`Label: "${element.AXLabel}"`);
|
|
908
|
+
if (element.AXValue)
|
|
909
|
+
parts.push(`Value: "${element.AXValue}"`);
|
|
910
|
+
if (element.frame) {
|
|
911
|
+
const f = element.frame;
|
|
912
|
+
const centerX = Math.round(f.x + f.width / 2);
|
|
913
|
+
const centerY = Math.round(f.y + f.height / 2);
|
|
914
|
+
parts.push(`Frame: (${f.x}, ${f.y}) ${f.width}x${f.height}`);
|
|
915
|
+
parts.push(`Tap: (${centerX}, ${centerY})`);
|
|
916
|
+
}
|
|
917
|
+
return {
|
|
918
|
+
success: true,
|
|
919
|
+
result: parts.length > 0
|
|
920
|
+
? `Element at (${xRounded}, ${yRounded}):\n${parts.join("\n")}`
|
|
921
|
+
: `No accessibility element found at (${xRounded}, ${yRounded})`,
|
|
922
|
+
elements: element ? [element] : []
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
catch (error) {
|
|
926
|
+
return {
|
|
927
|
+
success: false,
|
|
928
|
+
error: `Failed to describe point: ${error instanceof Error ? error.message : String(error)}`
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Helper to flatten nested accessibility elements
|
|
934
|
+
*/
|
|
935
|
+
function flattenElements(elements) {
|
|
936
|
+
const result = [];
|
|
937
|
+
for (const el of elements) {
|
|
938
|
+
result.push(el);
|
|
939
|
+
if (el.children && el.children.length > 0) {
|
|
940
|
+
result.push(...flattenElements(el.children));
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
return result;
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* Tap an element by its accessibility label using IDB
|
|
947
|
+
* This simplifies the workflow: no need to manually find coordinates
|
|
948
|
+
*/
|
|
949
|
+
export async function iosTapElement(options) {
|
|
950
|
+
try {
|
|
951
|
+
const { label, labelContains, index = 0, duration, udid } = options;
|
|
952
|
+
if (!label && !labelContains) {
|
|
953
|
+
return {
|
|
954
|
+
success: false,
|
|
955
|
+
error: "Either 'label' (exact match) or 'labelContains' (partial match) is required"
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
// Get all accessibility elements
|
|
959
|
+
const describeResult = await iosDescribeAll(udid);
|
|
960
|
+
if (!describeResult.success || !describeResult.elements) {
|
|
961
|
+
return {
|
|
962
|
+
success: false,
|
|
963
|
+
error: describeResult.error || "Failed to get accessibility elements"
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
// Flatten the tree and find matching elements
|
|
967
|
+
const allElements = flattenElements(describeResult.elements);
|
|
968
|
+
const matches = allElements.filter(el => {
|
|
969
|
+
if (!el.AXLabel)
|
|
970
|
+
return false;
|
|
971
|
+
if (label)
|
|
972
|
+
return el.AXLabel === label;
|
|
973
|
+
if (labelContains)
|
|
974
|
+
return el.AXLabel.toLowerCase().includes(labelContains.toLowerCase());
|
|
975
|
+
return false;
|
|
976
|
+
});
|
|
977
|
+
if (matches.length === 0) {
|
|
978
|
+
const searchTerm = label ? `label="${label}"` : `labelContains="${labelContains}"`;
|
|
979
|
+
return {
|
|
980
|
+
success: false,
|
|
981
|
+
error: `No element found with ${searchTerm}`
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
// Select element by index (default 0 = first match)
|
|
985
|
+
if (index >= matches.length) {
|
|
986
|
+
return {
|
|
987
|
+
success: false,
|
|
988
|
+
error: `Index ${index} out of range. Found ${matches.length} matching element(s).`
|
|
989
|
+
};
|
|
990
|
+
}
|
|
991
|
+
const element = matches[index];
|
|
992
|
+
// Check if element has frame coordinates
|
|
993
|
+
if (!element.frame) {
|
|
994
|
+
return {
|
|
995
|
+
success: false,
|
|
996
|
+
error: `Element "${element.AXLabel}" has no frame coordinates`
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
// Calculate center
|
|
1000
|
+
const centerX = Math.round(element.frame.x + element.frame.width / 2);
|
|
1001
|
+
const centerY = Math.round(element.frame.y + element.frame.height / 2);
|
|
1002
|
+
// Tap at center
|
|
1003
|
+
const tapResult = await iosTap(centerX, centerY, { duration, udid });
|
|
1004
|
+
if (tapResult.success) {
|
|
1005
|
+
return {
|
|
1006
|
+
success: true,
|
|
1007
|
+
result: `Tapped "${element.AXLabel}" at (${centerX}, ${centerY})`
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
return tapResult;
|
|
1011
|
+
}
|
|
1012
|
+
catch (error) {
|
|
1013
|
+
return {
|
|
1014
|
+
success: false,
|
|
1015
|
+
error: `Failed to tap element: ${error instanceof Error ? error.message : String(error)}`
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Parse IDB accessibility output into simplified element array
|
|
1021
|
+
*/
|
|
1022
|
+
function parseIdbAccessibilityForFindElement(output) {
|
|
1023
|
+
const elements = [];
|
|
1024
|
+
try {
|
|
1025
|
+
const data = JSON.parse(output);
|
|
1026
|
+
const extractElements = (node) => {
|
|
1027
|
+
const frame = node.frame;
|
|
1028
|
+
if (frame) {
|
|
1029
|
+
const element = {
|
|
1030
|
+
label: node.AXLabel || node.label || "",
|
|
1031
|
+
value: node.AXValue || node.value || "",
|
|
1032
|
+
type: node.type || node.AXType || "",
|
|
1033
|
+
frame: {
|
|
1034
|
+
x: frame.x || 0,
|
|
1035
|
+
y: frame.y || 0,
|
|
1036
|
+
width: frame.width || 0,
|
|
1037
|
+
height: frame.height || 0
|
|
1038
|
+
},
|
|
1039
|
+
center: {
|
|
1040
|
+
x: Math.round((frame.x || 0) + (frame.width || 0) / 2),
|
|
1041
|
+
y: Math.round((frame.y || 0) + (frame.height || 0) / 2)
|
|
1042
|
+
},
|
|
1043
|
+
enabled: node.enabled !== false,
|
|
1044
|
+
traits: node.traits || []
|
|
1045
|
+
};
|
|
1046
|
+
if (element.label || element.value || element.type) {
|
|
1047
|
+
elements.push(element);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
const children = node.children;
|
|
1051
|
+
if (children && Array.isArray(children)) {
|
|
1052
|
+
for (const child of children) {
|
|
1053
|
+
extractElements(child);
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
if (Array.isArray(data)) {
|
|
1058
|
+
for (const item of data) {
|
|
1059
|
+
extractElements(item);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
else {
|
|
1063
|
+
extractElements(data);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
catch {
|
|
1067
|
+
// If JSON parsing fails, return empty array
|
|
1068
|
+
}
|
|
1069
|
+
return elements;
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Match iOS element against find options
|
|
1073
|
+
*/
|
|
1074
|
+
function matchesIOSFindElement(element, options) {
|
|
1075
|
+
if (options.label !== undefined) {
|
|
1076
|
+
if (element.label !== options.label)
|
|
1077
|
+
return false;
|
|
1078
|
+
}
|
|
1079
|
+
if (options.labelContains !== undefined) {
|
|
1080
|
+
if (!element.label.toLowerCase().includes(options.labelContains.toLowerCase()))
|
|
1081
|
+
return false;
|
|
1082
|
+
}
|
|
1083
|
+
if (options.value !== undefined) {
|
|
1084
|
+
if (element.value !== options.value)
|
|
1085
|
+
return false;
|
|
1086
|
+
}
|
|
1087
|
+
if (options.valueContains !== undefined) {
|
|
1088
|
+
if (!element.value.toLowerCase().includes(options.valueContains.toLowerCase()))
|
|
1089
|
+
return false;
|
|
1090
|
+
}
|
|
1091
|
+
if (options.type !== undefined) {
|
|
1092
|
+
if (!element.type.toLowerCase().includes(options.type.toLowerCase()))
|
|
1093
|
+
return false;
|
|
1094
|
+
}
|
|
1095
|
+
return true;
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Get UI accessibility tree from iOS simulator using IDB (for find_element)
|
|
1099
|
+
*/
|
|
1100
|
+
export async function iosGetUITree(udid) {
|
|
1101
|
+
try {
|
|
1102
|
+
const idbAvailable = await isIdbAvailable();
|
|
1103
|
+
if (!idbAvailable) {
|
|
1104
|
+
return {
|
|
1105
|
+
success: false,
|
|
1106
|
+
error: "IDB is not installed. Install with: brew install idb-companion"
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
const targetUdid = udid || (await getActiveOrBootedSimulatorUdid());
|
|
1110
|
+
if (!targetUdid) {
|
|
1111
|
+
return {
|
|
1112
|
+
success: false,
|
|
1113
|
+
error: "No iOS simulator is currently running. Start a simulator first."
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
const connectResult = await ensureIdbConnected(targetUdid);
|
|
1117
|
+
if (!connectResult.success) {
|
|
1118
|
+
return { success: false, error: connectResult.error };
|
|
1119
|
+
}
|
|
1120
|
+
const { stdout } = await runIdb("ui", "describe-all", "--udid", targetUdid);
|
|
1121
|
+
const elements = parseIdbAccessibilityForFindElement(stdout);
|
|
1122
|
+
return {
|
|
1123
|
+
success: true,
|
|
1124
|
+
elements,
|
|
1125
|
+
rawOutput: stdout
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
catch (error) {
|
|
1129
|
+
return {
|
|
1130
|
+
success: false,
|
|
1131
|
+
error: `Failed to get UI tree: ${error instanceof Error ? error.message : String(error)}`
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Find element(s) in the iOS UI tree matching the given criteria
|
|
1137
|
+
*/
|
|
1138
|
+
export async function iosFindElement(options, udid) {
|
|
1139
|
+
try {
|
|
1140
|
+
if (!options.label && !options.labelContains && !options.value &&
|
|
1141
|
+
!options.valueContains && !options.type) {
|
|
1142
|
+
return {
|
|
1143
|
+
success: false,
|
|
1144
|
+
found: false,
|
|
1145
|
+
error: "At least one search criteria (label, labelContains, value, valueContains, or type) must be provided"
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
const treeResult = await iosGetUITree(udid);
|
|
1149
|
+
if (!treeResult.success || !treeResult.elements) {
|
|
1150
|
+
return {
|
|
1151
|
+
success: false,
|
|
1152
|
+
found: false,
|
|
1153
|
+
error: treeResult.error
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
const matches = treeResult.elements.filter(el => matchesIOSFindElement(el, options));
|
|
1157
|
+
if (matches.length === 0) {
|
|
1158
|
+
return {
|
|
1159
|
+
success: true,
|
|
1160
|
+
found: false,
|
|
1161
|
+
matchCount: 0
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
const index = options.index ?? 0;
|
|
1165
|
+
const selectedElement = matches[index];
|
|
1166
|
+
if (!selectedElement) {
|
|
1167
|
+
return {
|
|
1168
|
+
success: true,
|
|
1169
|
+
found: false,
|
|
1170
|
+
matchCount: matches.length,
|
|
1171
|
+
error: `Index ${index} out of bounds. Found ${matches.length} matching element(s).`
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
return {
|
|
1175
|
+
success: true,
|
|
1176
|
+
found: true,
|
|
1177
|
+
element: selectedElement,
|
|
1178
|
+
allMatches: matches,
|
|
1179
|
+
matchCount: matches.length
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
catch (error) {
|
|
1183
|
+
return {
|
|
1184
|
+
success: false,
|
|
1185
|
+
found: false,
|
|
1186
|
+
error: `Failed to find element: ${error instanceof Error ? error.message : String(error)}`
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* Wait for element to appear on iOS screen with polling
|
|
1192
|
+
*/
|
|
1193
|
+
export async function iosWaitForElement(options, udid) {
|
|
1194
|
+
const timeoutMs = options.timeoutMs ?? 10000;
|
|
1195
|
+
const pollIntervalMs = options.pollIntervalMs ?? 500;
|
|
1196
|
+
const startTime = Date.now();
|
|
1197
|
+
if (!options.label && !options.labelContains && !options.value &&
|
|
1198
|
+
!options.valueContains && !options.type) {
|
|
1199
|
+
return {
|
|
1200
|
+
success: false,
|
|
1201
|
+
found: false,
|
|
1202
|
+
timedOut: false,
|
|
1203
|
+
error: "At least one search criteria (label, labelContains, value, valueContains, or type) must be provided"
|
|
1204
|
+
};
|
|
1205
|
+
}
|
|
1206
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
1207
|
+
const result = await iosFindElement(options, udid);
|
|
1208
|
+
if (result.found && result.element) {
|
|
1209
|
+
return {
|
|
1210
|
+
...result,
|
|
1211
|
+
elapsedMs: Date.now() - startTime,
|
|
1212
|
+
timedOut: false
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
if (!result.success) {
|
|
1216
|
+
return {
|
|
1217
|
+
...result,
|
|
1218
|
+
elapsedMs: Date.now() - startTime,
|
|
1219
|
+
timedOut: false
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
|
|
1223
|
+
}
|
|
1224
|
+
return {
|
|
1225
|
+
success: true,
|
|
1226
|
+
found: false,
|
|
1227
|
+
elapsedMs: Date.now() - startTime,
|
|
1228
|
+
timedOut: true,
|
|
1229
|
+
error: `Timed out after ${timeoutMs}ms waiting for element`
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
//# sourceMappingURL=ios.js.map
|