verikun 0.4.1 → 0.5.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.
package/README.md CHANGED
@@ -1,13 +1,14 @@
1
1
  # verikun
2
2
 
3
- Drive a connected Android device/emulator (and, later, iOS simulators) the way
3
+ Drive a connected Android device/emulator or iOS simulator the way
4
4
  Puppeteer drives a browser — **tap, type, swipe, screenshot**, and most
5
5
  importantly **inspect the UI hierarchy by semantic identifiers** so an AI agent
6
6
  can act and then *verify* what happened.
7
7
 
8
- It is a thin, deterministic, zero-runtime-dependency wrapper over `adb` (and
9
- `xcrun simctl` for iOS) that turns the raw `uiautomator` dump into a compact,
10
- token-efficient list of meaningful elements addressable by `resource-id`,
8
+ It is a thin, deterministic, zero-runtime-dependency wrapper over `adb` (Android)
9
+ and `idb` + `xcrun simctl` (iOS) that turns the raw `uiautomator` dump (Android)
10
+ or `idb ui describe-all` accessibility tree (iOS) into a compact, token-efficient
11
+ list of meaningful elements addressable by `resource-id` / accessibility id,
11
12
  visible text, accessibility label, or class.
12
13
 
13
14
  ```
@@ -78,7 +79,7 @@ vk screenshot # -> ./.verikun/screen.png
78
79
  | `swipe --from x,y --to x,y [--duration ms]` | Explicit swipe between two points. |
79
80
  | `screenshot [--out path] [--more] [--max px] [--full] [--json]` | Save a PNG (default `./.verikun/screen.png`); prints the path. [Downscaled](#screenshots) to a 700px longest edge by default to save tokens; `--more` bumps detail, `--max px` sets an exact cap, `--full` keeps the original. |
80
81
  | `launch <app> [--clear] [--no-restart]` / `stop <app>` | App lifecycle by package id (Android) / bundle id (iOS). `launch` **restarts by default** — it force-stops the app first (a no-op if it isn't running) so a rerun starts fresh instead of resurfacing a still-running instance's current screen; `--no-restart` skips that. `--clear` also wipes the app's local data (login/session, prefs, cache) for a fresh-install start. |
81
- | `clear <app>` | Wipe the app's locally stored data — login/session, preferences, caches — resetting it to a just-installed state (Android `pm clear`, which also force-stops the app). iOS not supported yet. |
82
+ | `clear <app>` | Wipe the app's locally stored data — login/session, preferences, caches — resetting it to a just-installed state (Android `pm clear`, which also force-stops the app). iOS unsupported: there is no per-app data reset. |
82
83
 
83
84
  ### Batch
84
85
  | Command | Description |
@@ -319,9 +320,10 @@ Failure-evidence captures in test-run reports stay full-resolution for debugging
319
320
  ## How it works
320
321
 
321
322
  ```
322
- cli.ts ──> drivers/ ──> adb / xcrun (platform I/O)
323
+ cli.ts ──> drivers/ ──> adb (Android) / idb + simctl (iOS) (platform I/O)
323
324
  │ └─ produces normalized Element[]
324
325
  ├─ ui/android-parse.ts uiautomator XML -> Element[]
326
+ ├─ ui/ios-parse.ts idb describe-all JSON -> Element[]
325
327
  ├─ ui/selector.ts @id / text: / desc: / class: matching
326
328
  └─ ui/format.ts compact / tree / json rendering
327
329
  ```
@@ -332,30 +334,39 @@ selector, formatting, and command layers operate only on the normalized
332
334
 
333
335
  - **Android** (`drivers/adb.ts`): `uiautomator dump` for the hierarchy,
334
336
  `screencap -p` for screenshots, `input tap/text/swipe/keyevent`, `wm size`.
335
- - **iOS** (`drivers/simctl.ts`): `screenshot`, `launch`, `stop` work today via
336
- `xcrun simctl`. Full interaction and hierarchy inspection are planned via
337
- WebDriverAgent see [iOS roadmap](#ios-roadmap) below.
337
+ - **iOS** (`drivers/ios.ts`): `idb ui describe-all` for the hierarchy,
338
+ `idb ui tap/text/swipe/key/button` for interaction, `idb describe` for screen
339
+ size; simulator `screenshot`/`launch`/`stop`/`log` stay on `xcrun simctl` (no
340
+ idb needed for those). See [iOS setup](#ios-setup) below.
338
341
 
339
342
  Run artifacts (screenshots, dumps) are written under `./.verikun/` (gitignored).
340
343
 
341
- ## iOS roadmap
344
+ ## iOS setup
342
345
 
343
- Today `vk --ios` supports **screenshots, launch, and stop** via `xcrun simctl`.
344
- Tapping, swiping, typing, and `vk ui` hierarchy inspection are not yet wired up.
346
+ `vk --ios` reaches feature parity with Android `ui`/`find`, `tap`, `text`/`type`,
347
+ `swipe`, `key`, `assert`, `wait`, `screenshot`, `launch`/`stop`, plus `vk batch`,
348
+ `vk ai`, and the JUnit/HTML reports — on both simulators and physical devices.
345
349
 
346
- The planned backend is **[WebDriverAgent](https://github.com/appium/WebDriverAgent)**
347
- (WDA) an open-source XCTest HTTP server maintained by the Appium team. It
348
- requires no Python and works on both simulators and physical devices. Once WDA
349
- is running, `vk` will drive it over HTTP and the command layer stays unchanged.
350
+ Everything interactive is powered by **[`idb`](https://github.com/facebook/idb)**
351
+ (Facebook's iOS Development Bridge), a CLI shelled one-shot exactly like `adb`, so
352
+ verikun stays zero-runtime-dependency and one-process-per-command. Install it once:
350
353
 
351
- **One-time setup (when this lands):**
352
- 1. Clone WebDriverAgent and open it in Xcode
353
- 2. Set your Apple developer signing team
354
- 3. Build & run on the target device or simulator
355
- 4. `vk --ios tap`, `vk --ios ui`, etc. will work automatically
354
+ ```sh
355
+ brew tap facebook/fb && brew install idb-companion # the companion daemon
356
+ pip install fb-idb # the idb CLI (needs Python 3.6+)
357
+ ```
356
358
 
357
- Until then, running any unsupported iOS command prints an explanation and exits
358
- with code 3.
359
+ Then boot a simulator (Simulator.app, or `xcrun simctl boot <name>`) and check
360
+ your setup with `vk doctor --ios`. `vk --ios tap`, `vk --ios ui`, etc. then work.
361
+
362
+ **Caveats (documented limitations, not bugs):**
363
+ - `clear` is unsupported — iOS has no per-app data reset (uninstall + reinstall is
364
+ the manual equivalent, but it removes the app too). Exits 3 with an explanation.
365
+ - `current` returns `(unknown)` — iOS exposes no reliable foreground-app query.
366
+ - `swipe` duration is not honored (idb has no millisecond duration knob).
367
+ - `log` capture is simulator-only (via `log show`); for a physical device use
368
+ Console.app or `idb log` directly.
369
+ - `--tree` renders flat — idb's accessibility list has no nesting depth.
359
370
 
360
371
  ## Using it from an AI agent
361
372
 
package/dist/cli.js CHANGED
@@ -149,10 +149,10 @@ function cmdDevices(ctx) {
149
149
  }
150
150
  try {
151
151
  // Only include booted simulators; always include physical devices (they carry a note)
152
- allDevices.push(...new drivers_1.SimctlDriver().listDevices().filter((d) => d.state === 'booted' || d.note));
152
+ allDevices.push(...new drivers_1.IdbDriver().listDevices().filter((d) => d.state === 'booted' || d.note));
153
153
  }
154
154
  catch (e) {
155
- (0, output_1.err)(`devices: simctl backend unavailable (${e.message})`);
155
+ (0, output_1.err)(`devices: iOS backend unavailable (${e.message})`);
156
156
  }
157
157
  if ((0, args_1.flagBool)(ctx.flags, 'json')) {
158
158
  (0, output_1.json)(allDevices);
@@ -171,18 +171,50 @@ function cmdDevices(ctx) {
171
171
  }
172
172
  function cmdDoctor(ctx) {
173
173
  if (ctx.platform === 'ios') {
174
- const r = (0, exec_1.runText)('xcrun', ['simctl', 'list', 'devices', 'booted']);
175
- (0, output_1.out)('xcrun: present');
176
- (0, output_1.out)(r.stdout.trim() || '(no booted simulators)');
177
- (0, output_1.out)('note: iOS screenshots + launch/stop work via simctl; tap/text/swipe/hierarchy need idb.');
178
- return 0;
174
+ try {
175
+ const r = (0, exec_1.runText)('xcrun', ['simctl', 'list', 'devices', 'booted']);
176
+ (0, output_1.out)('xcrun: present');
177
+ (0, output_1.out)(r.stdout.trim() || '(no booted simulators)');
178
+ }
179
+ catch (e) {
180
+ // Not necessarily missing: runText also throws on a spawn timeout or other exec
181
+ // failure, so surface the real reason rather than always claiming "NOT FOUND".
182
+ (0, output_1.err)(`xcrun: ${e.message}`);
183
+ (0, output_1.err)(' (if the Xcode command-line tools are not installed: `xcode-select --install`)');
184
+ return 3;
185
+ }
186
+ // idb (+ its companion) powers everything interactive: ui/tap/text/swipe/key/logs.
187
+ const idb = process.env.IDB || 'idb';
188
+ let idbOk = true;
189
+ try {
190
+ (0, exec_1.runText)(idb, ['--help']); // idb has no --version; --help confirms the binary runs
191
+ (0, output_1.out)('idb: present');
192
+ }
193
+ catch (e) {
194
+ (0, output_1.err)(`idb: ${e.message}`);
195
+ (0, output_1.err)(' needed for ui/tap/text/swipe/key/logs — install: `brew install idb-companion` then `pip install fb-idb`');
196
+ idbOk = false;
197
+ }
198
+ try {
199
+ (0, exec_1.runText)('idb_companion', ['--help']);
200
+ (0, output_1.out)('idb_companion: present');
201
+ }
202
+ catch (e) {
203
+ (0, output_1.err)(`idb_companion: ${e.message}`);
204
+ (0, output_1.err)(' install: `brew install idb-companion`');
205
+ idbOk = false;
206
+ }
207
+ (0, output_1.out)('note: simulator screenshots + launch/stop work via simctl; ui/tap/text/swipe/key/logs use idb.');
208
+ return idbOk ? 0 : 3;
179
209
  }
180
210
  const adb = process.env.ADB || 'adb';
181
211
  try {
182
212
  (0, output_1.out)('adb: ' + (0, exec_1.runText)(adb, ['version']).stdout.split('\n')[0]);
183
213
  }
184
- catch {
185
- (0, output_1.err)('adb: NOT FOUND on PATH');
214
+ catch (e) {
215
+ // Not necessarily missing: runText also throws on a spawn timeout or other exec
216
+ // failure — surface the real reason rather than always claiming "NOT FOUND".
217
+ (0, output_1.err)(`adb: ${e.message}`);
186
218
  return 3;
187
219
  }
188
220
  const devices = ctx.driver.listDevices();
@@ -1294,5 +1326,7 @@ GLOBAL FLAGS
1294
1326
  EXIT CODES
1295
1327
  0 success · 1 not found / assertion failed / timeout · 2 usage or ambiguous selector · 3 environment error
1296
1328
 
1297
- iOS: screenshots + launch/stop work today via simctl; tap/text/swipe/hierarchy need idb (planned).`;
1329
+ iOS (--ios): full parity via idb ui/tap/text/swipe/key + screenshot/launch/stop.
1330
+ Needs idb (\`brew install idb-companion\` + \`pip install fb-idb\`); see \`vk doctor --ios\`.
1331
+ Caveats: no \`clear\` (no per-app reset), \`current\` is (unknown), device logs are simulator-only.`;
1298
1332
  }
@@ -1,13 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SimctlDriver = exports.AdbDriver = void 0;
3
+ exports.IdbDriver = exports.AdbDriver = void 0;
4
4
  exports.getDriver = getDriver;
5
5
  const adb_1 = require("./adb");
6
- const simctl_1 = require("./simctl");
6
+ const ios_1 = require("./ios");
7
7
  var adb_2 = require("./adb");
8
8
  Object.defineProperty(exports, "AdbDriver", { enumerable: true, get: function () { return adb_2.AdbDriver; } });
9
- var simctl_2 = require("./simctl");
10
- Object.defineProperty(exports, "SimctlDriver", { enumerable: true, get: function () { return simctl_2.SimctlDriver; } });
9
+ var ios_2 = require("./ios");
10
+ Object.defineProperty(exports, "IdbDriver", { enumerable: true, get: function () { return ios_2.IdbDriver; } });
11
11
  function getDriver(platform, device) {
12
- return platform === 'ios' ? new simctl_1.SimctlDriver(device) : new adb_1.AdbDriver(device);
12
+ return platform === 'ios' ? new ios_1.IdbDriver(device) : new adb_1.AdbDriver(device);
13
13
  }
@@ -0,0 +1,351 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IdbDriver = void 0;
4
+ const node_os_1 = require("node:os");
5
+ const node_path_1 = require("node:path");
6
+ const node_fs_1 = require("node:fs");
7
+ const errors_1 = require("../errors");
8
+ const exec_1 = require("../exec");
9
+ const ios_parse_1 = require("../ui/ios-parse");
10
+ // iOS driver. `xcrun simctl` / `devicectl` cover device discovery and — on a
11
+ // simulator — screenshots, app lifecycle, and logs (no extra install needed).
12
+ // Everything interactive (UI hierarchy, tap, type, swipe, keys, screen size) and
13
+ // all interaction on physical devices go through Facebook's `idb`, a CLI shelled
14
+ // one-shot exactly like `adb` — so this driver keeps verikun's zero-runtime-dep,
15
+ // process-per-command shape. Install: `brew install idb-companion` + `pip install
16
+ // fb-idb` (see `vk doctor --ios`).
17
+ //
18
+ // Coordinates are in idb's point space: `idb ui describe-all` frames, `idb ui tap`,
19
+ // and `idb describe` screen_dimensions all agree, so element.center taps land.
20
+ // (simctl screenshots are pixels = points × scale — they are for viewing only and
21
+ // never feed back into a tap.)
22
+ const XCRUN = 'xcrun';
23
+ const IDB = process.env.IDB || 'idb';
24
+ const DEFAULT_LOG_LINES = 200;
25
+ const DEFAULT_LOG_WINDOW = '5m';
26
+ // Named keys -> USB-HID keyboard usage IDs, handed to `idb ui key <code>`. Numeric
27
+ // codes are accepted directly. Names mirror the adb driver so cross-platform
28
+ // batch/ai scripts share one vocabulary.
29
+ const IOS_KEYCODES = {
30
+ enter: 40,
31
+ return: 40,
32
+ escape: 41,
33
+ esc: 41,
34
+ del: 42,
35
+ delete: 42,
36
+ backspace: 42,
37
+ tab: 43,
38
+ space: 44,
39
+ forward_del: 76,
40
+ move_home: 74,
41
+ move_end: 77,
42
+ page_up: 75,
43
+ page_down: 78,
44
+ right: 79,
45
+ dpad_right: 79,
46
+ left: 80,
47
+ dpad_left: 80,
48
+ down: 81,
49
+ dpad_down: 81,
50
+ up: 82,
51
+ dpad_up: 82,
52
+ };
53
+ // Named hardware buttons -> `idb ui button <NAME>` (the only accepted set).
54
+ const IOS_BUTTONS = {
55
+ home: 'HOME',
56
+ lock: 'LOCK',
57
+ power: 'LOCK',
58
+ side_button: 'SIDE_BUTTON',
59
+ siri: 'SIRI',
60
+ apple_pay: 'APPLE_PAY',
61
+ };
62
+ function listPhysicalDevices() {
63
+ const r = (0, exec_1.runText)(XCRUN, ['devicectl', 'list', 'devices']);
64
+ if (r.code !== 0)
65
+ return [];
66
+ const lines = r.stdout.split('\n');
67
+ const headerIdx = lines.findIndex((l) => l.includes('Identifier'));
68
+ if (headerIdx < 0)
69
+ return [];
70
+ const header = lines[headerIdx];
71
+ const nameCol = header.indexOf('Name');
72
+ const hostCol = header.indexOf('Hostname');
73
+ const idCol = header.indexOf('Identifier');
74
+ const stateCol = header.indexOf('State');
75
+ const modelCol = header.indexOf('Model');
76
+ const devices = [];
77
+ for (const line of lines.slice(headerIdx + 2)) {
78
+ if (!line.trim() || line.startsWith('-'))
79
+ continue;
80
+ const identifier = line.slice(idCol, stateCol).trim();
81
+ const state = line.slice(stateCol, modelCol).trim();
82
+ const name = line.slice(nameCol, hostCol).trim();
83
+ const model = line.slice(modelCol).trim();
84
+ if (!identifier)
85
+ continue;
86
+ const productMatch = model.match(/\(([^)]+)\)$/);
87
+ devices.push({
88
+ serial: identifier,
89
+ state,
90
+ model: name,
91
+ product: productMatch?.[1],
92
+ platform: 'ios',
93
+ note: 'physical — via idb (Developer mode + idb_companion; logs limited)',
94
+ });
95
+ }
96
+ return devices;
97
+ }
98
+ class IdbDriver {
99
+ platform = 'ios';
100
+ requested;
101
+ cachedSerial;
102
+ cachedIsSim;
103
+ constructor(device) {
104
+ // 'booted' is a simctl-only alias idb can't address, so treat it as "auto-resolve".
105
+ this.requested = device && device !== 'booted' ? device : undefined;
106
+ }
107
+ /** All available simulators (booted or shutdown) via simctl. Tolerates odd output. */
108
+ simulators() {
109
+ const devices = [];
110
+ const { stdout } = (0, exec_1.runText)(XCRUN, ['simctl', 'list', 'devices', 'available', '--json']);
111
+ try {
112
+ const data = JSON.parse(stdout);
113
+ for (const [runtime, list] of Object.entries(data.devices)) {
114
+ for (const d of list) {
115
+ devices.push({
116
+ serial: d.udid,
117
+ state: d.state.toLowerCase(),
118
+ model: d.name,
119
+ product: runtime.split('.').pop(),
120
+ platform: 'ios',
121
+ });
122
+ }
123
+ }
124
+ }
125
+ catch {
126
+ /* tolerate unexpected simctl output */
127
+ }
128
+ return devices;
129
+ }
130
+ listDevices() {
131
+ return [...this.simulators(), ...listPhysicalDevices()];
132
+ }
133
+ resolvedSerial() {
134
+ if (this.cachedSerial)
135
+ return this.cachedSerial;
136
+ const sims = this.simulators();
137
+ const simUdids = new Set(sims.map((d) => d.serial));
138
+ if (this.requested) {
139
+ this.cachedSerial = this.requested;
140
+ this.cachedIsSim = simUdids.has(this.requested);
141
+ return this.cachedSerial;
142
+ }
143
+ // No explicit device: a booted simulator is the first-class, unambiguously
144
+ // drivable target, so prefer it. Only weigh physical devices when no simulator
145
+ // is booted — and only genuinely "connected" ones (devicectl also lists paired-
146
+ // but-idle devices as "available (paired)", which must not count as active).
147
+ const bootedSims = sims.filter((d) => d.state === 'booted');
148
+ const candidates = bootedSims.length > 0 ? bootedSims : listPhysicalDevices().filter((d) => /connected/i.test(d.state));
149
+ if (candidates.length === 0) {
150
+ throw new errors_1.CliError('No booted iOS simulator or connected device. Boot one (Simulator.app / `xcrun simctl boot`), then `verikun devices`.', 3);
151
+ }
152
+ if (candidates.length > 1) {
153
+ const list = candidates.map((d) => ' ' + d.serial + (d.model ? ` (${d.model})` : '')).join('\n');
154
+ throw new errors_1.CliError(`Multiple iOS targets; pass --device <udid> (or set VERIKUN_DEVICE):\n${list}`, 2);
155
+ }
156
+ this.cachedSerial = candidates[0].serial;
157
+ this.cachedIsSim = simUdids.has(candidates[0].serial);
158
+ return this.cachedSerial;
159
+ }
160
+ udid() {
161
+ return this.resolvedSerial();
162
+ }
163
+ isSimulator() {
164
+ this.resolvedSerial();
165
+ return this.cachedIsSim === true;
166
+ }
167
+ /** Run an idb subcommand against the resolved target, returning stdout. */
168
+ idbText(args, opts) {
169
+ const r = (0, exec_1.runText)(IDB, [...args, '--udid', this.udid()], opts);
170
+ if (r.code !== 0) {
171
+ throw new errors_1.CliError(`idb ${args.join(' ')} failed: ${r.stderr.trim() || `exit code ${r.code}`}`, 3);
172
+ }
173
+ return r.stdout;
174
+ }
175
+ getElements(opts = {}) {
176
+ // `idb ui describe-all` prints the accessibility tree as JSON (array or NDJSON);
177
+ // parseIosHierarchy handles either.
178
+ return (0, ios_parse_1.parseIosHierarchy)(this.idbText(['ui', 'describe-all'], { timeout: 15000 }), opts);
179
+ }
180
+ screenshot() {
181
+ const tmp = (0, node_path_1.join)((0, node_os_1.tmpdir)(), `verikun-ios-${process.pid}.png`);
182
+ if (this.isSimulator()) {
183
+ const r = (0, exec_1.runText)(XCRUN, ['simctl', 'io', this.udid(), 'screenshot', tmp]);
184
+ if (r.code !== 0)
185
+ throw new errors_1.CliError(`simctl screenshot failed: ${r.stderr.trim()}`, 3);
186
+ }
187
+ else {
188
+ this.idbText(['screenshot', tmp]);
189
+ }
190
+ let buf;
191
+ try {
192
+ buf = (0, node_fs_1.readFileSync)(tmp);
193
+ }
194
+ catch (e) {
195
+ throw new errors_1.CliError(`Could not read iOS screenshot: ${e.message}`, 3);
196
+ }
197
+ finally {
198
+ try {
199
+ (0, node_fs_1.unlinkSync)(tmp);
200
+ }
201
+ catch {
202
+ /* best-effort cleanup */
203
+ }
204
+ }
205
+ if (buf.length < 8 || buf[0] !== 0x89 || buf[1] !== 0x50) {
206
+ throw new errors_1.CliError('iOS screenshot was not a PNG.', 3);
207
+ }
208
+ return buf;
209
+ }
210
+ screenSize() {
211
+ // Prefer idb's target description (points, same space as tap/swipe).
212
+ try {
213
+ const d = JSON.parse(this.idbText(['describe', '--json']));
214
+ const w = Number(d.screen_dimensions?.width);
215
+ const h = Number(d.screen_dimensions?.height);
216
+ if (w > 0 && h > 0)
217
+ return { width: w, height: h };
218
+ }
219
+ catch {
220
+ /* fall through to deriving from the hierarchy */
221
+ }
222
+ // Fallback: the extent of the on-screen hierarchy (the window/app root frame).
223
+ let width = 0;
224
+ let height = 0;
225
+ for (const el of (0, ios_parse_1.parseIosHierarchy)(this.idbText(['ui', 'describe-all']), { all: true })) {
226
+ width = Math.max(width, el.bounds.x2);
227
+ height = Math.max(height, el.bounds.y2);
228
+ }
229
+ if (width > 0 && height > 0)
230
+ return { width, height };
231
+ throw new errors_1.CliError('Could not determine iOS screen size via idb.', 3);
232
+ }
233
+ tap(x, y) {
234
+ this.idbText(['ui', 'tap', String(Math.round(x)), String(Math.round(y))]);
235
+ }
236
+ swipe(x1, y1, x2, y2, _durationMs) {
237
+ // idb controls swipe speed via `--delta` (px per step), not a ms duration, and
238
+ // the flag's availability varies by version — so we pass coordinates only for
239
+ // maximum compatibility. durationMs is not honored (a documented iOS gap).
240
+ this.idbText([
241
+ 'ui',
242
+ 'swipe',
243
+ String(Math.round(x1)),
244
+ String(Math.round(y1)),
245
+ String(Math.round(x2)),
246
+ String(Math.round(y2)),
247
+ ]);
248
+ }
249
+ inputText(text) {
250
+ if (!text)
251
+ return;
252
+ // Passed as a single argv (spawnSync, no host shell), so no device-shell escaping
253
+ // is needed — idb synthesizes the keystrokes itself.
254
+ this.idbText(['ui', 'text', text]);
255
+ }
256
+ pressKey(name) {
257
+ const key = name.toLowerCase();
258
+ const button = IOS_BUTTONS[key];
259
+ if (button) {
260
+ this.idbText(['ui', 'button', button]);
261
+ return;
262
+ }
263
+ const code = IOS_KEYCODES[key] ?? (/^\d+$/.test(name) ? Number(name) : undefined);
264
+ if (code === undefined) {
265
+ const hint = key === 'back' ? ' (iOS has no hardware Back — tap the on-screen back control instead)' : '';
266
+ throw new errors_1.CliError(`Unknown iOS key '${name}'${hint}. Known keys: ${Object.keys(IOS_KEYCODES).join(', ')}; ` +
267
+ `hardware buttons: ${Object.keys(IOS_BUTTONS).join(', ')}; or a numeric HID keycode.`, 2);
268
+ }
269
+ this.idbText(['ui', 'key', String(code)]);
270
+ }
271
+ launch(appId) {
272
+ if (this.isSimulator()) {
273
+ const r = (0, exec_1.runText)(XCRUN, ['simctl', 'launch', this.udid(), appId]);
274
+ if (r.code !== 0)
275
+ throw new errors_1.CliError(`simctl launch failed: ${r.stderr.trim()}`, 3);
276
+ }
277
+ else {
278
+ this.idbText(['launch', appId]);
279
+ }
280
+ }
281
+ stop(appId) {
282
+ // Best-effort force-stop. `terminate` reports a non-zero exit when the app simply
283
+ // wasn't running; that's a no-op success for us (parity with adb `am force-stop`,
284
+ // which never fails), so we don't surface it — otherwise `launch`'s default
285
+ // restart (stop-then-launch) would break whenever the app is already closed.
286
+ try {
287
+ if (this.isSimulator()) {
288
+ (0, exec_1.runText)(XCRUN, ['simctl', 'terminate', this.udid(), appId]);
289
+ }
290
+ else {
291
+ (0, exec_1.runText)(IDB, ['terminate', appId, '--udid', this.udid()]);
292
+ }
293
+ }
294
+ catch {
295
+ /* tool missing / target gone — nothing to stop */
296
+ }
297
+ }
298
+ clearApp(appId) {
299
+ // Honest degrade (no clean per-app data reset on iOS): don't silently uninstall.
300
+ throw new errors_1.CliError(`iOS app-data clearing is not supported (requested for '${appId}').\n` +
301
+ 'iOS has no per-app data reset; the manual equivalent is uninstall + reinstall ' +
302
+ '(`xcrun simctl uninstall <udid> <bundleId>`), which removes the app too.', 3);
303
+ }
304
+ currentApp() {
305
+ // iOS exposes no reliable foreground-app query; degrade like adb's fallback.
306
+ return '(unknown)';
307
+ }
308
+ getLogs(opts = {}) {
309
+ if (!this.isSimulator()) {
310
+ throw new errors_1.CliError('iOS physical-device log capture is not supported (simulator logs work via `log show`).\n' +
311
+ 'Use Console.app or `idb log` directly for a connected device.', 3);
312
+ }
313
+ // `log show` on the whole store is huge, so ALWAYS bound it: a session marker
314
+ // (--start) or a recent window (--last). Args go through spawnSync (no shell),
315
+ // so the marker/predicate need no escaping and can't inject.
316
+ const args = ['simctl', 'spawn', this.udid(), 'log', 'show', '--style', 'syslog'];
317
+ if (opts.since) {
318
+ args.push('--start', opts.since);
319
+ }
320
+ else {
321
+ args.push('--last', DEFAULT_LOG_WINDOW);
322
+ }
323
+ if (opts.appId) {
324
+ // Best-effort process scope: the simulator process name is usually the bundle's
325
+ // last component. A loose predicate is better than none for a crash trace.
326
+ const proc = opts.appId.split('.').pop() || opts.appId;
327
+ args.push('--predicate', `process CONTAINS "${proc}"`);
328
+ }
329
+ const out = (0, exec_1.runText)(XCRUN, args, { timeout: 20000 }).stdout;
330
+ if (opts.since)
331
+ return out; // the whole session window
332
+ // No explicit since → keep the last N lines, like adb's `logcat -t N`.
333
+ const n = opts.lines && opts.lines > 0 ? Math.floor(opts.lines) : DEFAULT_LOG_LINES;
334
+ const lines = out.split('\n');
335
+ return lines.slice(Math.max(0, lines.length - n)).join('\n');
336
+ }
337
+ deviceTime() {
338
+ // Simulator shares the host clock; sample it in `log show --start` format so the
339
+ // run's log window (run.ts) can anchor on it. Physical devices → '' (no marker),
340
+ // which disables windowing gracefully. Never throws (called at run start).
341
+ try {
342
+ if (!this.isSimulator())
343
+ return '';
344
+ return (0, exec_1.runText)(XCRUN, ['simctl', 'spawn', this.udid(), 'date', '+%Y-%m-%d %H:%M:%S']).stdout.trim();
345
+ }
346
+ catch {
347
+ return '';
348
+ }
349
+ }
350
+ }
351
+ exports.IdbDriver = IdbDriver;
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isInteresting = isInteresting;
4
+ exports.parseIosHierarchy = parseIosHierarchy;
5
+ // XCUIElementType names (idb `type`) that are meaningful tap targets — used to
6
+ // derive `clickable`, which iOS accessibility does not expose as a flag.
7
+ const TAPPABLE_TYPES = new Set([
8
+ 'Button', 'Cell', 'Link', 'MenuItem', 'MenuButton', 'PopUpButton', 'CheckBox',
9
+ 'RadioButton', 'Switch', 'Toggle', 'SegmentedControl', 'Tab', 'Key', 'Stepper',
10
+ 'Icon', 'SearchField', 'TextField', 'SecureTextField', 'DatePicker',
11
+ ]);
12
+ const SCROLLABLE_TYPES = new Set(['ScrollView', 'Table', 'TableView', 'CollectionView', 'WebView']);
13
+ const CHECKABLE_TYPES = new Set(['Switch', 'Toggle', 'CheckBox', 'RadioButton']);
14
+ const TEXT_INPUT_TYPES = new Set(['TextField', 'SecureTextField', 'SearchField', 'TextView']);
15
+ function str(v) {
16
+ return typeof v === 'string' ? v : v == null ? '' : String(v);
17
+ }
18
+ function isTrue(v) {
19
+ if (v === true)
20
+ return true;
21
+ const s = str(v).trim().toLowerCase();
22
+ return s === '1' || s === 'true' || s === 'on' || s === 'yes';
23
+ }
24
+ function stripAx(role) {
25
+ return role.startsWith('AX') ? role.slice(2) : role;
26
+ }
27
+ function parseFrame(raw) {
28
+ const f = raw.frame;
29
+ if (f &&
30
+ typeof f.x === 'number' &&
31
+ typeof f.y === 'number' &&
32
+ typeof f.width === 'number' &&
33
+ typeof f.height === 'number') {
34
+ return {
35
+ x1: Math.round(f.x),
36
+ y1: Math.round(f.y),
37
+ x2: Math.round(f.x + f.width),
38
+ y2: Math.round(f.y + f.height),
39
+ };
40
+ }
41
+ // Fallback: AXFrame is a string like "{{x, y}, {w, h}}".
42
+ const m = /\{\{\s*(-?[\d.]+),\s*(-?[\d.]+)\s*\},\s*\{\s*(-?[\d.]+),\s*(-?[\d.]+)\s*\}\}/.exec(str(raw.AXFrame));
43
+ if (m) {
44
+ const x = parseFloat(m[1]);
45
+ const y = parseFloat(m[2]);
46
+ const w = parseFloat(m[3]);
47
+ const h = parseFloat(m[4]);
48
+ return { x1: Math.round(x), y1: Math.round(y), x2: Math.round(x + w), y2: Math.round(y + h) };
49
+ }
50
+ return { x1: 0, y1: 0, x2: 0, y2: 0 };
51
+ }
52
+ function buildElement(raw) {
53
+ const bounds = parseFrame(raw);
54
+ const id = str(raw.AXUniqueId);
55
+ const type = str(raw.type) || stripAx(str(raw.role)) || 'Other';
56
+ const cls = str(raw.role) || type;
57
+ // iOS conflates "visible text" and "content-desc" into accessibilityLabel; a
58
+ // filled text field surfaces its contents as AXValue. Prefer the label, then a
59
+ // title, then the value, as the identifying text. `help` (accessibilityHint) is
60
+ // the closest analog to Android's content-desc, so it becomes `desc`.
61
+ const text = str(raw.AXLabel) || str(raw.title) || str(raw.AXValue);
62
+ const checkable = CHECKABLE_TYPES.has(type);
63
+ const clickable = TAPPABLE_TYPES.has(type) || (Array.isArray(raw.custom_actions) && raw.custom_actions.length > 0);
64
+ return {
65
+ index: -1,
66
+ class: cls,
67
+ type,
68
+ id,
69
+ idShort: id.includes('/') ? id.slice(id.lastIndexOf('/') + 1) : id,
70
+ text,
71
+ desc: str(raw.help),
72
+ bounds,
73
+ center: {
74
+ x: Math.floor((bounds.x1 + bounds.x2) / 2),
75
+ y: Math.floor((bounds.y1 + bounds.y2) / 2),
76
+ },
77
+ depth: 0,
78
+ clickable,
79
+ longClickable: false,
80
+ checkable,
81
+ checked: checkable && isTrue(raw.AXValue),
82
+ focusable: false,
83
+ focused: false,
84
+ scrollable: SCROLLABLE_TYPES.has(type),
85
+ enabled: raw.enabled === undefined ? true : isTrue(raw.enabled),
86
+ selected: false,
87
+ password: type === 'SecureTextField',
88
+ };
89
+ }
90
+ /**
91
+ * "Interesting" = something an agent can act on or read. Mirrors the Android
92
+ * filter's intent (drop pure layout containers) with iOS predicates.
93
+ */
94
+ function isInteresting(el) {
95
+ const w = el.bounds.x2 - el.bounds.x1;
96
+ const h = el.bounds.y2 - el.bounds.y1;
97
+ if (w <= 0 || h <= 0)
98
+ return false; // not visible / not tappable
99
+ if (el.text.trim())
100
+ return true;
101
+ if (el.desc.trim())
102
+ return true;
103
+ if (el.id)
104
+ return true;
105
+ if (el.clickable || el.checkable || el.scrollable)
106
+ return true;
107
+ if (TEXT_INPUT_TYPES.has(el.type))
108
+ return true;
109
+ return false;
110
+ }
111
+ function parseIosHierarchy(jsonText, opts = {}) {
112
+ const all = parseIdbJson(jsonText).map(buildElement);
113
+ const result = opts.all ? all : all.filter(isInteresting);
114
+ result.forEach((el, idx) => {
115
+ el.index = idx;
116
+ });
117
+ return result;
118
+ }
119
+ /** idb `--json` emits a JSON array; tolerate NDJSON (one object per line) too. */
120
+ function parseIdbJson(text) {
121
+ const t = text.trim();
122
+ if (!t)
123
+ return [];
124
+ try {
125
+ const parsed = JSON.parse(t);
126
+ if (Array.isArray(parsed))
127
+ return parsed;
128
+ if (parsed && typeof parsed === 'object')
129
+ return [parsed];
130
+ }
131
+ catch {
132
+ /* fall through to NDJSON */
133
+ }
134
+ const out = [];
135
+ for (const line of t.split('\n')) {
136
+ const s = line.trim();
137
+ if (!s)
138
+ continue;
139
+ try {
140
+ const o = JSON.parse(s);
141
+ if (o && typeof o === 'object')
142
+ out.push(o);
143
+ }
144
+ catch {
145
+ /* skip a malformed line rather than fail the whole dump */
146
+ }
147
+ }
148
+ return out;
149
+ }
package/dist/version.js CHANGED
@@ -3,4 +3,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
4
  // GENERATED by scripts/gen-version.mjs from package.json's "version" at build time
5
5
  // (the `prebuild` script). Do NOT edit by hand; bump package.json instead.
6
- exports.VERSION = '0.4.1';
6
+ exports.VERSION = '0.5.0';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "verikun",
3
- "version": "0.4.1",
4
- "description": "Drive Android emulators/devices (and later iOS simulators) for AI agents: tap, type, swipe, screenshot, and inspect the UI hierarchy by semantic identifiers — like Puppeteer for native apps.",
3
+ "version": "0.5.0",
4
+ "description": "Drive Android emulators/devices and iOS simulators for AI agents: tap, type, swipe, screenshot, and inspect the UI hierarchy by semantic identifiers — like Puppeteer for native apps.",
5
5
  "keywords": [
6
6
  "android",
7
7
  "adb",
@@ -1,156 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SimctlDriver = void 0;
4
- const node_os_1 = require("node:os");
5
- const node_path_1 = require("node:path");
6
- const node_fs_1 = require("node:fs");
7
- const errors_1 = require("../errors");
8
- const exec_1 = require("../exec");
9
- const XCRUN = 'xcrun';
10
- function listPhysicalDevices() {
11
- const r = (0, exec_1.runText)(XCRUN, ['devicectl', 'list', 'devices']);
12
- if (r.code !== 0)
13
- return [];
14
- const lines = r.stdout.split('\n');
15
- const headerIdx = lines.findIndex((l) => l.includes('Identifier'));
16
- if (headerIdx < 0)
17
- return [];
18
- const header = lines[headerIdx];
19
- const nameCol = header.indexOf('Name');
20
- const hostCol = header.indexOf('Hostname');
21
- const idCol = header.indexOf('Identifier');
22
- const stateCol = header.indexOf('State');
23
- const modelCol = header.indexOf('Model');
24
- const devices = [];
25
- for (const line of lines.slice(headerIdx + 2)) {
26
- if (!line.trim() || line.startsWith('-'))
27
- continue;
28
- const identifier = line.slice(idCol, stateCol).trim();
29
- const state = line.slice(stateCol, modelCol).trim();
30
- const name = line.slice(nameCol, hostCol).trim();
31
- const model = line.slice(modelCol).trim();
32
- if (!identifier)
33
- continue;
34
- const productMatch = model.match(/\(([^)]+)\)$/);
35
- devices.push({
36
- serial: identifier,
37
- state,
38
- model: name,
39
- product: productMatch?.[1],
40
- platform: 'ios',
41
- note: 'physical — screenshot only, tap/ui not supported',
42
- });
43
- }
44
- return devices;
45
- }
46
- // iOS support is intentionally partial. `simctl` covers screenshots, launch,
47
- // and stop. Full interaction (tap, swipe, type) and the accessibility hierarchy
48
- // require WebDriverAgent (https://github.com/appium/WebDriverAgent) — the
49
- // planned next step. WDA is an open-source XCTest HTTP server: build it once
50
- // in Xcode, sign it with your Apple developer certificate, and run it on the
51
- // target device or simulator. No Python required; works on both simulators and
52
- // physical devices. The Driver seam is already in place so the command layer
53
- // won't change when WDA support is added.
54
- function notSupported(feature) {
55
- throw new errors_1.CliError(`iOS ${feature} is not supported yet.\n` +
56
- 'Screenshots, launch, and stop work today via xcrun simctl.\n' +
57
- 'Full interaction (tap / swipe / type) and UI hierarchy inspection are planned\n' +
58
- 'via WebDriverAgent — build it once in Xcode and vk will drive it over HTTP.\n' +
59
- 'See: https://github.com/appium/WebDriverAgent', 3);
60
- }
61
- class SimctlDriver {
62
- platform = 'ios';
63
- udid;
64
- constructor(device) {
65
- this.udid = device || 'booted';
66
- }
67
- listDevices() {
68
- const devices = [];
69
- // Simulators via simctl
70
- const { stdout } = (0, exec_1.runText)(XCRUN, ['simctl', 'list', 'devices', 'available', '--json']);
71
- try {
72
- const data = JSON.parse(stdout);
73
- for (const [runtime, list] of Object.entries(data.devices)) {
74
- for (const d of list) {
75
- devices.push({
76
- serial: d.udid,
77
- state: d.state.toLowerCase(),
78
- model: d.name,
79
- product: runtime.split('.').pop(),
80
- platform: 'ios',
81
- });
82
- }
83
- }
84
- }
85
- catch {
86
- /* tolerate unexpected simctl output */
87
- }
88
- // Physical devices via devicectl (Xcode 15+)
89
- devices.push(...listPhysicalDevices());
90
- return devices;
91
- }
92
- resolvedSerial() {
93
- return this.udid;
94
- }
95
- screenshot() {
96
- const tmp = (0, node_path_1.join)((0, node_os_1.tmpdir)(), `verikun-ios-${process.pid}.png`);
97
- const r = (0, exec_1.runText)(XCRUN, ['simctl', 'io', this.udid, 'screenshot', tmp]);
98
- if (r.code !== 0)
99
- throw new errors_1.CliError(`simctl screenshot failed: ${r.stderr.trim()}`, 3);
100
- const buf = (0, node_fs_1.readFileSync)(tmp);
101
- try {
102
- (0, node_fs_1.unlinkSync)(tmp);
103
- }
104
- catch {
105
- /* best-effort cleanup */
106
- }
107
- return buf;
108
- }
109
- launch(appId) {
110
- const r = (0, exec_1.runText)(XCRUN, ['simctl', 'launch', this.udid, appId]);
111
- if (r.code !== 0)
112
- throw new errors_1.CliError(`simctl launch failed: ${r.stderr.trim()}`, 3);
113
- }
114
- stop(appId) {
115
- const r = (0, exec_1.runText)(XCRUN, ['simctl', 'terminate', this.udid, appId]);
116
- if (r.code !== 0)
117
- throw new errors_1.CliError(`simctl terminate failed: ${r.stderr.trim()}`, 3);
118
- }
119
- clearApp(appId) {
120
- // simctl has no per-app data reset. The manual equivalent is to uninstall and
121
- // reinstall, which also removes the app itself — so we don't do it implicitly.
122
- throw new errors_1.CliError(`iOS app-data clearing is not supported yet (requested for '${appId}').\n` +
123
- 'simctl has no per-app data reset; uninstall + reinstall ' +
124
- '(`xcrun simctl uninstall <udid> <bundleId>`) is the manual equivalent, but it removes the app too.', 3);
125
- }
126
- getElements() {
127
- return notSupported('hierarchy inspection');
128
- }
129
- screenSize() {
130
- return notSupported('screen size');
131
- }
132
- tap() {
133
- notSupported('tap');
134
- }
135
- swipe() {
136
- notSupported('swipe');
137
- }
138
- inputText() {
139
- notSupported('text input');
140
- }
141
- pressKey() {
142
- notSupported('key events');
143
- }
144
- currentApp() {
145
- return notSupported('current app');
146
- }
147
- getLogs() {
148
- // Eventually: `xcrun simctl spawn <udid> log show` (simulators) / devicectl
149
- // (physical). Heavier (predicate language, large output) — deferred with WDA.
150
- return notSupported('log capture');
151
- }
152
- deviceTime() {
153
- return ''; // no log window to anchor while iOS log capture is unsupported
154
- }
155
- }
156
- exports.SimctlDriver = SimctlDriver;