@sensaiorg/adapter-ios 0.1.0

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.
@@ -0,0 +1,264 @@
1
+ /**
2
+ * SimctlClient — wraps xcrun simctl + idb commands for iOS Simulator interaction.
3
+ *
4
+ * Uses simctl for: screenshots, app lifecycle, logs, device info
5
+ * Uses idb for: tap, type, swipe (simctl doesn't support these)
6
+ */
7
+
8
+ import { execFile } from "node:child_process";
9
+ import { promisify } from "node:util";
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ export interface SimulatorInfo {
14
+ udid: string;
15
+ name: string;
16
+ runtime: string;
17
+ state: string;
18
+ booted: boolean;
19
+ }
20
+
21
+ export class SimctlClient {
22
+ private readonly simulatorId: string;
23
+ private bootedState = false;
24
+ private idbPath: string | null = null;
25
+
26
+ constructor(simulatorId: string) {
27
+ this.simulatorId = simulatorId;
28
+ }
29
+
30
+ /**
31
+ * Detect idb binary path asynchronously. Checks common locations.
32
+ */
33
+ private async detectIdb(): Promise<void> {
34
+ const candidates = [
35
+ process.env.IDB_PATH,
36
+ process.env.HOME + "/Library/Python/3.9/bin/idb",
37
+ process.env.HOME + "/Library/Python/3.11/bin/idb",
38
+ process.env.HOME + "/Library/Python/3.12/bin/idb",
39
+ "/usr/local/bin/idb",
40
+ "/opt/homebrew/bin/idb",
41
+ "idb", // fallback to PATH
42
+ ].filter(Boolean) as string[];
43
+
44
+ for (const candidate of candidates) {
45
+ try {
46
+ await execFileAsync(candidate, ["--help"], {
47
+ timeout: 5_000,
48
+ });
49
+ this.idbPath = candidate;
50
+ return;
51
+ } catch {
52
+ // try next
53
+ }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Initialize idb detection. Call this before using interaction commands.
59
+ */
60
+ async initIdb(): Promise<void> {
61
+ await this.detectIdb();
62
+ }
63
+
64
+ /**
65
+ * Execute an idb command. Throws if idb is not available.
66
+ */
67
+ private async idb(subcommands: string[], args: string[] = []): Promise<string> {
68
+ if (!this.idbPath) {
69
+ throw new Error(
70
+ "idb (Facebook's iOS Development Bridge) is not installed. " +
71
+ "Install with: pip3 install fb-idb. " +
72
+ "Also ensure idb_companion is installed: brew install idb-companion"
73
+ );
74
+ }
75
+ const { stdout } = await execFileAsync(this.idbPath, [
76
+ ...subcommands,
77
+ ...args,
78
+ ], { timeout: 15_000 });
79
+ return stdout;
80
+ }
81
+
82
+ /**
83
+ * Execute a simctl command.
84
+ */
85
+ async exec(subcommand: string, args: string[] = []): Promise<string> {
86
+ const { stdout } = await execFileAsync("xcrun", [
87
+ "simctl",
88
+ subcommand,
89
+ this.simulatorId,
90
+ ...args,
91
+ ], {
92
+ timeout: 30_000,
93
+ maxBuffer: 10 * 1024 * 1024,
94
+ });
95
+ return stdout;
96
+ }
97
+
98
+ /**
99
+ * Get simulator device info.
100
+ */
101
+ async getDeviceInfo(): Promise<SimulatorInfo> {
102
+ try {
103
+ const { stdout } = await execFileAsync("xcrun", [
104
+ "simctl", "list", "devices", "--json",
105
+ ], { timeout: 10_000 });
106
+
107
+ const data = JSON.parse(stdout) as { devices: Record<string, Array<{
108
+ udid: string; name: string; state: string;
109
+ }>> };
110
+
111
+ // Find our simulator
112
+ for (const [runtime, devices] of Object.entries(data.devices)) {
113
+ for (const device of devices) {
114
+ const isTarget = this.simulatorId === "booted"
115
+ ? device.state === "Booted"
116
+ : device.udid === this.simulatorId;
117
+
118
+ if (isTarget) {
119
+ this.bootedState = device.state === "Booted";
120
+ return {
121
+ udid: device.udid,
122
+ name: device.name,
123
+ runtime,
124
+ state: device.state,
125
+ booted: this.bootedState,
126
+ };
127
+ }
128
+ }
129
+ }
130
+
131
+ return {
132
+ udid: this.simulatorId,
133
+ name: "Unknown",
134
+ runtime: "Unknown",
135
+ state: "Shutdown",
136
+ booted: false,
137
+ };
138
+ } catch {
139
+ return {
140
+ udid: this.simulatorId,
141
+ name: "Not Available",
142
+ runtime: "Unknown",
143
+ state: "Not Available",
144
+ booted: false,
145
+ };
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Take a screenshot and return as base64 PNG.
151
+ */
152
+ async screenshot(): Promise<Buffer> {
153
+ const tmpPath = `/tmp/sensai-ios-${Date.now()}.png`;
154
+ await this.exec("io", ["screenshot", tmpPath]);
155
+ const { readFile, unlink } = await import("node:fs/promises");
156
+ const data = await readFile(tmpPath);
157
+ await unlink(tmpPath).catch(() => { /* ignore cleanup errors */ });
158
+ return data;
159
+ }
160
+
161
+ /**
162
+ * Launch an app by bundle ID.
163
+ */
164
+ async launchApp(bundleId: string): Promise<void> {
165
+ await this.exec("launch", [bundleId]);
166
+ }
167
+
168
+ /**
169
+ * Terminate an app by bundle ID.
170
+ */
171
+ async terminateApp(bundleId: string): Promise<void> {
172
+ await this.exec("terminate", [bundleId]);
173
+ }
174
+
175
+ /**
176
+ * Tap at screen coordinates using idb.
177
+ */
178
+ async tap(x: number, y: number): Promise<void> {
179
+ await this.idb(["ui", "tap"], [String(x), String(y)]);
180
+ }
181
+
182
+ /**
183
+ * Type text into the focused field using idb.
184
+ */
185
+ async typeText(text: string): Promise<void> {
186
+ await this.idb(["ui", "text"], [text]);
187
+ }
188
+
189
+ /**
190
+ * Swipe from one point to another using idb.
191
+ */
192
+ async swipe(startX: number, startY: number, endX: number, endY: number, durationSec = 0.3): Promise<void> {
193
+ await this.idb(["ui", "swipe"], [
194
+ String(startX), String(startY), String(endX), String(endY),
195
+ "--duration", String(durationSec),
196
+ ]);
197
+ }
198
+
199
+ /**
200
+ * Describe all accessibility elements on screen using idb.
201
+ */
202
+ async describeAll(): Promise<string> {
203
+ return this.idb(["ui", "describe-all"]);
204
+ }
205
+
206
+ /**
207
+ * Describe accessibility element at a point using idb.
208
+ */
209
+ async describePoint(x: number, y: number): Promise<string> {
210
+ return this.idb(["ui", "describe-point"], [String(x), String(y)]);
211
+ }
212
+
213
+ /**
214
+ * Check if idb is available for interaction commands.
215
+ */
216
+ hasIdb(): boolean {
217
+ return this.idbPath !== null;
218
+ }
219
+
220
+ /**
221
+ * Get recent logs from the simulator.
222
+ */
223
+ async getLogs(predicate: string, duration = "30s"): Promise<string> {
224
+ const { stdout } = await execFileAsync("xcrun", [
225
+ "simctl", "spawn", this.simulatorId, "log", "show",
226
+ "--last", duration,
227
+ "--predicate", predicate,
228
+ ], { timeout: 30_000, maxBuffer: 10 * 1024 * 1024 });
229
+ return stdout;
230
+ }
231
+
232
+ /**
233
+ * Get the resolved UDID (needed for spawn commands when using "booted").
234
+ */
235
+ async getResolvedUdid(): Promise<string> {
236
+ if (this.simulatorId !== "booted") return this.simulatorId;
237
+ const info = await this.getDeviceInfo();
238
+ return info.udid;
239
+ }
240
+
241
+ /**
242
+ * Get crash/exception logs from the last N minutes.
243
+ */
244
+ async getCrashLogs(minutes = 5): Promise<string> {
245
+ const { stdout } = await execFileAsync("xcrun", [
246
+ "simctl", "spawn", this.simulatorId, "log", "show",
247
+ "--last", `${minutes}m`,
248
+ "--predicate",
249
+ 'eventMessage contains "crash" OR eventMessage contains "exception" OR eventMessage contains "fatal" OR eventMessage contains "SIGABRT"',
250
+ ], { timeout: 30_000, maxBuffer: 10 * 1024 * 1024 });
251
+ return stdout;
252
+ }
253
+
254
+ /**
255
+ * Send key input to the simulator via idb.
256
+ */
257
+ async keyPress(keycode: number): Promise<void> {
258
+ await this.idb(["ui", "key"], [String(keycode)]);
259
+ }
260
+
261
+ isBooted(): boolean {
262
+ return this.bootedState;
263
+ }
264
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src/**/*.ts"],
8
+ "references": [
9
+ { "path": "../core" }
10
+ ]
11
+ }