verikun 0.4.1
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 +21 -0
- package/README.md +435 -0
- package/dist/agent/cache.js +128 -0
- package/dist/agent/claude.js +144 -0
- package/dist/agent/cost.js +100 -0
- package/dist/agent/engine.js +205 -0
- package/dist/agent/grammar.js +80 -0
- package/dist/agent/ir.js +212 -0
- package/dist/agent/provider.js +2 -0
- package/dist/args.js +102 -0
- package/dist/bin/verikun.js +8 -0
- package/dist/cli.js +1298 -0
- package/dist/drivers/adb.js +300 -0
- package/dist/drivers/index.js +13 -0
- package/dist/drivers/simctl.js +156 -0
- package/dist/errors.js +51 -0
- package/dist/exec.js +42 -0
- package/dist/image.js +212 -0
- package/dist/output.js +43 -0
- package/dist/report.js +223 -0
- package/dist/run.js +434 -0
- package/dist/types.js +5 -0
- package/dist/ui/android-parse.js +149 -0
- package/dist/ui/format.js +71 -0
- package/dist/ui/selector.js +117 -0
- package/dist/version.js +6 -0
- package/package.json +53 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AdbDriver = void 0;
|
|
4
|
+
exports.escapeText = escapeText;
|
|
5
|
+
const errors_1 = require("../errors");
|
|
6
|
+
const exec_1 = require("../exec");
|
|
7
|
+
const android_parse_1 = require("../ui/android-parse");
|
|
8
|
+
const ADB = process.env.ADB || 'adb';
|
|
9
|
+
// Named keys -> Android keycodes. Numeric codes are also accepted directly.
|
|
10
|
+
const KEYCODES = {
|
|
11
|
+
enter: 66,
|
|
12
|
+
back: 4,
|
|
13
|
+
home: 3,
|
|
14
|
+
tab: 61,
|
|
15
|
+
space: 62,
|
|
16
|
+
del: 67,
|
|
17
|
+
delete: 67,
|
|
18
|
+
backspace: 67,
|
|
19
|
+
forward_del: 112,
|
|
20
|
+
escape: 111,
|
|
21
|
+
esc: 111,
|
|
22
|
+
menu: 82,
|
|
23
|
+
search: 84,
|
|
24
|
+
up: 19,
|
|
25
|
+
down: 20,
|
|
26
|
+
left: 21,
|
|
27
|
+
right: 22,
|
|
28
|
+
center: 23,
|
|
29
|
+
dpad_up: 19,
|
|
30
|
+
dpad_down: 20,
|
|
31
|
+
dpad_left: 21,
|
|
32
|
+
dpad_right: 22,
|
|
33
|
+
dpad_center: 23,
|
|
34
|
+
power: 26,
|
|
35
|
+
app_switch: 187,
|
|
36
|
+
recents: 187,
|
|
37
|
+
volume_up: 24,
|
|
38
|
+
volume_down: 25,
|
|
39
|
+
mute: 164,
|
|
40
|
+
move_home: 122,
|
|
41
|
+
move_end: 123,
|
|
42
|
+
page_up: 92,
|
|
43
|
+
page_down: 93,
|
|
44
|
+
};
|
|
45
|
+
const DUMP_PATHS = ['/sdcard/window_dump.xml', '/data/local/tmp/window_dump.xml'];
|
|
46
|
+
const DEFAULT_LOG_LINES = 200;
|
|
47
|
+
/**
|
|
48
|
+
* Escape a string for `adb shell input text <arg>`. The argument is parsed twice
|
|
49
|
+
* before it reaches the field: once by the on-device shell (mksh), then by
|
|
50
|
+
* `input`, which maps the literal token "%s" back to a space.
|
|
51
|
+
*
|
|
52
|
+
* Strategy is an ALLOWLIST: leave ASCII letters/digits and any non-ASCII bytes
|
|
53
|
+
* untouched, and backslash-escape EVERY ASCII punctuation/symbol. For a char mksh
|
|
54
|
+
* treats as ordinary (e.g. @ . + _ - , : /) the backslash is a no-op (\x -> x),
|
|
55
|
+
* so `input` receives the same character; for one mksh would interpret
|
|
56
|
+
* (quote, backtick, $ & | ; < > ( ) * ? ~ # ! { } [ ] backslash) the escape keeps
|
|
57
|
+
* it literal. So bob@mail.com (or a value with + = % # & ;) types verbatim.
|
|
58
|
+
* Spaces are encoded last as the token %s, which `input` decodes back to a space.
|
|
59
|
+
* (One inherent limit of that convention, unchanged here: a literal "%s" in the
|
|
60
|
+
* text also decodes to a space. The backslash on % only guards the shell, not
|
|
61
|
+
* `input`'s own %s handling.)
|
|
62
|
+
*
|
|
63
|
+
* Arbitrary Unicode (accents, emoji) is a known limitation of `input text` and is
|
|
64
|
+
* passed through unchanged; use an IME like ADBKeyboard for that — see SKILL.md.
|
|
65
|
+
*/
|
|
66
|
+
function escapeText(s) {
|
|
67
|
+
return s
|
|
68
|
+
// Backslash-escape every ASCII punctuation/symbol the device shell might
|
|
69
|
+
// interpret. Ranges cover all ASCII punctuation, excluding space (\x20),
|
|
70
|
+
// 0-9, A-Z, a-z. $& is the matched char.
|
|
71
|
+
.replace(/[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]/g, '\\$&')
|
|
72
|
+
// `input` maps the literal token %s back to a space, so encode spaces last.
|
|
73
|
+
.replace(/ /g, '%s');
|
|
74
|
+
}
|
|
75
|
+
class AdbDriver {
|
|
76
|
+
platform = 'android';
|
|
77
|
+
requested;
|
|
78
|
+
cachedSerial;
|
|
79
|
+
constructor(serial) {
|
|
80
|
+
this.requested = serial;
|
|
81
|
+
}
|
|
82
|
+
listDevices() {
|
|
83
|
+
const { stdout } = (0, exec_1.runText)(ADB, ['devices', '-l']);
|
|
84
|
+
const devices = [];
|
|
85
|
+
for (const line of stdout.split('\n').slice(1)) {
|
|
86
|
+
const t = line.trim();
|
|
87
|
+
if (!t || t.startsWith('*'))
|
|
88
|
+
continue;
|
|
89
|
+
const fields = t.split(/\s+/);
|
|
90
|
+
const serial = fields[0];
|
|
91
|
+
const state = fields[1];
|
|
92
|
+
if (!serial || !state)
|
|
93
|
+
continue;
|
|
94
|
+
const info = { serial, state, platform: 'android' };
|
|
95
|
+
for (const kv of fields.slice(2)) {
|
|
96
|
+
const idx = kv.indexOf(':');
|
|
97
|
+
if (idx < 0)
|
|
98
|
+
continue;
|
|
99
|
+
const k = kv.slice(0, idx);
|
|
100
|
+
const v = kv.slice(idx + 1);
|
|
101
|
+
if (k === 'model')
|
|
102
|
+
info.model = v;
|
|
103
|
+
if (k === 'product')
|
|
104
|
+
info.product = v;
|
|
105
|
+
}
|
|
106
|
+
devices.push(info);
|
|
107
|
+
}
|
|
108
|
+
return devices;
|
|
109
|
+
}
|
|
110
|
+
resolvedSerial() {
|
|
111
|
+
if (this.cachedSerial)
|
|
112
|
+
return this.cachedSerial;
|
|
113
|
+
if (this.requested) {
|
|
114
|
+
this.cachedSerial = this.requested;
|
|
115
|
+
return this.cachedSerial;
|
|
116
|
+
}
|
|
117
|
+
const all = this.listDevices();
|
|
118
|
+
const usable = all.filter((d) => d.state === 'device');
|
|
119
|
+
if (usable.length === 0) {
|
|
120
|
+
if (all.length) {
|
|
121
|
+
const states = all.map((d) => `${d.serial}=${d.state}`).join(', ');
|
|
122
|
+
throw new errors_1.CliError(`No usable Android device (states: ${states}). Authorize/reconnect it.`, 3);
|
|
123
|
+
}
|
|
124
|
+
throw new errors_1.CliError('No Android devices/emulators connected. Start one, then `verikun devices`.', 3);
|
|
125
|
+
}
|
|
126
|
+
if (usable.length > 1) {
|
|
127
|
+
const list = usable.map((d) => ' ' + d.serial + (d.model ? ` (${d.model})` : '')).join('\n');
|
|
128
|
+
throw new errors_1.CliError(`Multiple devices connected; pass --device <serial> (or set VERIKUN_DEVICE):\n${list}`, 2);
|
|
129
|
+
}
|
|
130
|
+
this.cachedSerial = usable[0].serial;
|
|
131
|
+
return this.cachedSerial;
|
|
132
|
+
}
|
|
133
|
+
withSerial(args) {
|
|
134
|
+
return ['-s', this.resolvedSerial(), ...args];
|
|
135
|
+
}
|
|
136
|
+
shell(args, timeout) {
|
|
137
|
+
return (0, exec_1.runText)(ADB, this.withSerial(['shell', ...args]), { timeout }).stdout;
|
|
138
|
+
}
|
|
139
|
+
getElements(opts = {}) {
|
|
140
|
+
return (0, android_parse_1.parseHierarchy)(this.dumpXml(), opts);
|
|
141
|
+
}
|
|
142
|
+
dumpXml() {
|
|
143
|
+
let lastErr = '';
|
|
144
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
145
|
+
const path = DUMP_PATHS[Math.min(attempt, DUMP_PATHS.length - 1)];
|
|
146
|
+
const dump = (0, exec_1.runText)(ADB, this.withSerial(['shell', 'uiautomator', 'dump', path]), { timeout: 15000 });
|
|
147
|
+
const cat = (0, exec_1.runBinary)(ADB, this.withSerial(['exec-out', 'cat', path]));
|
|
148
|
+
const xml = cat.stdout.toString('utf8');
|
|
149
|
+
if (xml.includes('<hierarchy'))
|
|
150
|
+
return xml;
|
|
151
|
+
lastErr = `${dump.stdout} ${dump.stderr} ${cat.stderr}`.replace(/\s+/g, ' ').trim();
|
|
152
|
+
}
|
|
153
|
+
throw new errors_1.CliError(`Failed to capture UI hierarchy after 3 attempts. ${lastErr}\n` +
|
|
154
|
+
'Tip: disable animations (`verikun doctor --fix`) and ensure the screen is idle.', 3);
|
|
155
|
+
}
|
|
156
|
+
screenshot() {
|
|
157
|
+
const r = (0, exec_1.runBinary)(ADB, this.withSerial(['exec-out', 'screencap', '-p']));
|
|
158
|
+
if (r.stdout.length < 8 || r.stdout[0] !== 0x89 || r.stdout[1] !== 0x50) {
|
|
159
|
+
throw new errors_1.CliError(`screencap did not return a PNG. ${r.stderr}`.trim(), 3);
|
|
160
|
+
}
|
|
161
|
+
return r.stdout;
|
|
162
|
+
}
|
|
163
|
+
screenSize() {
|
|
164
|
+
const out = this.shell(['wm', 'size']);
|
|
165
|
+
const lines = out.split('\n');
|
|
166
|
+
const override = lines.find((l) => /Override size/i.test(l));
|
|
167
|
+
const physical = lines.find((l) => /Physical size/i.test(l));
|
|
168
|
+
const m = /(\d+)x(\d+)/.exec(override ?? physical ?? out);
|
|
169
|
+
if (!m)
|
|
170
|
+
throw new errors_1.CliError(`Could not determine screen size from: ${out.trim()}`, 3);
|
|
171
|
+
return { width: +m[1], height: +m[2] };
|
|
172
|
+
}
|
|
173
|
+
tap(x, y) {
|
|
174
|
+
this.shell(['input', 'tap', String(Math.round(x)), String(Math.round(y))]);
|
|
175
|
+
}
|
|
176
|
+
swipe(x1, y1, x2, y2, durationMs) {
|
|
177
|
+
this.shell([
|
|
178
|
+
'input',
|
|
179
|
+
'swipe',
|
|
180
|
+
String(Math.round(x1)),
|
|
181
|
+
String(Math.round(y1)),
|
|
182
|
+
String(Math.round(x2)),
|
|
183
|
+
String(Math.round(y2)),
|
|
184
|
+
String(Math.round(durationMs)),
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
187
|
+
inputText(text) {
|
|
188
|
+
if (!text)
|
|
189
|
+
return;
|
|
190
|
+
this.shell(['input', 'text', escapeText(text)]);
|
|
191
|
+
}
|
|
192
|
+
pressKey(name) {
|
|
193
|
+
const code = KEYCODES[name.toLowerCase()] ?? (/^\d+$/.test(name) ? Number(name) : undefined);
|
|
194
|
+
if (code === undefined) {
|
|
195
|
+
throw new errors_1.CliError(`Unknown key '${name}'. Known: ${Object.keys(KEYCODES).join(', ')}, or a numeric keycode.`, 2);
|
|
196
|
+
}
|
|
197
|
+
this.shell(['input', 'keyevent', String(code)]);
|
|
198
|
+
}
|
|
199
|
+
launch(appId) {
|
|
200
|
+
// Resolve the app's default LAUNCHER activity, then start it with `am start -n`.
|
|
201
|
+
// We deliberately avoid `monkey -c LAUNCHER`: on some OEM skins (MIUI/HyperOS) it
|
|
202
|
+
// hangs indefinitely rather than returning, tripping the exec timeout.
|
|
203
|
+
const resolved = this.shell([
|
|
204
|
+
'cmd',
|
|
205
|
+
'package',
|
|
206
|
+
'resolve-activity',
|
|
207
|
+
'--brief',
|
|
208
|
+
'-a',
|
|
209
|
+
'android.intent.action.MAIN',
|
|
210
|
+
'-c',
|
|
211
|
+
'android.intent.category.LAUNCHER',
|
|
212
|
+
appId,
|
|
213
|
+
]);
|
|
214
|
+
// `--brief` may print a header line before the component; the component is the
|
|
215
|
+
// last non-empty line and looks like `pkg/.Activity`.
|
|
216
|
+
const component = resolved
|
|
217
|
+
.split(/\r?\n/)
|
|
218
|
+
.map((l) => l.trim())
|
|
219
|
+
.filter(Boolean)
|
|
220
|
+
.pop();
|
|
221
|
+
if (!component || !component.includes('/')) {
|
|
222
|
+
throw new errors_1.CliError(`Could not resolve a launcher activity for '${appId}' (is it installed?).`, 3);
|
|
223
|
+
}
|
|
224
|
+
// Capture stdout+stderr+exit code: `am start` reports failures via `Error:`/`Error
|
|
225
|
+
// type` (on EITHER stream) and/or a non-zero exit, but shell() returns only stdout —
|
|
226
|
+
// so a failed launch would otherwise read as success. The benign `Warning: Activity
|
|
227
|
+
// not started` (intent delivered to a running instance, e.g. --no-restart) is NOT a
|
|
228
|
+
// failure.
|
|
229
|
+
const r = (0, exec_1.runText)(ADB, this.withSerial(['shell', 'am', 'start', '-n', component]));
|
|
230
|
+
const combined = `${r.stdout}\n${r.stderr}`;
|
|
231
|
+
const benignWarning = /Warning: Activity not started/.test(combined);
|
|
232
|
+
if (/^Error\b/im.test(combined) || (r.code !== 0 && !benignWarning)) {
|
|
233
|
+
throw new errors_1.CliError(`Failed to launch '${appId}': ${r.stderr.trim() || r.stdout.trim() || `exit code ${r.code}`}`, 3);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
stop(appId) {
|
|
237
|
+
this.shell(['am', 'force-stop', appId]);
|
|
238
|
+
}
|
|
239
|
+
clearApp(appId) {
|
|
240
|
+
// `pm clear` deletes the app's data dirs (shared-prefs, databases, caches) and
|
|
241
|
+
// force-stops it — resetting it to a just-installed state (logged out, no local
|
|
242
|
+
// data). It prints "Success", or "Failed" if the package is unknown/protected.
|
|
243
|
+
const result = this.shell(['pm', 'clear', appId]);
|
|
244
|
+
if (!/success/i.test(result)) {
|
|
245
|
+
throw new errors_1.CliError(`Failed to clear app data for '${appId}': ${result.trim() || 'no output from pm clear'}`, 3);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
currentApp() {
|
|
249
|
+
const resumed = /mResumedActivity[^\n]*?\s([A-Za-z0-9_.]+\/[A-Za-z0-9_.]+)/.exec(this.shell(['dumpsys', 'activity', 'activities']));
|
|
250
|
+
if (resumed)
|
|
251
|
+
return resumed[1];
|
|
252
|
+
const focus = /mCurrentFocus[^\n]*?\s([A-Za-z0-9_.]+\/[A-Za-z0-9_.]+)/.exec(this.shell(['dumpsys', 'window']));
|
|
253
|
+
return focus ? focus[1] : '(unknown)';
|
|
254
|
+
}
|
|
255
|
+
getLogs(opts = {}) {
|
|
256
|
+
// One-shot dump: -d (and -t) make logcat EXIT. NEVER add -f/follow — it would
|
|
257
|
+
// stream forever and hang until the spawnSync timeout. The default buffers
|
|
258
|
+
// (main,system,crash) already include crash traces, so no -b needed.
|
|
259
|
+
const args = ['logcat', '-d'];
|
|
260
|
+
if (opts.since) {
|
|
261
|
+
// A logcat timestamp is `MM-DD HH:MM:SS.mmm` — digits, space, `-`, `:`, `.` only.
|
|
262
|
+
// Reject anything else so a caller-supplied `--since` cannot break out of the
|
|
263
|
+
// device-shell single-quoting below into command injection (device-shell escaping
|
|
264
|
+
// is the driver's job — see escapeText / CLAUDE.md).
|
|
265
|
+
if (!/^[0-9 :.\-]+$/.test(opts.since)) {
|
|
266
|
+
throw new errors_1.CliError(`Invalid --since '${opts.since}': only a logcat timestamp (digits, space, '-', ':', '.') is allowed.`, 2);
|
|
267
|
+
}
|
|
268
|
+
// `-t '<time>'` prints lines at/after that time, then exits. The marker
|
|
269
|
+
// contains a space and adb concatenates the post-`shell` args into one
|
|
270
|
+
// device-side command line, so single-quote it for the device shell to
|
|
271
|
+
// keep it a single token. (The marker is digits/`-`/`:`/`.`/space only.)
|
|
272
|
+
args.push('-t', `'${opts.since}'`);
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
const n = opts.lines && opts.lines > 0 ? Math.floor(opts.lines) : DEFAULT_LOG_LINES;
|
|
276
|
+
args.push('-t', String(n));
|
|
277
|
+
}
|
|
278
|
+
if (opts.appId) {
|
|
279
|
+
// Scope to the app's process when it's alive. If pidof is empty the app
|
|
280
|
+
// isn't running (likely crashed) — fall through to a system-wide dump so
|
|
281
|
+
// the FATAL EXCEPTION, still in the crash buffer, is not missed.
|
|
282
|
+
const pid = this.shell(['pidof', opts.appId]).trim().split(/\s+/)[0];
|
|
283
|
+
if (pid)
|
|
284
|
+
args.push(`--pid=${pid}`);
|
|
285
|
+
}
|
|
286
|
+
return this.shell(args, 15000);
|
|
287
|
+
}
|
|
288
|
+
deviceTime() {
|
|
289
|
+
// logcat's default timestamp is MM-DD HH:MM:SS.mmm in the device's LOCAL time.
|
|
290
|
+
// Sample it from the device clock with a space-free format (so no device-shell
|
|
291
|
+
// quoting is needed), then restore the space to match logcat's `-t` form.
|
|
292
|
+
try {
|
|
293
|
+
return this.shell(['date', '+%m-%dT%H:%M:%S.000']).trim().replace('T', ' ');
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return '';
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
exports.AdbDriver = AdbDriver;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SimctlDriver = exports.AdbDriver = void 0;
|
|
4
|
+
exports.getDriver = getDriver;
|
|
5
|
+
const adb_1 = require("./adb");
|
|
6
|
+
const simctl_1 = require("./simctl");
|
|
7
|
+
var adb_2 = require("./adb");
|
|
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; } });
|
|
11
|
+
function getDriver(platform, device) {
|
|
12
|
+
return platform === 'ios' ? new simctl_1.SimctlDriver(device) : new adb_1.AdbDriver(device);
|
|
13
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
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;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// CliError carries the process exit code so the dispatcher can map failures to
|
|
3
|
+
// stable, agent-readable exit statuses:
|
|
4
|
+
// 0 success / found / assertion passed
|
|
5
|
+
// 1 not found / assertion failed / wait timeout
|
|
6
|
+
// 2 usage error or ambiguous selector (caller must refine)
|
|
7
|
+
// 3 environment error (adb/simctl missing, no/multiple devices, dump failed)
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.AmbiguousSelectorError = exports.SelectorNotFoundError = exports.envError = exports.notFound = exports.usageError = exports.CliError = void 0;
|
|
10
|
+
class CliError extends Error {
|
|
11
|
+
exitCode;
|
|
12
|
+
constructor(message, exitCode) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.exitCode = exitCode;
|
|
15
|
+
this.name = 'CliError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
exports.CliError = CliError;
|
|
19
|
+
const usageError = (m) => new CliError(m, 2);
|
|
20
|
+
exports.usageError = usageError;
|
|
21
|
+
const notFound = (m) => new CliError(m, 1);
|
|
22
|
+
exports.notFound = notFound;
|
|
23
|
+
const envError = (m) => new CliError(m, 3);
|
|
24
|
+
exports.envError = envError;
|
|
25
|
+
// --- Selector-resolution errors (heal triggers for the agent runner) --------
|
|
26
|
+
//
|
|
27
|
+
// A selector miss (zero matches) and an ambiguous match (>1) are still ordinary
|
|
28
|
+
// CliErrors with the same exit codes as before (1 and 2) — printing, exit codes,
|
|
29
|
+
// and `instanceof CliError` are unchanged for every existing caller. They are
|
|
30
|
+
// subclassed only so the `vk ai` engine can tell a *resolvable-by-repair* failure
|
|
31
|
+
// (these) apart from an assertion failure (`assert` returns exit 1, never throws),
|
|
32
|
+
// which it must never "heal" or it would mask a real regression.
|
|
33
|
+
/** Selector matched zero elements. Exit 1. The agent runner treats it as a heal trigger. */
|
|
34
|
+
class SelectorNotFoundError extends CliError {
|
|
35
|
+
constructor(message) {
|
|
36
|
+
super(message, 1);
|
|
37
|
+
this.name = 'SelectorNotFoundError';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
exports.SelectorNotFoundError = SelectorNotFoundError;
|
|
41
|
+
/** Selector matched >1 element. Exit 2. Carries the candidates so the agent runner
|
|
42
|
+
* can ask the model to disambiguate (a heal trigger) instead of aborting. */
|
|
43
|
+
class AmbiguousSelectorError extends CliError {
|
|
44
|
+
candidates;
|
|
45
|
+
constructor(message, candidates) {
|
|
46
|
+
super(message, 2);
|
|
47
|
+
this.candidates = candidates;
|
|
48
|
+
this.name = 'AmbiguousSelectorError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
exports.AmbiguousSelectorError = AmbiguousSelectorError;
|
package/dist/exec.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runText = runText;
|
|
4
|
+
exports.runBinary = runBinary;
|
|
5
|
+
const node_child_process_1 = require("node:child_process");
|
|
6
|
+
const errors_1 = require("./errors");
|
|
7
|
+
const MAX_BUFFER = 64 * 1024 * 1024; // screenshots can be a few MB
|
|
8
|
+
function describeError(cmd, args, err) {
|
|
9
|
+
if (err.code === 'ENOENT') {
|
|
10
|
+
return new errors_1.CliError(`'${cmd}' was not found on PATH. Is it installed and on your PATH?`, 3);
|
|
11
|
+
}
|
|
12
|
+
if (err.code === 'ETIMEDOUT') {
|
|
13
|
+
return new errors_1.CliError(`'${cmd} ${args.join(' ')}' timed out`, 3);
|
|
14
|
+
}
|
|
15
|
+
return new errors_1.CliError(`Failed to run '${cmd}': ${err.message}`, 3);
|
|
16
|
+
}
|
|
17
|
+
/** Run a command and capture stdout/stderr as UTF-8 text. */
|
|
18
|
+
function runText(cmd, args, opts = {}) {
|
|
19
|
+
const r = (0, node_child_process_1.spawnSync)(cmd, args, {
|
|
20
|
+
encoding: 'utf8',
|
|
21
|
+
timeout: opts.timeout ?? 30000,
|
|
22
|
+
input: opts.input,
|
|
23
|
+
maxBuffer: MAX_BUFFER,
|
|
24
|
+
});
|
|
25
|
+
if (r.error)
|
|
26
|
+
throw describeError(cmd, args, r.error);
|
|
27
|
+
return { code: r.status ?? 0, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
|
28
|
+
}
|
|
29
|
+
/** Run a command and capture stdout as raw bytes (e.g. PNG screenshots). */
|
|
30
|
+
function runBinary(cmd, args, opts = {}) {
|
|
31
|
+
const r = (0, node_child_process_1.spawnSync)(cmd, args, {
|
|
32
|
+
timeout: opts.timeout ?? 30000,
|
|
33
|
+
maxBuffer: MAX_BUFFER,
|
|
34
|
+
});
|
|
35
|
+
if (r.error)
|
|
36
|
+
throw describeError(cmd, args, r.error);
|
|
37
|
+
return {
|
|
38
|
+
code: r.status ?? 0,
|
|
39
|
+
stdout: r.stdout ?? Buffer.alloc(0),
|
|
40
|
+
stderr: r.stderr?.toString('utf8') ?? '',
|
|
41
|
+
};
|
|
42
|
+
}
|