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
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1298 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parsePoint = parsePoint;
|
|
4
|
+
exports.healNote = healNote;
|
|
5
|
+
exports.parseDuration = parseDuration;
|
|
6
|
+
exports.waitWindowMs = waitWindowMs;
|
|
7
|
+
exports.waitNote = waitNote;
|
|
8
|
+
exports.confineToCwd = confineToCwd;
|
|
9
|
+
exports.assertSafeAppId = assertSafeAppId;
|
|
10
|
+
exports.chooseLogOpts = chooseLogOpts;
|
|
11
|
+
exports.evalAssert = evalAssert;
|
|
12
|
+
exports.tokenizeLine = tokenizeLine;
|
|
13
|
+
exports.withBatchGlobals = withBatchGlobals;
|
|
14
|
+
exports.run = run;
|
|
15
|
+
const node_fs_1 = require("node:fs");
|
|
16
|
+
const node_path_1 = require("node:path");
|
|
17
|
+
const args_1 = require("./args");
|
|
18
|
+
const errors_1 = require("./errors");
|
|
19
|
+
const exec_1 = require("./exec");
|
|
20
|
+
const drivers_1 = require("./drivers");
|
|
21
|
+
const selector_1 = require("./ui/selector");
|
|
22
|
+
const format_1 = require("./ui/format");
|
|
23
|
+
const output_1 = require("./output");
|
|
24
|
+
const run_1 = require("./run");
|
|
25
|
+
const image_1 = require("./image");
|
|
26
|
+
const engine_1 = require("./agent/engine");
|
|
27
|
+
const claude_1 = require("./agent/claude");
|
|
28
|
+
const cache_1 = require("./agent/cache");
|
|
29
|
+
const cost_1 = require("./agent/cost");
|
|
30
|
+
const version_1 = require("./version");
|
|
31
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
32
|
+
function platformFromFlags(flags) {
|
|
33
|
+
if ((0, args_1.flagBool)(flags, 'ios'))
|
|
34
|
+
return 'ios';
|
|
35
|
+
if ((0, args_1.flagBool)(flags, 'android'))
|
|
36
|
+
return 'android';
|
|
37
|
+
const p = (0, args_1.flagStr)(flags, 'platform');
|
|
38
|
+
if (p === 'ios' || p === 'android')
|
|
39
|
+
return p;
|
|
40
|
+
if (p)
|
|
41
|
+
throw new errors_1.CliError(`Unknown platform '${p}' (use android|ios)`, 2);
|
|
42
|
+
return 'android';
|
|
43
|
+
}
|
|
44
|
+
function deviceFromFlags(flags, platform) {
|
|
45
|
+
return ((0, args_1.flagStr)(flags, 'device') ||
|
|
46
|
+
process.env.VERIKUN_DEVICE ||
|
|
47
|
+
(platform === 'android' ? process.env.ANDROID_SERIAL : undefined) ||
|
|
48
|
+
undefined);
|
|
49
|
+
}
|
|
50
|
+
function buildSelector(raw, flags) {
|
|
51
|
+
if (!raw) {
|
|
52
|
+
throw new errors_1.CliError('Missing selector. e.g. `@login_button`, `text:Login`, `desc:Submit`.', 2);
|
|
53
|
+
}
|
|
54
|
+
return (0, selector_1.parseSelector)(raw, { contains: (0, args_1.flagBool)(flags, 'contains'), index: (0, args_1.flagNum)(flags, 'index') });
|
|
55
|
+
}
|
|
56
|
+
function parsePoint(s) {
|
|
57
|
+
const m = /^(-?\d+)\s*,\s*(-?\d+)$/.exec(s.trim());
|
|
58
|
+
if (!m)
|
|
59
|
+
throw new errors_1.CliError(`Expected coordinates as x,y but got '${s}'`, 2);
|
|
60
|
+
return { x: +m[1], y: +m[2] };
|
|
61
|
+
}
|
|
62
|
+
/** A short note appended to action output when the selector matched non-exactly. */
|
|
63
|
+
function healNote(tier) {
|
|
64
|
+
return tier && tier !== 'exact' ? ` (healed: ${tier} match)` : '';
|
|
65
|
+
}
|
|
66
|
+
// --- Auto-wait on selector lookups -----------------------------------------
|
|
67
|
+
// A selector-resolving command does not fail the instant a lookup misses: it
|
|
68
|
+
// re-captures the hierarchy and retries until the (lenient) match succeeds or a
|
|
69
|
+
// wait window elapses (default 5s). A straightforward flow can then skip explicit
|
|
70
|
+
// `wait` calls — fewer round-trips, fewer tokens — while `--no-wait` / `--wait 0`
|
|
71
|
+
// restores fail-fast. Ambiguity (a present-but-plural match) is never waited on:
|
|
72
|
+
// the elements are already there, so it surfaces at once.
|
|
73
|
+
const DEFAULT_WAIT_MS = 5000;
|
|
74
|
+
const DEFAULT_POLL_MS = 300;
|
|
75
|
+
/** Parse a duration: a bare number is milliseconds (CLI convention), or `5s` / `800ms`. */
|
|
76
|
+
function parseDuration(raw, flag) {
|
|
77
|
+
const m = /^(\d+(?:\.\d+)?)\s*(ms|s|m)?$/.exec(raw.trim());
|
|
78
|
+
if (!m)
|
|
79
|
+
throw new errors_1.CliError(`--${flag} must be a duration like 5000, 5s, 800ms, or 15m; got '${raw}'`, 2);
|
|
80
|
+
const n = Number(m[1]);
|
|
81
|
+
const scale = m[2] === 's' ? 1000 : m[2] === 'm' ? 60000 : 1;
|
|
82
|
+
return Math.max(0, Math.round(n * scale));
|
|
83
|
+
}
|
|
84
|
+
/** Wait window (ms) for selector lookups: `--no-wait`/`--wait 0` → 0; else `--wait <dur>`, else 5s. */
|
|
85
|
+
function waitWindowMs(flags) {
|
|
86
|
+
if ((0, args_1.flagBool)(flags, 'no-wait'))
|
|
87
|
+
return 0;
|
|
88
|
+
const v = flags['wait'];
|
|
89
|
+
if (v === undefined || v === true)
|
|
90
|
+
return DEFAULT_WAIT_MS; // absent, or bare `--wait` → default
|
|
91
|
+
return parseDuration(String(v), 'wait');
|
|
92
|
+
}
|
|
93
|
+
/** A short note appended to a confirmation when the action had to wait for its target. */
|
|
94
|
+
function waitNote(ms) {
|
|
95
|
+
return ms >= 100 ? ` (waited ${(ms / 1000).toFixed(1)}s)` : '';
|
|
96
|
+
}
|
|
97
|
+
/** Poll interval (ms) for auto-wait, capped so a sleep never overshoots the deadline. */
|
|
98
|
+
function pollStep(flags, deadline) {
|
|
99
|
+
const interval = (0, args_1.flagNum)(flags, 'interval') ?? DEFAULT_POLL_MS;
|
|
100
|
+
return Math.min(interval, Math.max(0, deadline - Date.now()));
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* matchElements with auto-wait: re-capture + re-match until at least one element
|
|
104
|
+
* matches or the window elapses. Returns the final result either way (empty on miss).
|
|
105
|
+
*/
|
|
106
|
+
async function matchWaiting(ctx, sel, opts = {}) {
|
|
107
|
+
const deadline = Date.now() + waitWindowMs(ctx.flags);
|
|
108
|
+
for (;;) {
|
|
109
|
+
const res = (0, selector_1.matchElements)(ctx.driver.getElements(opts), sel);
|
|
110
|
+
if (res.matches.length > 0 || Date.now() >= deadline)
|
|
111
|
+
return res;
|
|
112
|
+
await sleep(pollStep(ctx.flags, deadline));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* resolveOne with auto-wait: poll until exactly one element resolves. A hit (1) or
|
|
117
|
+
* an ambiguous (>1) match returns/throws at once via resolveOne — only an empty
|
|
118
|
+
* result is retried. On a final miss, throws not-found (exit 1), noting the wait.
|
|
119
|
+
*/
|
|
120
|
+
async function resolveOneWaiting(ctx, sel, opts = {}) {
|
|
121
|
+
const windowMs = waitWindowMs(ctx.flags);
|
|
122
|
+
const start = Date.now();
|
|
123
|
+
const deadline = start + windowMs;
|
|
124
|
+
for (;;) {
|
|
125
|
+
const els = ctx.driver.getElements(opts);
|
|
126
|
+
if ((0, selector_1.matchElements)(els, sel).matches.length >= 1) {
|
|
127
|
+
const { element, tier } = (0, selector_1.resolveOne)(els, sel); // 1 → resolved; >1 → throws ambiguity
|
|
128
|
+
return { element, tier, waitedMs: Date.now() - start };
|
|
129
|
+
}
|
|
130
|
+
if (Date.now() >= deadline) {
|
|
131
|
+
const waited = windowMs > 0 ? ` after ${(windowMs / 1000).toFixed(1)}s` : '';
|
|
132
|
+
throw new errors_1.SelectorNotFoundError(`No element matched selector '${sel.raw}'${waited}. Run \`verikun ui\` to inspect the current screen.`);
|
|
133
|
+
}
|
|
134
|
+
await sleep(pollStep(ctx.flags, deadline));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// Commands
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
function cmdDevices(ctx) {
|
|
141
|
+
const allDevices = [];
|
|
142
|
+
try {
|
|
143
|
+
allDevices.push(...new drivers_1.AdbDriver().listDevices());
|
|
144
|
+
}
|
|
145
|
+
catch (e) {
|
|
146
|
+
// adb not on PATH is the common (expected) case, but surface anything else so a real
|
|
147
|
+
// adb listing failure isn't hidden behind a silently-empty device list.
|
|
148
|
+
(0, output_1.err)(`devices: adb backend unavailable (${e.message})`);
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
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));
|
|
153
|
+
}
|
|
154
|
+
catch (e) {
|
|
155
|
+
(0, output_1.err)(`devices: simctl backend unavailable (${e.message})`);
|
|
156
|
+
}
|
|
157
|
+
if ((0, args_1.flagBool)(ctx.flags, 'json')) {
|
|
158
|
+
(0, output_1.json)(allDevices);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
if (!allDevices.length) {
|
|
162
|
+
(0, output_1.err)('No devices found.');
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
165
|
+
for (const d of allDevices) {
|
|
166
|
+
(0, output_1.out)([d.platform, d.serial, d.state, d.model ?? '', d.product ? `(${d.product})` : '', d.note ? `[${d.note}]` : '']
|
|
167
|
+
.filter(Boolean)
|
|
168
|
+
.join('\t'));
|
|
169
|
+
}
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
function cmdDoctor(ctx) {
|
|
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;
|
|
179
|
+
}
|
|
180
|
+
const adb = process.env.ADB || 'adb';
|
|
181
|
+
try {
|
|
182
|
+
(0, output_1.out)('adb: ' + (0, exec_1.runText)(adb, ['version']).stdout.split('\n')[0]);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
(0, output_1.err)('adb: NOT FOUND on PATH');
|
|
186
|
+
return 3;
|
|
187
|
+
}
|
|
188
|
+
const devices = ctx.driver.listDevices();
|
|
189
|
+
const usable = devices.filter((d) => d.state === 'device');
|
|
190
|
+
(0, output_1.out)(`devices: ${devices.length} attached, ${usable.length} usable`);
|
|
191
|
+
for (const d of devices)
|
|
192
|
+
(0, output_1.out)(` ${d.serial} ${d.state}${d.model ? ` (${d.model})` : ''}`);
|
|
193
|
+
let ok = true;
|
|
194
|
+
if (usable.length !== 1 && !ctx.device) {
|
|
195
|
+
(0, output_1.err)(usable.length ? ' -> multiple devices: pass --device for interaction commands' : ' -> no usable device');
|
|
196
|
+
ok = false;
|
|
197
|
+
}
|
|
198
|
+
if (usable.length === 1 || ctx.device) {
|
|
199
|
+
try {
|
|
200
|
+
const serial = ctx.device || usable[0].serial;
|
|
201
|
+
const keys = ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale'];
|
|
202
|
+
const get = (k) => (0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'get', 'global', k]).stdout.trim();
|
|
203
|
+
const vals = keys.map(get);
|
|
204
|
+
const off = vals.every((v) => v === '0' || v === '0.0');
|
|
205
|
+
(0, output_1.out)(`animations: ${vals.join('/')} ${off ? '(off, good)' : '(ON — flaky dumps; run `verikun doctor --fix`)'}`);
|
|
206
|
+
if ((0, args_1.flagBool)(ctx.flags, 'fix') && !off) {
|
|
207
|
+
for (const k of keys)
|
|
208
|
+
(0, exec_1.runText)(adb, ['-s', serial, 'shell', 'settings', 'put', 'global', k, '0']);
|
|
209
|
+
(0, output_1.out)('animations: disabled (good)');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
(0, output_1.err)('animations: could not read device settings');
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return ok ? 0 : 3;
|
|
217
|
+
}
|
|
218
|
+
function cmdUi(ctx) {
|
|
219
|
+
const els = ctx.driver.getElements({ all: (0, args_1.flagBool)(ctx.flags, 'all') });
|
|
220
|
+
if ((0, args_1.flagBool)(ctx.flags, 'json')) {
|
|
221
|
+
(0, output_1.json)(els.map(format_1.toJsonShape));
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
(0, output_1.out)((0, args_1.flagBool)(ctx.flags, 'tree') ? (0, format_1.formatTree)(els) : (0, format_1.formatCompact)(els));
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
async function cmdFind(ctx) {
|
|
228
|
+
const sel = buildSelector(ctx.positionals[0], ctx.flags);
|
|
229
|
+
const { matches, tier } = await matchWaiting(ctx, sel, { all: (0, args_1.flagBool)(ctx.flags, 'all') });
|
|
230
|
+
if ((0, args_1.flagBool)(ctx.flags, 'json'))
|
|
231
|
+
(0, output_1.json)(matches.map(format_1.toJsonShape));
|
|
232
|
+
else if (!matches.length)
|
|
233
|
+
(0, output_1.err)(`no match for '${sel.raw}'`);
|
|
234
|
+
else {
|
|
235
|
+
(0, output_1.out)((0, format_1.formatCompact)(matches));
|
|
236
|
+
if (tier && tier !== 'exact')
|
|
237
|
+
(0, output_1.err)(`(healed: matched via ${tier}, not exact)`);
|
|
238
|
+
}
|
|
239
|
+
return matches.length ? 0 : 1;
|
|
240
|
+
}
|
|
241
|
+
async function cmdTap(ctx) {
|
|
242
|
+
const at = (0, args_1.flagStr)(ctx.flags, 'at');
|
|
243
|
+
if (at) {
|
|
244
|
+
const p = parsePoint(at);
|
|
245
|
+
ctx.driver.tap(p.x, p.y);
|
|
246
|
+
ctx.record?.note({ message: `tapped coordinates (${p.x},${p.y})` });
|
|
247
|
+
(0, output_1.out)(`tapped (${p.x},${p.y})`);
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
const raw = ctx.positionals[0];
|
|
251
|
+
// Bare integer == tap the element with that index from the latest `ui` snapshot.
|
|
252
|
+
// An index points at a specific prior dump, so it is single-shot (never waited on).
|
|
253
|
+
const isBareIndex = raw !== undefined &&
|
|
254
|
+
/^\d+$/.test(raw) &&
|
|
255
|
+
ctx.flags['index'] === undefined &&
|
|
256
|
+
!raw.startsWith('@') &&
|
|
257
|
+
!/^(id|text|desc|class):/.test(raw);
|
|
258
|
+
let target;
|
|
259
|
+
let tier = null;
|
|
260
|
+
let waitedMs = 0;
|
|
261
|
+
if (isBareIndex) {
|
|
262
|
+
const els = ctx.driver.getElements({ all: (0, args_1.flagBool)(ctx.flags, 'all') });
|
|
263
|
+
const idx = Number(raw);
|
|
264
|
+
const found = els.find((e) => e.index === idx);
|
|
265
|
+
if (!found)
|
|
266
|
+
throw new errors_1.CliError(`No element with index [${idx}] on the current screen. Run \`verikun ui\`.`, 1);
|
|
267
|
+
target = found;
|
|
268
|
+
ctx.record?.note({ element: target, message: `tapped by index [${idx}]` });
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
const sel = buildSelector(raw, ctx.flags);
|
|
272
|
+
({ element: target, tier, waitedMs } = await resolveOneWaiting(ctx, sel, { all: (0, args_1.flagBool)(ctx.flags, 'all') }));
|
|
273
|
+
ctx.record?.note({ selector: sel, tier, element: target });
|
|
274
|
+
}
|
|
275
|
+
ctx.driver.tap(target.center.x, target.center.y);
|
|
276
|
+
(0, output_1.out)(`tapped ${(0, format_1.formatInline)(target)}${healNote(tier)}${waitNote(waitedMs)}`);
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
async function cmdText(ctx) {
|
|
280
|
+
if (ctx.positionals.length < 2) {
|
|
281
|
+
throw new errors_1.CliError('Usage: verikun text <selector> <text...> (use -- before text starting with "-")', 2);
|
|
282
|
+
}
|
|
283
|
+
const sel = buildSelector(ctx.positionals[0], ctx.flags);
|
|
284
|
+
const value = ctx.positionals.slice(1).join(' ');
|
|
285
|
+
const { element: target, tier, waitedMs } = await resolveOneWaiting(ctx, sel);
|
|
286
|
+
ctx.record?.note({
|
|
287
|
+
selector: sel,
|
|
288
|
+
tier,
|
|
289
|
+
element: target,
|
|
290
|
+
message: target.password ? 'typed «redacted»' : `typed ${JSON.stringify(value)}`,
|
|
291
|
+
});
|
|
292
|
+
ctx.driver.tap(target.center.x, target.center.y);
|
|
293
|
+
// Wait for field to be focused after tap
|
|
294
|
+
await sleep(100);
|
|
295
|
+
if ((0, args_1.flagBool)(ctx.flags, 'clear') && target.text) {
|
|
296
|
+
ctx.driver.pressKey('move_end');
|
|
297
|
+
for (let i = 0; i < target.text.length + 2; i++)
|
|
298
|
+
ctx.driver.pressKey('del');
|
|
299
|
+
// Wait for field to settle after clearing before typing
|
|
300
|
+
await sleep(200);
|
|
301
|
+
}
|
|
302
|
+
// Prime the input method with a space, then delete it, to avoid losing first character
|
|
303
|
+
// (workaround for adb input text behavior where first char is sometimes lost)
|
|
304
|
+
ctx.driver.inputText(' ');
|
|
305
|
+
ctx.driver.pressKey('backspace');
|
|
306
|
+
ctx.driver.inputText(value);
|
|
307
|
+
if ((0, args_1.flagBool)(ctx.flags, 'enter'))
|
|
308
|
+
ctx.driver.pressKey('enter');
|
|
309
|
+
(0, output_1.out)(`typed ${JSON.stringify(value)} into ${(0, format_1.formatInline)(target)}${healNote(tier)}${waitNote(waitedMs)}`);
|
|
310
|
+
return 0;
|
|
311
|
+
}
|
|
312
|
+
function cmdType(ctx) {
|
|
313
|
+
const value = ctx.positionals.join(' ');
|
|
314
|
+
if (!value)
|
|
315
|
+
throw new errors_1.CliError('Usage: verikun type <text...> (types into the focused field)', 2);
|
|
316
|
+
ctx.driver.inputText(value);
|
|
317
|
+
if ((0, args_1.flagBool)(ctx.flags, 'enter'))
|
|
318
|
+
ctx.driver.pressKey('enter');
|
|
319
|
+
ctx.record?.note({ message: `typed ${value.length} char(s) into focused field` });
|
|
320
|
+
(0, output_1.out)(`typed ${JSON.stringify(value)}`);
|
|
321
|
+
return 0;
|
|
322
|
+
}
|
|
323
|
+
function cmdKey(ctx) {
|
|
324
|
+
const name = ctx.positionals[0];
|
|
325
|
+
if (!name)
|
|
326
|
+
throw new errors_1.CliError('Usage: verikun key <name|code>', 2);
|
|
327
|
+
ctx.driver.pressKey(name);
|
|
328
|
+
ctx.record?.note({ message: `key ${name}` });
|
|
329
|
+
(0, output_1.out)(`key ${name}`);
|
|
330
|
+
return 0;
|
|
331
|
+
}
|
|
332
|
+
function quickKey(ctx, name) {
|
|
333
|
+
ctx.driver.pressKey(name);
|
|
334
|
+
ctx.record?.note({ message: `key ${name}` });
|
|
335
|
+
(0, output_1.out)(name);
|
|
336
|
+
return 0;
|
|
337
|
+
}
|
|
338
|
+
async function cmdSwipe(ctx) {
|
|
339
|
+
const duration = (0, args_1.flagNum)(ctx.flags, 'duration') ?? 300;
|
|
340
|
+
const from = (0, args_1.flagStr)(ctx.flags, 'from');
|
|
341
|
+
const to = (0, args_1.flagStr)(ctx.flags, 'to');
|
|
342
|
+
if (from && to) {
|
|
343
|
+
const a = parsePoint(from);
|
|
344
|
+
const b = parsePoint(to);
|
|
345
|
+
ctx.driver.swipe(a.x, a.y, b.x, b.y, duration);
|
|
346
|
+
ctx.record?.note({ message: `swiped (${a.x},${a.y})->(${b.x},${b.y})` });
|
|
347
|
+
(0, output_1.out)(`swiped (${a.x},${a.y})->(${b.x},${b.y})`);
|
|
348
|
+
return 0;
|
|
349
|
+
}
|
|
350
|
+
const dir = ctx.positionals[0];
|
|
351
|
+
if (!dir) {
|
|
352
|
+
throw new errors_1.CliError('Usage: verikun swipe <up|down|left|right> [--on <selector>] | --from x,y --to x,y', 2);
|
|
353
|
+
}
|
|
354
|
+
// Region the swipe happens within: the whole screen, or one element via --on.
|
|
355
|
+
let region;
|
|
356
|
+
let waitedMs = 0;
|
|
357
|
+
const on = (0, args_1.flagStr)(ctx.flags, 'on');
|
|
358
|
+
if (on) {
|
|
359
|
+
const onSel = (0, selector_1.parseSelector)(on, { contains: (0, args_1.flagBool)(ctx.flags, 'contains') });
|
|
360
|
+
const { element, waitedMs: w } = await resolveOneWaiting(ctx, onSel);
|
|
361
|
+
waitedMs = w;
|
|
362
|
+
ctx.record?.note({ selector: onSel, element });
|
|
363
|
+
region = element.bounds;
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
const { width, height } = ctx.driver.screenSize();
|
|
367
|
+
region = { x1: 0, y1: 0, x2: width, y2: height };
|
|
368
|
+
}
|
|
369
|
+
const cx = Math.floor((region.x1 + region.x2) / 2);
|
|
370
|
+
const cy = Math.floor((region.y1 + region.y2) / 2);
|
|
371
|
+
const frac = Math.min(Math.max((0, args_1.flagNum)(ctx.flags, 'distance') ?? 0.6, 0.1), 0.95);
|
|
372
|
+
const dx = Math.floor(((region.x2 - region.x1) * frac) / 2);
|
|
373
|
+
const dy = Math.floor(((region.y2 - region.y1) * frac) / 2);
|
|
374
|
+
let a;
|
|
375
|
+
let b;
|
|
376
|
+
switch (dir) {
|
|
377
|
+
case 'up':
|
|
378
|
+
a = { x: cx, y: cy + dy };
|
|
379
|
+
b = { x: cx, y: cy - dy };
|
|
380
|
+
break;
|
|
381
|
+
case 'down':
|
|
382
|
+
a = { x: cx, y: cy - dy };
|
|
383
|
+
b = { x: cx, y: cy + dy };
|
|
384
|
+
break;
|
|
385
|
+
case 'left':
|
|
386
|
+
a = { x: cx + dx, y: cy };
|
|
387
|
+
b = { x: cx - dx, y: cy };
|
|
388
|
+
break;
|
|
389
|
+
case 'right':
|
|
390
|
+
a = { x: cx - dx, y: cy };
|
|
391
|
+
b = { x: cx + dx, y: cy };
|
|
392
|
+
break;
|
|
393
|
+
default:
|
|
394
|
+
throw new errors_1.CliError(`Unknown direction '${dir}' (use up|down|left|right)`, 2);
|
|
395
|
+
}
|
|
396
|
+
ctx.driver.swipe(a.x, a.y, b.x, b.y, duration);
|
|
397
|
+
ctx.record?.note({ message: `swiped ${dir}${on ? ` on ${on}` : ''}` });
|
|
398
|
+
(0, output_1.out)(`swiped ${dir}${waitNote(waitedMs)}`);
|
|
399
|
+
return 0;
|
|
400
|
+
}
|
|
401
|
+
// Screenshots are downscaled by default so an agent reading them spends fewer
|
|
402
|
+
// tokens (image cost scales with pixel area) — we rarely need much detail to tell
|
|
403
|
+
// what's on screen, and text stays legible at a small size. The cap is the
|
|
404
|
+
// longest edge in px: the default is deliberately small; --more bumps it up,
|
|
405
|
+
// --max <px> sets an exact cap, VERIKUN_SHOT_MAX_EDGE changes the default, and
|
|
406
|
+
// --full writes the original.
|
|
407
|
+
const DEFAULT_SHOT_MAX_EDGE = 700;
|
|
408
|
+
const MORE_SHOT_MAX_EDGE = 1400;
|
|
409
|
+
function shotMaxEdge() {
|
|
410
|
+
const env = process.env.VERIKUN_SHOT_MAX_EDGE;
|
|
411
|
+
if (env) {
|
|
412
|
+
const n = Number(env);
|
|
413
|
+
if (Number.isFinite(n) && n >= 1)
|
|
414
|
+
return n;
|
|
415
|
+
}
|
|
416
|
+
return DEFAULT_SHOT_MAX_EDGE;
|
|
417
|
+
}
|
|
418
|
+
/** Resolve an `--out` path and confine it to the working directory. A host-side write
|
|
419
|
+
* (a screenshot PNG, captured device logs) must never land outside cwd via a `..`
|
|
420
|
+
* traversal or an absolute path — including when driven by `vk ai` model output, whose
|
|
421
|
+
* leaf flags `validateNode` does not constrain. Exported for unit tests. */
|
|
422
|
+
function confineToCwd(outFlag) {
|
|
423
|
+
const cwd = (0, node_path_1.resolve)(process.cwd());
|
|
424
|
+
const path = (0, node_path_1.resolve)(cwd, outFlag);
|
|
425
|
+
if (path !== cwd && !path.startsWith(cwd + node_path_1.sep)) {
|
|
426
|
+
throw new errors_1.CliError(`--out must stay within the current directory; '${outFlag}' resolves outside it.`, 2);
|
|
427
|
+
}
|
|
428
|
+
return path;
|
|
429
|
+
}
|
|
430
|
+
/** A package / bundle id is handed to `adb shell`, which re-concatenates its args into
|
|
431
|
+
* one device-side command line — so a value with shell metacharacters would inject into
|
|
432
|
+
* the device shell. Valid Android package / iOS bundle ids are only `[A-Za-z0-9._-]`;
|
|
433
|
+
* reject anything else. This is the trust gate for `launch` / `stop` / `clear`, all
|
|
434
|
+
* reachable from `vk ai` model output. Exported for unit tests. */
|
|
435
|
+
function assertSafeAppId(appId) {
|
|
436
|
+
if (!/^[A-Za-z0-9._-]+$/.test(appId)) {
|
|
437
|
+
throw new errors_1.CliError(`Invalid app id '${appId}': only letters, digits, '.', '_' and '-' are allowed.`, 2);
|
|
438
|
+
}
|
|
439
|
+
return appId;
|
|
440
|
+
}
|
|
441
|
+
function cmdScreenshot(ctx) {
|
|
442
|
+
const raw = ctx.driver.screenshot();
|
|
443
|
+
// Precedence: --full (original) > --max <px> (explicit) > --more (preset) > default.
|
|
444
|
+
const maxEdge = (0, args_1.flagNum)(ctx.flags, 'max') ?? ((0, args_1.flagBool)(ctx.flags, 'more') ? MORE_SHOT_MAX_EDGE : shotMaxEdge());
|
|
445
|
+
const res = (0, args_1.flagBool)(ctx.flags, 'full') ? null : (0, image_1.downscalePng)(raw, maxEdge);
|
|
446
|
+
const buf = res?.scaled ? res.buf : raw;
|
|
447
|
+
const outFlag = (0, args_1.flagStr)(ctx.flags, 'out');
|
|
448
|
+
const path = outFlag ? confineToCwd(outFlag) : (0, output_1.defaultScreenshotPath)();
|
|
449
|
+
(0, node_fs_1.writeFileSync)(path, buf);
|
|
450
|
+
ctx.record?.attachImage(buf);
|
|
451
|
+
ctx.record?.note({ message: res?.scaled ? `${path} (${res.width}×${res.height})` : path });
|
|
452
|
+
// Surface the one case worth knowing about: we wanted to shrink but couldn't.
|
|
453
|
+
if (res && !res.scaled && res.reason?.startsWith('unsupported')) {
|
|
454
|
+
(0, output_1.err)(`screenshot not downscaled: ${res.reason}`);
|
|
455
|
+
}
|
|
456
|
+
if ((0, args_1.flagBool)(ctx.flags, 'json')) {
|
|
457
|
+
(0, output_1.json)({
|
|
458
|
+
path,
|
|
459
|
+
bytes: buf.length,
|
|
460
|
+
...(res?.scaled
|
|
461
|
+
? { width: res.width, height: res.height, scaledFrom: { width: res.origWidth, height: res.origHeight } }
|
|
462
|
+
: {}),
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
(0, output_1.out)(path);
|
|
467
|
+
if (res?.scaled)
|
|
468
|
+
(0, output_1.err)(`scaled ${res.origWidth}×${res.origHeight} -> ${res.width}×${res.height} (max edge ${maxEdge}px; --more for detail, --full for original)`);
|
|
469
|
+
}
|
|
470
|
+
return 0;
|
|
471
|
+
}
|
|
472
|
+
// --full asks for everything; cap it large-but-finite so output stays under the
|
|
473
|
+
// exec MAX_BUFFER (the driver still does a single bounded dump, never a stream).
|
|
474
|
+
const FULL_LOG_LINES = 100000;
|
|
475
|
+
/**
|
|
476
|
+
* Pick the logcat window for `vk log`. Precedence:
|
|
477
|
+
* --since <marker> > -n/--lines <count> > --full > session window > last DEFAULT_LOG_LINES
|
|
478
|
+
* The session window (the run's device-clock start, when recording past the first
|
|
479
|
+
* step) is the default, so logs from before the run started are excluded.
|
|
480
|
+
* Exported for unit tests.
|
|
481
|
+
*/
|
|
482
|
+
function chooseLogOpts(flags, ctx) {
|
|
483
|
+
const appId = ctx.appId;
|
|
484
|
+
const sinceFlag = (0, args_1.flagStr)(flags, 'since');
|
|
485
|
+
if (sinceFlag)
|
|
486
|
+
return { since: sinceFlag, appId };
|
|
487
|
+
const explicitLines = (0, args_1.flagNum)(flags, 'lines');
|
|
488
|
+
if (explicitLines !== undefined)
|
|
489
|
+
return { lines: explicitLines, appId };
|
|
490
|
+
if ((0, args_1.flagBool)(flags, 'full'))
|
|
491
|
+
return { lines: FULL_LOG_LINES, appId };
|
|
492
|
+
if (ctx.sessionSince)
|
|
493
|
+
return { since: ctx.sessionSince, appId };
|
|
494
|
+
return { appId };
|
|
495
|
+
}
|
|
496
|
+
function cmdLog(ctx) {
|
|
497
|
+
const opts = chooseLogOpts(ctx.flags, {
|
|
498
|
+
appId: ctx.positionals[0], // optional package; omitted = system-wide
|
|
499
|
+
sessionSince: ctx.record?.logWindowStart(),
|
|
500
|
+
});
|
|
501
|
+
const logs = ctx.driver.getLogs(opts);
|
|
502
|
+
// Recorded so the on-demand capture lands in the archived report (when a run is active).
|
|
503
|
+
ctx.record?.attachLog(logs);
|
|
504
|
+
const lineCount = logs === '' ? 0 : logs.replace(/\n+$/, '').split('\n').length;
|
|
505
|
+
const outFlag = (0, args_1.flagStr)(ctx.flags, 'out');
|
|
506
|
+
if (outFlag) {
|
|
507
|
+
// Keep --out inside the working directory (device logs can contain secrets).
|
|
508
|
+
const path = confineToCwd(outFlag);
|
|
509
|
+
(0, node_fs_1.writeFileSync)(path, logs);
|
|
510
|
+
if ((0, args_1.flagBool)(ctx.flags, 'json'))
|
|
511
|
+
(0, output_1.json)({ path, bytes: Buffer.byteLength(logs), lines: lineCount });
|
|
512
|
+
else
|
|
513
|
+
(0, output_1.out)(path);
|
|
514
|
+
return 0;
|
|
515
|
+
}
|
|
516
|
+
if ((0, args_1.flagBool)(ctx.flags, 'json')) {
|
|
517
|
+
(0, output_1.json)({ logs, lines: lineCount, ...(opts.appId ? { app: opts.appId } : {}), ...(opts.since ? { since: opts.since } : {}) });
|
|
518
|
+
return 0;
|
|
519
|
+
}
|
|
520
|
+
(0, output_1.out)(logs);
|
|
521
|
+
return 0;
|
|
522
|
+
}
|
|
523
|
+
async function cmdWait(ctx) {
|
|
524
|
+
const sel = buildSelector(ctx.positionals[0], ctx.flags);
|
|
525
|
+
const gone = (0, args_1.flagBool)(ctx.flags, 'gone');
|
|
526
|
+
const timeout = (0, args_1.flagNum)(ctx.flags, 'timeout') ?? 10000;
|
|
527
|
+
const interval = (0, args_1.flagNum)(ctx.flags, 'interval') ?? 400;
|
|
528
|
+
const deadline = Date.now() + timeout;
|
|
529
|
+
while (Date.now() < deadline) {
|
|
530
|
+
const { matches, tier } = (0, selector_1.matchElements)(ctx.driver.getElements(), sel);
|
|
531
|
+
if (gone ? matches.length === 0 : matches.length > 0) {
|
|
532
|
+
ctx.record?.note({ selector: sel, tier, element: matches[0], message: gone ? 'gone' : `${matches.length} match(es)` });
|
|
533
|
+
if (gone)
|
|
534
|
+
(0, output_1.out)(`gone: '${sel.raw}'`);
|
|
535
|
+
else
|
|
536
|
+
(0, output_1.out)((0, format_1.formatCompact)(matches));
|
|
537
|
+
return 0;
|
|
538
|
+
}
|
|
539
|
+
await sleep(interval);
|
|
540
|
+
}
|
|
541
|
+
ctx.record?.note({ selector: sel, message: `timeout after ${timeout}ms${gone ? ' (still present)' : ' (never appeared)'}` });
|
|
542
|
+
(0, output_1.err)(`timeout after ${timeout}ms waiting for '${sel.raw}'${gone ? ' to disappear' : ''}`);
|
|
543
|
+
return 1;
|
|
544
|
+
}
|
|
545
|
+
/** Evaluate an assertion against a single captured snapshot. */
|
|
546
|
+
function evalAssert(els, sel, flags) {
|
|
547
|
+
const { matches } = (0, selector_1.matchElements)(els, sel);
|
|
548
|
+
const gone = (0, args_1.flagBool)(flags, 'gone');
|
|
549
|
+
const wantText = (0, args_1.flagStr)(flags, 'text');
|
|
550
|
+
let pass;
|
|
551
|
+
let reason;
|
|
552
|
+
if (gone) {
|
|
553
|
+
pass = matches.length === 0;
|
|
554
|
+
reason = pass ? 'absent' : `still present (${matches.length})`;
|
|
555
|
+
}
|
|
556
|
+
else if (matches.length === 0) {
|
|
557
|
+
pass = false;
|
|
558
|
+
reason = 'not found';
|
|
559
|
+
}
|
|
560
|
+
else if (wantText !== undefined) {
|
|
561
|
+
const contains = (0, args_1.flagBool)(flags, 'contains');
|
|
562
|
+
pass = matches.some((m) => contains
|
|
563
|
+
? m.text.toLowerCase().includes(wantText.toLowerCase())
|
|
564
|
+
: m.text.trim().toLowerCase() === wantText.trim().toLowerCase());
|
|
565
|
+
reason = pass ? 'text matched' : `found, but text != ${JSON.stringify(wantText)} (got ${JSON.stringify(matches.map((m) => m.text))})`;
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
pass = true;
|
|
569
|
+
reason = `found ${matches.length}`;
|
|
570
|
+
}
|
|
571
|
+
return { pass, reason, matches };
|
|
572
|
+
}
|
|
573
|
+
async function cmdAssert(ctx) {
|
|
574
|
+
const sel = buildSelector(ctx.positionals[0], ctx.flags);
|
|
575
|
+
// Auto-wait subsumes the common "wait then assert": poll until the assertion
|
|
576
|
+
// passes or the window elapses. `--gone` therefore waits for disappearance.
|
|
577
|
+
const deadline = Date.now() + waitWindowMs(ctx.flags);
|
|
578
|
+
let result = evalAssert(ctx.driver.getElements(), sel, ctx.flags);
|
|
579
|
+
while (!result.pass && Date.now() < deadline) {
|
|
580
|
+
await sleep(pollStep(ctx.flags, deadline));
|
|
581
|
+
result = evalAssert(ctx.driver.getElements(), sel, ctx.flags);
|
|
582
|
+
}
|
|
583
|
+
const { pass, reason, matches } = result;
|
|
584
|
+
ctx.record?.note({ selector: sel, element: matches[0], message: `${pass ? 'PASS' : 'FAIL'} — ${reason}` });
|
|
585
|
+
if ((0, args_1.flagBool)(ctx.flags, 'json'))
|
|
586
|
+
(0, output_1.json)({ pass, selector: sel.raw, reason, matches: matches.map(format_1.toJsonShape) });
|
|
587
|
+
else
|
|
588
|
+
(0, output_1.out)(`${pass ? 'PASS' : 'FAIL'} ${sel.raw} — ${reason}`);
|
|
589
|
+
return pass ? 0 : 1;
|
|
590
|
+
}
|
|
591
|
+
function cmdLaunch(ctx) {
|
|
592
|
+
const appId = ctx.positionals[0];
|
|
593
|
+
if (!appId)
|
|
594
|
+
throw new errors_1.CliError('Usage: verikun launch <package|bundleId> [--clear] [--no-restart]', 2);
|
|
595
|
+
assertSafeAppId(appId);
|
|
596
|
+
// launch RESTARTS by default: if the app is already running/foregrounded, re-issuing
|
|
597
|
+
// the launch intent just delivers it to the live (possibly mid-flow, stale) instance
|
|
598
|
+
// instead of giving a fresh start — which makes reruns flaky. So we force-stop first.
|
|
599
|
+
// force-stop is a no-op when the app isn't running, so no "is it running?" probe is
|
|
600
|
+
// needed (and none would be portable — the iOS backend has no foreground query).
|
|
601
|
+
// --clear wipes data first via `pm clear` (which already force-stops) → fresh install
|
|
602
|
+
// --no-restart opt out of the force-stop (just bring the existing instance forward)
|
|
603
|
+
const cleared = (0, args_1.flagBool)(ctx.flags, 'clear');
|
|
604
|
+
const noRestart = (0, args_1.flagBool)(ctx.flags, 'no-restart');
|
|
605
|
+
if (cleared && noRestart) {
|
|
606
|
+
throw new errors_1.CliError('Cannot combine --clear with --no-restart: --clear wipes data and force-stops (a restart).', 2);
|
|
607
|
+
}
|
|
608
|
+
if (cleared)
|
|
609
|
+
ctx.driver.clearApp(appId);
|
|
610
|
+
else if (!noRestart)
|
|
611
|
+
ctx.driver.stop(appId);
|
|
612
|
+
ctx.driver.launch(appId);
|
|
613
|
+
const how = cleared ? 'cleared data + launched' : noRestart ? 'launched' : 'restarted';
|
|
614
|
+
ctx.record?.note({ message: `${how} ${appId}` });
|
|
615
|
+
if ((0, args_1.flagBool)(ctx.flags, 'json'))
|
|
616
|
+
(0, output_1.json)({ launched: appId, cleared, restarted: !cleared && !noRestart });
|
|
617
|
+
else
|
|
618
|
+
(0, output_1.out)(`${how} ${appId}`);
|
|
619
|
+
return 0;
|
|
620
|
+
}
|
|
621
|
+
function cmdStop(ctx) {
|
|
622
|
+
const appId = ctx.positionals[0];
|
|
623
|
+
if (!appId)
|
|
624
|
+
throw new errors_1.CliError('Usage: verikun stop <package|bundleId>', 2);
|
|
625
|
+
assertSafeAppId(appId);
|
|
626
|
+
ctx.driver.stop(appId);
|
|
627
|
+
ctx.record?.note({ message: `stopped ${appId}` });
|
|
628
|
+
(0, output_1.out)(`stopped ${appId}`);
|
|
629
|
+
return 0;
|
|
630
|
+
}
|
|
631
|
+
function cmdClear(ctx) {
|
|
632
|
+
const appId = ctx.positionals[0];
|
|
633
|
+
if (!appId)
|
|
634
|
+
throw new errors_1.CliError('Usage: verikun clear <package|bundleId>', 2);
|
|
635
|
+
assertSafeAppId(appId);
|
|
636
|
+
ctx.driver.clearApp(appId);
|
|
637
|
+
ctx.record?.note({ message: `cleared app data for ${appId}` });
|
|
638
|
+
if ((0, args_1.flagBool)(ctx.flags, 'json'))
|
|
639
|
+
(0, output_1.json)({ cleared: appId });
|
|
640
|
+
else
|
|
641
|
+
(0, output_1.out)(`cleared ${appId}`);
|
|
642
|
+
return 0;
|
|
643
|
+
}
|
|
644
|
+
function cmdCurrent(ctx) {
|
|
645
|
+
(0, output_1.out)(ctx.driver.currentApp());
|
|
646
|
+
return 0;
|
|
647
|
+
}
|
|
648
|
+
// Manage the active test run. Needs no device, so it is dispatched before the
|
|
649
|
+
// driver is built and is itself never recorded as a step.
|
|
650
|
+
function cmdRun(positionals, flags, platform, device) {
|
|
651
|
+
const sub = (positionals[0] ?? 'status').toLowerCase();
|
|
652
|
+
const asJson = (0, args_1.flagBool)(flags, 'json');
|
|
653
|
+
const tally = (steps) => ({
|
|
654
|
+
passed: steps.filter((s) => s.status === 'passed').length,
|
|
655
|
+
failed: steps.filter((s) => s.status !== 'passed').length,
|
|
656
|
+
});
|
|
657
|
+
switch (sub) {
|
|
658
|
+
case 'start': {
|
|
659
|
+
const state = run_1.Recorder.start(positionals[1], platform, device, (0, args_1.flagBool)(flags, 'force'));
|
|
660
|
+
if (asJson)
|
|
661
|
+
(0, output_1.json)({ started: state.id, name: state.name });
|
|
662
|
+
else
|
|
663
|
+
(0, output_1.err)(`started test run '${state.name}' (${state.id})`);
|
|
664
|
+
return 0;
|
|
665
|
+
}
|
|
666
|
+
case 'status': {
|
|
667
|
+
const state = run_1.Recorder.status();
|
|
668
|
+
if (asJson) {
|
|
669
|
+
(0, output_1.json)(state ?? { active: false });
|
|
670
|
+
return 0;
|
|
671
|
+
}
|
|
672
|
+
if (!state) {
|
|
673
|
+
(0, output_1.out)('no active test run');
|
|
674
|
+
return 0;
|
|
675
|
+
}
|
|
676
|
+
const { passed, failed } = tally(state.steps);
|
|
677
|
+
(0, output_1.out)(`run '${state.name}' (${state.id})${state.implicit ? ' [implicit]' : ''}: ${state.steps.length} step(s), ${passed} passed, ${failed} failed/error`);
|
|
678
|
+
(0, output_1.out)(` ${run_1.Recorder.contextLine(state)}`);
|
|
679
|
+
for (const s of state.steps)
|
|
680
|
+
(0, output_1.out)(` #${s.index} ${s.status.toUpperCase()} ${s.name} (${s.durationMs}ms)`);
|
|
681
|
+
return 0;
|
|
682
|
+
}
|
|
683
|
+
case 'clear':
|
|
684
|
+
case 'stop':
|
|
685
|
+
case 'discard': {
|
|
686
|
+
const cleared = run_1.Recorder.clear();
|
|
687
|
+
if (asJson)
|
|
688
|
+
(0, output_1.json)({ cleared: cleared?.id ?? null });
|
|
689
|
+
else
|
|
690
|
+
(0, output_1.out)(cleared ? `discarded test run '${cleared.name}' (${cleared.steps.length} step(s))` : 'no active test run');
|
|
691
|
+
return 0;
|
|
692
|
+
}
|
|
693
|
+
case 'archive':
|
|
694
|
+
case 'finish':
|
|
695
|
+
case 'save': {
|
|
696
|
+
const { dir, xmlPath, htmlPath, state } = run_1.Recorder.archive(positionals[1]);
|
|
697
|
+
const { passed, failed } = tally(state.steps);
|
|
698
|
+
if (asJson) {
|
|
699
|
+
(0, output_1.json)({ archived: dir, report: htmlPath, junit: xmlPath, steps: state.steps.length, passed, failed });
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
(0, output_1.out)(dir); // primary result: the archived run directory
|
|
703
|
+
(0, output_1.err)(`archived '${state.name}': ${state.steps.length} step(s), ${passed} passed, ${failed} failed/error`);
|
|
704
|
+
(0, output_1.err)(` JUnit: ${xmlPath}`);
|
|
705
|
+
(0, output_1.err)(` HTML: ${htmlPath}`);
|
|
706
|
+
}
|
|
707
|
+
// Exit non-zero when the run contained failures, so CI can gate on it.
|
|
708
|
+
return failed > 0 ? 1 : 0;
|
|
709
|
+
}
|
|
710
|
+
default:
|
|
711
|
+
throw new errors_1.CliError(`Unknown 'run' subcommand '${sub}'. Use: start | status | archive | clear.`, 2);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// ---------------------------------------------------------------------------
|
|
715
|
+
// batch — run many commands from stdin or a --file, one per line
|
|
716
|
+
// ---------------------------------------------------------------------------
|
|
717
|
+
//
|
|
718
|
+
// Each non-blank, non-`#` line is parsed and executed exactly as if it had been
|
|
719
|
+
// its own `vk` invocation: same driver resolution, same auto-wait, same recording
|
|
720
|
+
// into the active test run, same stdout/stderr/exit semantics. Lines run in order
|
|
721
|
+
// and the batch STOPS at the first command that exits non-zero, propagating that
|
|
722
|
+
// code — a failed step means the flow's assumptions no longer hold, so continuing
|
|
723
|
+
// would be meaningless ("break on an irrecoverable error"). Device/output globals
|
|
724
|
+
// on the `batch` call are inherited by every line unless the line sets its own.
|
|
725
|
+
const BATCH_GLOBALS = ['device', 'platform', 'ios', 'android', 'json'];
|
|
726
|
+
/** Read the batch source text: --file if given, else stdin (which must be piped). */
|
|
727
|
+
function readBatchSource(flags) {
|
|
728
|
+
const file = (0, args_1.flagStr)(flags, 'file');
|
|
729
|
+
if (file) {
|
|
730
|
+
const path = (0, node_path_1.resolve)(process.cwd(), file);
|
|
731
|
+
try {
|
|
732
|
+
return (0, node_fs_1.readFileSync)(path, 'utf8');
|
|
733
|
+
}
|
|
734
|
+
catch (e) {
|
|
735
|
+
throw new errors_1.CliError(`batch: cannot read --file '${file}' (${e.message})`, 2);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
// No --file: read newline-separated commands piped on stdin. A TTY means nothing
|
|
739
|
+
// was piped, so guide the caller instead of blocking forever on input.
|
|
740
|
+
if (process.stdin.isTTY) {
|
|
741
|
+
throw new errors_1.CliError('batch: no commands. Pipe them on stdin, or pass --file <path>.\n' +
|
|
742
|
+
" printf 'tap @login\\nassert text:Home\\n' | vk batch\n" +
|
|
743
|
+
' vk batch --file flow.txt', 2);
|
|
744
|
+
}
|
|
745
|
+
try {
|
|
746
|
+
return (0, node_fs_1.readFileSync)(0, 'utf8'); // fd 0 == stdin
|
|
747
|
+
}
|
|
748
|
+
catch (e) {
|
|
749
|
+
throw new errors_1.CliError(`batch: could not read stdin (${e.message})`, 2);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Split a batch line into argv tokens with shell-like single/double quoting and
|
|
754
|
+
* backslash escapes — but WITHOUT a shell: this is pure string scanning, so a line
|
|
755
|
+
* can never spawn a host process or expand a variable (the same no-host-shell rule
|
|
756
|
+
* the rest of the CLI follows). Throws on an unterminated quote.
|
|
757
|
+
*/
|
|
758
|
+
function tokenizeLine(line) {
|
|
759
|
+
const tokens = [];
|
|
760
|
+
let cur = '';
|
|
761
|
+
let started = false; // lets an empty "" / '' still produce a real empty token
|
|
762
|
+
for (let i = 0; i < line.length;) {
|
|
763
|
+
const c = line[i];
|
|
764
|
+
if (c === '"' || c === "'") {
|
|
765
|
+
started = true;
|
|
766
|
+
i++;
|
|
767
|
+
while (i < line.length && line[i] !== c) {
|
|
768
|
+
if (c === '"' && line[i] === '\\' && (line[i + 1] === '"' || line[i + 1] === '\\')) {
|
|
769
|
+
cur += line[i + 1];
|
|
770
|
+
i += 2;
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
cur += line[i++];
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (i >= line.length) {
|
|
777
|
+
throw new errors_1.CliError(`batch: unterminated ${c === '"' ? 'double' : 'single'} quote in: ${line}`, 2);
|
|
778
|
+
}
|
|
779
|
+
i++; // consume the closing quote
|
|
780
|
+
}
|
|
781
|
+
else if (c === '\\' && i + 1 < line.length) {
|
|
782
|
+
cur += line[i + 1];
|
|
783
|
+
started = true;
|
|
784
|
+
i += 2;
|
|
785
|
+
}
|
|
786
|
+
else if (c === ' ' || c === '\t') {
|
|
787
|
+
if (started) {
|
|
788
|
+
tokens.push(cur);
|
|
789
|
+
cur = '';
|
|
790
|
+
started = false;
|
|
791
|
+
}
|
|
792
|
+
i++;
|
|
793
|
+
}
|
|
794
|
+
else {
|
|
795
|
+
cur += c;
|
|
796
|
+
started = true;
|
|
797
|
+
i++;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (started)
|
|
801
|
+
tokens.push(cur);
|
|
802
|
+
return tokens;
|
|
803
|
+
}
|
|
804
|
+
/** Globals on the `batch` call become defaults for each line (the line may override). */
|
|
805
|
+
function withBatchGlobals(lineFlags, batchFlags) {
|
|
806
|
+
const merged = { ...lineFlags };
|
|
807
|
+
for (const k of BATCH_GLOBALS) {
|
|
808
|
+
if (merged[k] === undefined && batchFlags[k] !== undefined)
|
|
809
|
+
merged[k] = batchFlags[k];
|
|
810
|
+
}
|
|
811
|
+
return merged;
|
|
812
|
+
}
|
|
813
|
+
async function cmdBatch(positionals, batchFlags) {
|
|
814
|
+
let source;
|
|
815
|
+
try {
|
|
816
|
+
if (positionals.length > 0) {
|
|
817
|
+
throw new errors_1.CliError(`batch: unexpected argument '${positionals[0]}'. Pipe commands on stdin or pass --file <path>.`, 2);
|
|
818
|
+
}
|
|
819
|
+
source = readBatchSource(batchFlags);
|
|
820
|
+
}
|
|
821
|
+
catch (e) {
|
|
822
|
+
return mapError(e, batchFlags);
|
|
823
|
+
}
|
|
824
|
+
// Number lines first (so messages point at the true source line), then drop
|
|
825
|
+
// blank lines and `#` comments.
|
|
826
|
+
const all = source.split(/\r?\n/).map((text, i) => ({ n: i + 1, text: text.trim() }));
|
|
827
|
+
const commands = all.filter((l) => l.text.length > 0 && !l.text.startsWith('#'));
|
|
828
|
+
const quiet = (0, args_1.flagBool)(batchFlags, 'quiet');
|
|
829
|
+
if (commands.length === 0) {
|
|
830
|
+
(0, output_1.err)('[verikun] batch: no commands to run');
|
|
831
|
+
return 0;
|
|
832
|
+
}
|
|
833
|
+
for (const { n, text } of commands) {
|
|
834
|
+
let code;
|
|
835
|
+
try {
|
|
836
|
+
const { command, positionals: pos, flags } = (0, args_1.parseArgs)(tokenizeLine(text));
|
|
837
|
+
if (!command)
|
|
838
|
+
continue; // tokens were all flags — nothing to run
|
|
839
|
+
if (command === 'batch') {
|
|
840
|
+
throw new errors_1.CliError(`batch: a batch line may not itself be 'batch' (line ${n})`, 2);
|
|
841
|
+
}
|
|
842
|
+
if (!quiet)
|
|
843
|
+
(0, output_1.err)(`[verikun] batch ${n}: ${text}`);
|
|
844
|
+
code = await executeParsed(command, pos, withBatchGlobals(flags, batchFlags));
|
|
845
|
+
}
|
|
846
|
+
catch (e) {
|
|
847
|
+
// A malformed line (bad quoting, nested batch) is itself an error to halt on.
|
|
848
|
+
code = mapError(e, batchFlags);
|
|
849
|
+
}
|
|
850
|
+
if (code !== 0) {
|
|
851
|
+
(0, output_1.err)(`[verikun] batch stopped at line ${n} (\`${text}\`) — exit ${code}`);
|
|
852
|
+
return code;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (!quiet)
|
|
856
|
+
(0, output_1.err)(`[verikun] batch: ${commands.length} command(s) ok`);
|
|
857
|
+
return 0;
|
|
858
|
+
}
|
|
859
|
+
// ---------------------------------------------------------------------------
|
|
860
|
+
// ai — compile a natural-language test to a plan IR, then run it (self-healing)
|
|
861
|
+
// ---------------------------------------------------------------------------
|
|
862
|
+
//
|
|
863
|
+
// `vk ai <file>` reads a plain-English test, compiles it ONCE into a deterministic
|
|
864
|
+
// plan IR via the model (cached by NL + app build), then replays it with NO model
|
|
865
|
+
// calls on the happy path. The model is woken only to repair a step that fails to
|
|
866
|
+
// resolve its selector; a green run persists the (possibly repaired) plan so the
|
|
867
|
+
// next run is free again. Cost is bounded by --max-cost-usd. Progress streams to
|
|
868
|
+
// stderr (CI liveness — it never goes quiet); stdout carries the final result.
|
|
869
|
+
async function cmdAi(positionals, flags) {
|
|
870
|
+
const file = positionals[0];
|
|
871
|
+
if (!file) {
|
|
872
|
+
throw new errors_1.CliError('Usage: verikun ai <file> [--model m] [--max-cost-usd n] [--timeout dur] [--show-plan] [--recompile]', 2);
|
|
873
|
+
}
|
|
874
|
+
let nl;
|
|
875
|
+
try {
|
|
876
|
+
nl = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(process.cwd(), file), 'utf8');
|
|
877
|
+
}
|
|
878
|
+
catch (e) {
|
|
879
|
+
throw new errors_1.CliError(`ai: cannot read '${file}' (${e.message})`, 2);
|
|
880
|
+
}
|
|
881
|
+
if (!nl.trim())
|
|
882
|
+
throw new errors_1.CliError(`ai: '${file}' is empty`, 2);
|
|
883
|
+
const platform = platformFromFlags(flags);
|
|
884
|
+
const device = deviceFromFlags(flags, platform);
|
|
885
|
+
const model = (0, cost_1.resolveModel)((0, args_1.flagStr)(flags, 'model'));
|
|
886
|
+
const overrideRaw = (0, args_1.flagStr)(flags, 'cost-override');
|
|
887
|
+
const override = overrideRaw ? (0, cost_1.parseCostOverride)(overrideRaw) : undefined;
|
|
888
|
+
const maxCostUsd = (0, args_1.flagNum)(flags, 'max-cost-usd') ?? cost_1.DEFAULT_MAX_COST_USD;
|
|
889
|
+
if (maxCostUsd <= 0)
|
|
890
|
+
throw new errors_1.CliError(`--max-cost-usd must be greater than 0 (got ${maxCostUsd}).`, 2);
|
|
891
|
+
const cost = new cost_1.CostTracker((0, cost_1.priceFor)(model, override), maxCostUsd);
|
|
892
|
+
// Whole-run wall-clock ceiling (default 15m) so a runaway loop/repair can't hang the run.
|
|
893
|
+
const timeoutFlag = (0, args_1.flagStr)(flags, 'timeout');
|
|
894
|
+
const timeoutMs = timeoutFlag ? parseDuration(timeoutFlag, 'timeout') : engine_1.DEFAULT_RUN_TIMEOUT_MS;
|
|
895
|
+
const deadline = Date.now() + timeoutMs;
|
|
896
|
+
const effort = (0, args_1.flagStr)(flags, 'effort');
|
|
897
|
+
const pkg = (0, args_1.flagStr)(flags, 'package');
|
|
898
|
+
const build = (0, args_1.flagStr)(flags, 'app-build');
|
|
899
|
+
const key = { nl, pkg, build, platform };
|
|
900
|
+
const recompile = (0, args_1.flagBool)(flags, 'recompile') || (0, args_1.flagBool)(flags, 'no-cache');
|
|
901
|
+
const showPlan = (0, args_1.flagBool)(flags, 'show-plan');
|
|
902
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
903
|
+
const provider = apiKey ? new claude_1.ClaudeProvider({ model, apiKey, effort }) : null;
|
|
904
|
+
// 1. Obtain the plan: a cache hit (free) or a compile (pays tokens; may seed from
|
|
905
|
+
// a prior build's plan to avoid a full recompile).
|
|
906
|
+
const cached = recompile ? null : (0, cache_1.readPlan)(key);
|
|
907
|
+
let plan;
|
|
908
|
+
if (cached) {
|
|
909
|
+
plan = cached.plan;
|
|
910
|
+
(0, output_1.err)(`[ai] plan cache hit — ${model} not called to compile`);
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
if (!provider) {
|
|
914
|
+
throw new errors_1.CliError('ANTHROPIC_API_KEY is not set — `vk ai` needs it to compile the test. Set it and retry.', 3);
|
|
915
|
+
}
|
|
916
|
+
const seed = (0, cache_1.findSeed)(key);
|
|
917
|
+
if (seed)
|
|
918
|
+
(0, output_1.err)(`[ai] no exact cache; seeding from a prior plan (build ${seed.build ?? 'unknown'})`);
|
|
919
|
+
(0, output_1.err)(`[ai] compiling '${file}' with ${model} (effort ${effort ?? 'default'})…`);
|
|
920
|
+
const compiled = await provider.compile({ nl, pkg, platform, seed: seed?.plan });
|
|
921
|
+
cost.add(compiled.usage, 'compile');
|
|
922
|
+
plan = compiled.plan;
|
|
923
|
+
(0, output_1.err)(`[ai] compiled ${plan.steps.length} top-level step(s) · ${cost.summaryLine()}`);
|
|
924
|
+
// Cache the freshly-compiled plan right away, keyed by the test-text hash, so an
|
|
925
|
+
// unchanged test is never recompiled — even via --show-plan or after a failed run.
|
|
926
|
+
// A green run below re-persists the healed plan; a failed run leaves this clean
|
|
927
|
+
// compile cached (never a half-healed one).
|
|
928
|
+
try {
|
|
929
|
+
(0, cache_1.writePlan)(key, plan);
|
|
930
|
+
}
|
|
931
|
+
catch (e) {
|
|
932
|
+
(0, output_1.err)(`[ai] could not cache compiled plan: ${e.message}`);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
// 2. --show-plan: print the compiled IR and stop (no device run).
|
|
936
|
+
if (showPlan) {
|
|
937
|
+
(0, output_1.json)(plan);
|
|
938
|
+
return 0;
|
|
939
|
+
}
|
|
940
|
+
// Running needs the provider for repair-on-failure; a cache hit with no key can't repair.
|
|
941
|
+
if (!provider) {
|
|
942
|
+
throw new errors_1.CliError('ANTHROPIC_API_KEY is not set — `vk ai` needs it to repair a failing step at runtime.', 3);
|
|
943
|
+
}
|
|
944
|
+
// The budget is a TOTAL-run ceiling: if the compile alone already crossed it, abort
|
|
945
|
+
// before running. A cache hit spends nothing, so a free replay is still allowed.
|
|
946
|
+
if (!cached && cost.exceeded()) {
|
|
947
|
+
(0, output_1.err)(`[ai] cost ceiling $${maxCostUsd} reached during compile (${cost.summaryLine()}) — not running`);
|
|
948
|
+
return 1;
|
|
949
|
+
}
|
|
950
|
+
// 3. One explicit run + one shared driver for the whole flow (so rollover can't
|
|
951
|
+
// split the test, and we don't rebuild a driver per step).
|
|
952
|
+
const existing = run_1.Recorder.status();
|
|
953
|
+
if (existing && existing.steps.length > 0) {
|
|
954
|
+
// Seal the pre-existing run into the archive instead of letting start(force=true)
|
|
955
|
+
// discard it — a manual in-progress run should never be silently lost.
|
|
956
|
+
const sealed = run_1.Recorder.archive();
|
|
957
|
+
(0, output_1.err)(`[ai] archived the active run ('${existing.name}', ${existing.steps.length} step(s)) → ${sealed.dir}`);
|
|
958
|
+
}
|
|
959
|
+
run_1.Recorder.start(`ai: ${(0, node_path_1.basename)(file)}`, platform, device, true);
|
|
960
|
+
const driver = (0, drivers_1.getDriver)(platform, device);
|
|
961
|
+
// Suppress per-step `out()` so stdout stays the one final result; progress -> stderr.
|
|
962
|
+
const prevQuiet = (0, output_1.setOutputQuiet)(true);
|
|
963
|
+
let result;
|
|
964
|
+
try {
|
|
965
|
+
result = await (0, engine_1.runPlan)(plan, {
|
|
966
|
+
exec: (command, pos, f) => executeOutcome(command, pos, f, driver),
|
|
967
|
+
getElements: () => driver.getElements(),
|
|
968
|
+
provider,
|
|
969
|
+
cost,
|
|
970
|
+
log: (m) => (0, output_1.err)(m),
|
|
971
|
+
markHealed: (m) => run_1.Recorder.markLastStepHealed(m),
|
|
972
|
+
maxRepairs: 3,
|
|
973
|
+
deadline,
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
catch (e) {
|
|
977
|
+
// An unexpected throw mid-run (e.g. an unrecoverable device error) must still
|
|
978
|
+
// seal the run so it is not left dangling in .verikun/run/ for the next command
|
|
979
|
+
// to roll over. Then let the error map to an exit code as usual.
|
|
980
|
+
run_1.Recorder.annotateRun({ ai: { ok: false, cost: cost.summaryLine(), modelRepairs: 0, improvements: [] } });
|
|
981
|
+
try {
|
|
982
|
+
run_1.Recorder.archive();
|
|
983
|
+
}
|
|
984
|
+
catch (sealErr) {
|
|
985
|
+
// Best-effort seal in an error path; surface a failure (the run state may itself be
|
|
986
|
+
// unreadable) but still throw the ORIGINAL error below.
|
|
987
|
+
(0, output_1.err)(`[ai] could not archive the run after a mid-run error (${sealErr.message})`);
|
|
988
|
+
}
|
|
989
|
+
throw e;
|
|
990
|
+
}
|
|
991
|
+
finally {
|
|
992
|
+
(0, output_1.setOutputQuiet)(prevQuiet);
|
|
993
|
+
}
|
|
994
|
+
// 4. Persist the (possibly repaired) plan only on a fully-green run; attach the
|
|
995
|
+
// cost + improvements summary to the run; archive into the report.
|
|
996
|
+
const costLine = cost.summaryLine();
|
|
997
|
+
if (result.ok) {
|
|
998
|
+
try {
|
|
999
|
+
(0, cache_1.writePlan)(key, result.plan);
|
|
1000
|
+
(0, output_1.err)('[ai] cached the green plan for next run');
|
|
1001
|
+
}
|
|
1002
|
+
catch (e) {
|
|
1003
|
+
(0, output_1.err)(`[ai] could not cache plan: ${e.message}`);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
run_1.Recorder.annotateRun({
|
|
1007
|
+
ai: { ok: result.ok, cost: costLine, modelRepairs: result.modelRepairs, improvements: result.improvements },
|
|
1008
|
+
});
|
|
1009
|
+
const { dir, xmlPath, htmlPath } = run_1.Recorder.archive();
|
|
1010
|
+
const status = result.ok
|
|
1011
|
+
? 'PASS'
|
|
1012
|
+
: result.abortedForBudget
|
|
1013
|
+
? `ABORTED — cost ceiling $${maxCostUsd} reached`
|
|
1014
|
+
: result.abortedForTimeout
|
|
1015
|
+
? `ABORTED — run timeout (${Math.round(timeoutMs / 1000)}s) reached`
|
|
1016
|
+
: `FAIL at ${result.failure?.where}: ${result.failure?.reason}`;
|
|
1017
|
+
(0, output_1.err)(`[ai] ${status} · ${costLine}`);
|
|
1018
|
+
(0, output_1.err)(`[ai] report: ${htmlPath}`);
|
|
1019
|
+
if (result.improvements.length) {
|
|
1020
|
+
(0, output_1.err)(`[ai] ${result.improvements.length} suggested improvement(s) (also in the report):`);
|
|
1021
|
+
for (const imp of result.improvements)
|
|
1022
|
+
(0, output_1.err)(' - ' + imp);
|
|
1023
|
+
}
|
|
1024
|
+
(0, output_1.err)(`[ai] estimated total cost: $${cost.usd().toFixed(4)}`);
|
|
1025
|
+
if ((0, args_1.flagBool)(flags, 'json')) {
|
|
1026
|
+
(0, output_1.json)({
|
|
1027
|
+
ok: result.ok,
|
|
1028
|
+
model,
|
|
1029
|
+
cost: costLine,
|
|
1030
|
+
costUsd: Number(cost.usd().toFixed(4)),
|
|
1031
|
+
modelRepairs: result.modelRepairs,
|
|
1032
|
+
improvements: result.improvements,
|
|
1033
|
+
report: htmlPath,
|
|
1034
|
+
junit: xmlPath,
|
|
1035
|
+
runDir: dir,
|
|
1036
|
+
...(result.failure ? { failure: result.failure } : {}),
|
|
1037
|
+
...(result.abortedForBudget ? { abortedForBudget: true } : {}),
|
|
1038
|
+
...(result.abortedForTimeout ? { abortedForTimeout: true } : {}),
|
|
1039
|
+
});
|
|
1040
|
+
}
|
|
1041
|
+
else {
|
|
1042
|
+
(0, output_1.out)(htmlPath); // primary machine result: the report path
|
|
1043
|
+
}
|
|
1044
|
+
return result.ok ? 0 : 1;
|
|
1045
|
+
}
|
|
1046
|
+
// ---------------------------------------------------------------------------
|
|
1047
|
+
// Dispatch
|
|
1048
|
+
// ---------------------------------------------------------------------------
|
|
1049
|
+
async function executeCommand(command, ctx) {
|
|
1050
|
+
switch (command) {
|
|
1051
|
+
case 'devices':
|
|
1052
|
+
return cmdDevices(ctx);
|
|
1053
|
+
case 'doctor':
|
|
1054
|
+
return cmdDoctor(ctx);
|
|
1055
|
+
case 'ui':
|
|
1056
|
+
case 'dump':
|
|
1057
|
+
return cmdUi(ctx);
|
|
1058
|
+
case 'find':
|
|
1059
|
+
return await cmdFind(ctx);
|
|
1060
|
+
case 'tap':
|
|
1061
|
+
case 'click':
|
|
1062
|
+
return await cmdTap(ctx);
|
|
1063
|
+
case 'text':
|
|
1064
|
+
return await cmdText(ctx);
|
|
1065
|
+
case 'type':
|
|
1066
|
+
return cmdType(ctx);
|
|
1067
|
+
case 'key':
|
|
1068
|
+
return cmdKey(ctx);
|
|
1069
|
+
case 'back':
|
|
1070
|
+
return quickKey(ctx, 'back');
|
|
1071
|
+
case 'home':
|
|
1072
|
+
return quickKey(ctx, 'home');
|
|
1073
|
+
case 'enter':
|
|
1074
|
+
return quickKey(ctx, 'enter');
|
|
1075
|
+
case 'swipe':
|
|
1076
|
+
case 'scroll':
|
|
1077
|
+
return await cmdSwipe(ctx);
|
|
1078
|
+
case 'screenshot':
|
|
1079
|
+
case 'shot':
|
|
1080
|
+
return cmdScreenshot(ctx);
|
|
1081
|
+
case 'wait':
|
|
1082
|
+
return await cmdWait(ctx);
|
|
1083
|
+
case 'assert':
|
|
1084
|
+
return await cmdAssert(ctx);
|
|
1085
|
+
case 'launch':
|
|
1086
|
+
case 'open':
|
|
1087
|
+
return cmdLaunch(ctx);
|
|
1088
|
+
case 'stop':
|
|
1089
|
+
return cmdStop(ctx);
|
|
1090
|
+
case 'clear':
|
|
1091
|
+
return cmdClear(ctx);
|
|
1092
|
+
case 'current':
|
|
1093
|
+
return cmdCurrent(ctx);
|
|
1094
|
+
case 'log':
|
|
1095
|
+
case 'logs':
|
|
1096
|
+
return cmdLog(ctx);
|
|
1097
|
+
default:
|
|
1098
|
+
(0, output_1.err)(`Unknown command '${command}'. Run \`verikun help\`.`);
|
|
1099
|
+
return 2;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
/** Map a thrown error to an exit code, emitting it as text or JSON per --json. */
|
|
1103
|
+
function mapError(e, flags) {
|
|
1104
|
+
if (e instanceof errors_1.CliError) {
|
|
1105
|
+
if ((0, args_1.flagBool)(flags, 'json'))
|
|
1106
|
+
(0, output_1.json)({ error: e.message, exitCode: e.exitCode });
|
|
1107
|
+
else
|
|
1108
|
+
(0, output_1.err)(e.message);
|
|
1109
|
+
return e.exitCode;
|
|
1110
|
+
}
|
|
1111
|
+
(0, output_1.err)('Unexpected error: ' + e.message);
|
|
1112
|
+
if (process.env.VERIKUN_DEBUG)
|
|
1113
|
+
(0, output_1.err)(e.stack ?? '');
|
|
1114
|
+
return 3;
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Execute one (non-meta) command and return its RAW outcome — the exit code, and
|
|
1118
|
+
* the thrown error if any, WITHOUT mapping it to a printed exit. The agent engine
|
|
1119
|
+
* (`vk ai`) uses this to tell a selector miss / ambiguity (a heal trigger: the error
|
|
1120
|
+
* is a SelectorNotFoundError / AmbiguousSelectorError) apart from an assertion
|
|
1121
|
+
* failure (`assert` *returns* exit 1, never throws — so it must never be healed, or
|
|
1122
|
+
* a real regression would be masked). `executeParsed` wraps it to restore the
|
|
1123
|
+
* print-and-exit behavior the CLI and `batch` rely on.
|
|
1124
|
+
*
|
|
1125
|
+
* An optional `sharedDriver` lets the engine reuse one device handle across many
|
|
1126
|
+
* commands (and its control-flow guards) instead of building a driver per call.
|
|
1127
|
+
*/
|
|
1128
|
+
async function executeOutcome(command, positionals, flags, sharedDriver) {
|
|
1129
|
+
const platform = platformFromFlags(flags);
|
|
1130
|
+
const device = deviceFromFlags(flags, platform);
|
|
1131
|
+
// Recordable commands open a step (auto-starting an implicit run if needed);
|
|
1132
|
+
// the step is finalized with the outcome — and, on failure, screenshot + UI
|
|
1133
|
+
// hierarchy of the page are captured — whether the command returns or throws.
|
|
1134
|
+
const recordable = (0, run_1.isRecordable)(command);
|
|
1135
|
+
let driver = sharedDriver;
|
|
1136
|
+
let recorder = null;
|
|
1137
|
+
try {
|
|
1138
|
+
if (!driver)
|
|
1139
|
+
driver = (0, drivers_1.getDriver)(platform, device);
|
|
1140
|
+
if (recordable) {
|
|
1141
|
+
// Resolve the serial up front (cheap; the driver caches it) so the run can
|
|
1142
|
+
// detect a device change. Tolerate failure — the handler raises the real error.
|
|
1143
|
+
let serial;
|
|
1144
|
+
try {
|
|
1145
|
+
serial = driver.resolvedSerial();
|
|
1146
|
+
}
|
|
1147
|
+
catch {
|
|
1148
|
+
/* surfaced by the command handler below */
|
|
1149
|
+
}
|
|
1150
|
+
recorder = run_1.Recorder.beginStep(command, positionals, flags, platform, device, serial, driver);
|
|
1151
|
+
}
|
|
1152
|
+
const ctx = { driver, platform, device, positionals, flags, record: recorder ?? undefined };
|
|
1153
|
+
const code = await executeCommand(command, ctx);
|
|
1154
|
+
recorder?.finish(code, driver);
|
|
1155
|
+
return { code };
|
|
1156
|
+
}
|
|
1157
|
+
catch (e) {
|
|
1158
|
+
recorder?.finishError(e, driver);
|
|
1159
|
+
return { code: e instanceof errors_1.CliError ? e.exitCode : 3, error: e };
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Run one already-parsed command for the CLI / `batch`: dispatch meta-commands,
|
|
1164
|
+
* else execute it and map any failure to a printed exit code. This is the shared
|
|
1165
|
+
* per-command entry both a top-level `run()` and each `batch` line go through, so a
|
|
1166
|
+
* batched command behaves identically to a standalone invocation.
|
|
1167
|
+
*/
|
|
1168
|
+
async function executeParsed(command, positionals, flags) {
|
|
1169
|
+
const platform = platformFromFlags(flags);
|
|
1170
|
+
const device = deviceFromFlags(flags, platform);
|
|
1171
|
+
// Meta-commands manage local state / orchestrate other commands. They build no
|
|
1172
|
+
// driver of their own and are dispatched before the recording machinery.
|
|
1173
|
+
if (command === 'run')
|
|
1174
|
+
return cmdRun(positionals, flags, platform, device);
|
|
1175
|
+
if (command === 'batch')
|
|
1176
|
+
return cmdBatch(positionals, flags);
|
|
1177
|
+
// `ai` orchestrates its own steps; map its thrown CliErrors to exit codes here
|
|
1178
|
+
// (usage 2 / env 3 / …) so they honor the exit-code contract instead of escaping
|
|
1179
|
+
// to the top-level "Fatal" handler (which would force exit 3).
|
|
1180
|
+
if (command === 'ai') {
|
|
1181
|
+
try {
|
|
1182
|
+
return await cmdAi(positionals, flags);
|
|
1183
|
+
}
|
|
1184
|
+
catch (e) {
|
|
1185
|
+
return mapError(e, flags);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
const { code, error } = await executeOutcome(command, positionals, flags);
|
|
1189
|
+
return error ? mapError(error, flags) : code;
|
|
1190
|
+
}
|
|
1191
|
+
async function run(argv) {
|
|
1192
|
+
const { command, positionals, flags } = (0, args_1.parseArgs)(argv);
|
|
1193
|
+
if ((0, args_1.flagBool)(flags, 'version') || command === 'version') {
|
|
1194
|
+
(0, output_1.out)(version_1.VERSION);
|
|
1195
|
+
return 0;
|
|
1196
|
+
}
|
|
1197
|
+
if (!command || command === 'help' || (0, args_1.flagBool)(flags, 'help')) {
|
|
1198
|
+
(0, output_1.out)(usageText());
|
|
1199
|
+
return command && command !== 'help' && !(0, args_1.flagBool)(flags, 'help') ? 2 : 0;
|
|
1200
|
+
}
|
|
1201
|
+
return executeParsed(command, positionals, flags);
|
|
1202
|
+
}
|
|
1203
|
+
function usageText() {
|
|
1204
|
+
return `verikun ${version_1.VERSION} — drive simulators/devices for AI agents (Puppeteer-style).
|
|
1205
|
+
|
|
1206
|
+
USAGE
|
|
1207
|
+
verikun <command> [args] [flags]
|
|
1208
|
+
|
|
1209
|
+
INSPECT (semantic hierarchy — the core feature)
|
|
1210
|
+
ui [--all] [--tree] [--json] Compact list of interactive/labeled elements
|
|
1211
|
+
find <selector> [--json] Print elements matching a selector (exit 1 if none)
|
|
1212
|
+
assert <selector> [--text S] [--gone] Assertion for tests (exit 0 pass / 1 fail)
|
|
1213
|
+
wait <selector> [--timeout ms] [--interval ms] [--gone] Poll until match/absent
|
|
1214
|
+
current Foreground app/activity
|
|
1215
|
+
log [package] [-n lines] [--since t] [--out path] [--full] [--json] Device logs (logcat snapshot)
|
|
1216
|
+
In a run, defaults to logs since the run started; -n caps lines,
|
|
1217
|
+
--since <MM-DD HH:MM:SS.mmm> overrides, --full dumps everything.
|
|
1218
|
+
Scopes to a package's process (system-wide if it has crashed);
|
|
1219
|
+
recorded into the run so it lands in the report
|
|
1220
|
+
|
|
1221
|
+
ACT
|
|
1222
|
+
tap <selector|index> | --at x,y Tap an element (or raw coordinates)
|
|
1223
|
+
text <selector> <text...> [--clear] [--enter] Focus a field and type
|
|
1224
|
+
type <text...> [--enter] Type into the currently focused field
|
|
1225
|
+
key <name|code> | back | home | enter Send a key event
|
|
1226
|
+
swipe <up|down|left|right> [--on <selector>] [--distance f] [--duration ms]
|
|
1227
|
+
swipe --from x,y --to x,y [--duration ms]
|
|
1228
|
+
screenshot [--out path] [--more] [--max px] [--full] [--json] Save a PNG (default: ./.verikun/screen.png)
|
|
1229
|
+
Downscaled to <=700px longest edge for token-cheap, legible reads;
|
|
1230
|
+
--more bumps detail (1400px), --max px sets an exact cap
|
|
1231
|
+
(VERIKUN_SHOT_MAX_EDGE changes the default), --full keeps original
|
|
1232
|
+
launch <app> [--clear] [--no-restart] stop <app> App lifecycle (launch restarts by
|
|
1233
|
+
default — force-stops first; --clear also wipes app data)
|
|
1234
|
+
clear <app> Wipe app data — login/session, caches (fresh-install state)
|
|
1235
|
+
|
|
1236
|
+
BATCH (script many commands in one process)
|
|
1237
|
+
batch [--file path] [--quiet] Run newline-separated commands — from --file,
|
|
1238
|
+
else piped stdin — each exactly as its own
|
|
1239
|
+
command. Streams each result to stdout; stops
|
|
1240
|
+
and propagates the exit code on the first
|
|
1241
|
+
failure. Blank lines and # comments are skipped.
|
|
1242
|
+
|
|
1243
|
+
AI (run a natural-language test — compile once, replay model-free, self-heal)
|
|
1244
|
+
ai <file> [--model m] [--max-cost-usd n] [--cost-override in/out] [--effort e]
|
|
1245
|
+
[--package pkg] [--app-build id] [--show-plan] [--recompile] [--json]
|
|
1246
|
+
Compile a plain-English test (<file>) into a
|
|
1247
|
+
deterministic plan, cached by NL + app build,
|
|
1248
|
+
then replay it with NO model calls on the happy
|
|
1249
|
+
path. The model is woken only to repair a step
|
|
1250
|
+
that fails to resolve; a green run persists the
|
|
1251
|
+
(repaired) plan so the next run is free. Needs
|
|
1252
|
+
ANTHROPIC_API_KEY. Progress -> stderr; the report
|
|
1253
|
+
path -> stdout. --show-plan prints the compiled
|
|
1254
|
+
IR without running; --recompile ignores the cache.
|
|
1255
|
+
Models: claude-haiku-4-5 | claude-sonnet-4-6
|
|
1256
|
+
(default) | claude-opus-4-8 | claude-fable-5.
|
|
1257
|
+
|
|
1258
|
+
ENVIRONMENT
|
|
1259
|
+
devices [--json] List attached devices/simulators
|
|
1260
|
+
doctor [--fix] Diagnose adb/device; --fix disables animations
|
|
1261
|
+
|
|
1262
|
+
TEST RUNS (actions are recorded; a run auto-starts on first action)
|
|
1263
|
+
run start [name] [--force] Begin a named run (else one starts implicitly)
|
|
1264
|
+
run status Show the active run, its device/session, and steps
|
|
1265
|
+
run archive [name] Write JUnit + HTML report, move to ./.verikun/runs/<id>/
|
|
1266
|
+
run clear Discard the active run with no report
|
|
1267
|
+
An implicit run auto-closes (archives) and rolls over on a device change, a
|
|
1268
|
+
VERIKUN_SESSION change, or VERIKUN_RUN_IDLE_MIN minutes idle (default 30; 0 off).
|
|
1269
|
+
VERIKUN_NO_RUN=1 disables recording entirely.
|
|
1270
|
+
|
|
1271
|
+
SELECTORS
|
|
1272
|
+
@login shorthand for id:login
|
|
1273
|
+
id:login resource-id (full, suffix, or short name)
|
|
1274
|
+
text:Sign in visible text (exact, case-insensitive) [+ --contains for substring]
|
|
1275
|
+
desc:Submit content-desc / accessibility label
|
|
1276
|
+
class:Button type or full class name
|
|
1277
|
+
"Sign in" bare string == text:"Sign in"
|
|
1278
|
+
Modifiers: --contains (substring), --index N (pick Nth match)
|
|
1279
|
+
|
|
1280
|
+
AUTO-WAIT (selector lookups retry until they resolve)
|
|
1281
|
+
Selector commands (tap, text, find, assert, swipe --on) re-poll the screen for
|
|
1282
|
+
up to 5s when a lookup misses, so a settling UI needs no explicit \`wait\`.
|
|
1283
|
+
--wait <dur> override the window: 8s, 800ms, or bare ms (3000); 0 disables
|
|
1284
|
+
--no-wait fail fast on the first miss (same as --wait 0)
|
|
1285
|
+
Ambiguity is never waited on (the elements are already there). The \`wait\`
|
|
1286
|
+
command stays for explicit polling, including --gone, with --timeout/--interval.
|
|
1287
|
+
|
|
1288
|
+
GLOBAL FLAGS
|
|
1289
|
+
-d, --device <serial> target a specific device (or VERIKUN_DEVICE / ANDROID_SERIAL)
|
|
1290
|
+
-p, --platform <android|ios> (default: android; --ios / --android shortcuts)
|
|
1291
|
+
-j, --json machine-readable output
|
|
1292
|
+
-- end flag parsing (so text/args may start with '-')
|
|
1293
|
+
|
|
1294
|
+
EXIT CODES
|
|
1295
|
+
0 success · 1 not found / assertion failed / timeout · 2 usage or ambiguous selector · 3 environment error
|
|
1296
|
+
|
|
1297
|
+
iOS: screenshots + launch/stop work today via simctl; tap/text/swipe/hierarchy need idb (planned).`;
|
|
1298
|
+
}
|