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 +34 -23
- package/dist/cli.js +44 -10
- package/dist/drivers/index.js +5 -5
- package/dist/drivers/ios.js +351 -0
- package/dist/ui/ios-parse.js +149 -0
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/dist/drivers/simctl.js +0 -156
package/README.md
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
# verikun
|
|
2
2
|
|
|
3
|
-
Drive a connected Android device/emulator
|
|
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` (
|
|
9
|
-
`xcrun simctl`
|
|
10
|
-
|
|
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
|
|
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 /
|
|
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/
|
|
336
|
-
`
|
|
337
|
-
|
|
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
|
|
344
|
+
## iOS setup
|
|
342
345
|
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
347
|
-
(
|
|
348
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
358
|
-
with
|
|
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.
|
|
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:
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
}
|
package/dist/drivers/index.js
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.
|
|
3
|
+
exports.IdbDriver = exports.AdbDriver = void 0;
|
|
4
4
|
exports.getDriver = getDriver;
|
|
5
5
|
const adb_1 = require("./adb");
|
|
6
|
-
const
|
|
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
|
|
10
|
-
Object.defineProperty(exports, "
|
|
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
|
|
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.
|
|
6
|
+
exports.VERSION = '0.5.0';
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verikun",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Drive Android emulators/devices
|
|
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",
|
package/dist/drivers/simctl.js
DELETED
|
@@ -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;
|