verikun 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -4
- package/dist/agent/cost.js +21 -10
- package/dist/agent/engine.js +8 -8
- package/dist/agent/openai.js +243 -0
- package/dist/agent/remote.js +166 -0
- package/dist/args.js +2 -0
- package/dist/bin/verikun.js +0 -0
- package/dist/cli.js +381 -98
- package/dist/drivers/adb.js +12 -0
- package/dist/drivers/ios.js +7 -0
- package/dist/drivers/simctl.js +156 -0
- package/dist/report.js +93 -3
- package/dist/rpc.js +46 -0
- package/dist/run.js +101 -1
- package/dist/server.js +349 -0
- package/dist/suite.js +152 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/server.js
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// `vk server` — expose THIS machine's connected device to remote verikun clients
|
|
3
|
+
// (`vk ai/suite/install --server <url>`) over HTTP+JSON, Node's built-in http only.
|
|
4
|
+
//
|
|
5
|
+
// Security model (the server is the trust boundary, not the transport):
|
|
6
|
+
// - Mandatory bearer auth: a key is REQUIRED unless --allow-unsafe-anonymous is
|
|
7
|
+
// passed explicitly (for networks that are themselves the boundary, e.g. a
|
|
8
|
+
// private tailnet). If none is configured, one is generated and printed loudly.
|
|
9
|
+
// Comparison is crypto.timingSafeEqual over fixed-width sha256 digests.
|
|
10
|
+
// - /v1/exec accepts ONLY verikun's validated action grammar: every request runs
|
|
11
|
+
// through the SAME validateNode gate that guards `vk ai` model repairs, so only
|
|
12
|
+
// KNOWN_COMMANDS action verbs execute — never `ui`/`log`, never a shell. The
|
|
13
|
+
// server's driver is fixed at startup; client flags can never repoint the device.
|
|
14
|
+
// - /v1/install is a privileged management verb: auth PLUS --allow-install, body
|
|
15
|
+
// streamed to a server-generated temp path (the client supplies only an
|
|
16
|
+
// allowlisted extension — never a path), optional sha256 verification.
|
|
17
|
+
// - Binds 127.0.0.1 unless --bind opts into exposure. One run-token holds the
|
|
18
|
+
// device lock at a time (409 otherwise; an idle lock is taken over so a crashed
|
|
19
|
+
// caller can't wedge the box). Device endpoints are serialized via a mutex.
|
|
20
|
+
//
|
|
21
|
+
// cli.ts reaches this module via a DYNAMIC import (no static cli↔server cycle, and
|
|
22
|
+
// node:http stays off the default CLI load path); this module imports cli.ts's
|
|
23
|
+
// executeForServer statically — a one-way runtime edge.
|
|
24
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
25
|
+
exports.buildServer = buildServer;
|
|
26
|
+
exports.cmdServer = cmdServer;
|
|
27
|
+
const node_http_1 = require("node:http");
|
|
28
|
+
const node_crypto_1 = require("node:crypto");
|
|
29
|
+
const node_fs_1 = require("node:fs");
|
|
30
|
+
const node_os_1 = require("node:os");
|
|
31
|
+
const node_path_1 = require("node:path");
|
|
32
|
+
const node_stream_1 = require("node:stream");
|
|
33
|
+
const promises_1 = require("node:stream/promises");
|
|
34
|
+
const args_1 = require("./args");
|
|
35
|
+
const errors_1 = require("./errors");
|
|
36
|
+
const drivers_1 = require("./drivers");
|
|
37
|
+
const output_1 = require("./output");
|
|
38
|
+
const ir_1 = require("./agent/ir");
|
|
39
|
+
const rpc_1 = require("./rpc");
|
|
40
|
+
const cli_1 = require("./cli");
|
|
41
|
+
const version_1 = require("./version");
|
|
42
|
+
const DEFAULT_PORT = 8391;
|
|
43
|
+
const EXEC_BODY_CAP = 1024 * 1024; // 1 MB of JSON is far beyond any leaf command
|
|
44
|
+
const INSTALL_BODY_CAP = 512 * 1024 * 1024; // 512 MB app build
|
|
45
|
+
// A silent run-token older than this may be taken over by a new one — long enough
|
|
46
|
+
// to survive a client-side compile/repair pause, short enough that a crashed
|
|
47
|
+
// caller doesn't wedge the device.
|
|
48
|
+
const LOCK_IDLE_MS = 5 * 60 * 1000;
|
|
49
|
+
/** An error that already knows its HTTP status + the client-side exit code. */
|
|
50
|
+
class HttpError extends Error {
|
|
51
|
+
status;
|
|
52
|
+
exitCode;
|
|
53
|
+
constructor(status, message, exitCode = status === 400 || status === 404 || status === 413 ? 2 : 3) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.status = status;
|
|
56
|
+
this.exitCode = exitCode;
|
|
57
|
+
this.name = 'HttpError';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function sendJson(res, status, payload) {
|
|
61
|
+
const body = JSON.stringify(payload);
|
|
62
|
+
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) });
|
|
63
|
+
res.end(body);
|
|
64
|
+
}
|
|
65
|
+
function readBody(req, cap) {
|
|
66
|
+
return new Promise((resolve, reject) => {
|
|
67
|
+
const chunks = [];
|
|
68
|
+
let size = 0;
|
|
69
|
+
req.on('data', (c) => {
|
|
70
|
+
size += c.length;
|
|
71
|
+
if (size > cap) {
|
|
72
|
+
reject(new HttpError(413, `request body exceeds ${cap} bytes`));
|
|
73
|
+
req.destroy();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
chunks.push(c);
|
|
77
|
+
});
|
|
78
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
79
|
+
req.on('error', reject);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** Wire flags → FlagSpec[] for validateNode. Primitives are coerced to strings
|
|
83
|
+
* (a boolean flag travels as "true"); anything structured is rejected. */
|
|
84
|
+
function flagsToSpecs(flags) {
|
|
85
|
+
if (flags === undefined || flags === null)
|
|
86
|
+
return [];
|
|
87
|
+
if (typeof flags !== 'object' || Array.isArray(flags))
|
|
88
|
+
throw new HttpError(400, 'flags must be an object');
|
|
89
|
+
return Object.entries(flags).map(([name, value]) => {
|
|
90
|
+
if (typeof value === 'string')
|
|
91
|
+
return { name, value };
|
|
92
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
93
|
+
return { name, value: String(value) };
|
|
94
|
+
throw new HttpError(400, `flag '${name}' must be a string`);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
function encodeArtifacts(artifacts) {
|
|
98
|
+
const out = {};
|
|
99
|
+
for (const [rel, buf] of Object.entries(artifacts))
|
|
100
|
+
out[rel] = buf.toString('base64');
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
function buildServer(config) {
|
|
104
|
+
const sha = (s) => (0, node_crypto_1.createHash)('sha256').update(s, 'utf8').digest();
|
|
105
|
+
const authorized = (req) => {
|
|
106
|
+
if (!config.authKey)
|
|
107
|
+
return true; // --allow-unsafe-anonymous
|
|
108
|
+
const m = /^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? '');
|
|
109
|
+
if (!m)
|
|
110
|
+
return false;
|
|
111
|
+
// Fixed-width digests make the comparison length-safe as well as timing-safe.
|
|
112
|
+
return (0, node_crypto_1.timingSafeEqual)(sha(m[1]), sha(config.authKey));
|
|
113
|
+
};
|
|
114
|
+
// One active run-token owns the device; see LOCK_IDLE_MS for takeover.
|
|
115
|
+
let lock = null;
|
|
116
|
+
const acquireLock = (token) => {
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
if (lock && lock.token !== token) {
|
|
119
|
+
if (now - lock.lastSeenMs < LOCK_IDLE_MS)
|
|
120
|
+
return false;
|
|
121
|
+
(0, output_1.err)(`[server] device lock: idle run ${lock.token.slice(0, 8)}… taken over by ${token.slice(0, 8)}…`);
|
|
122
|
+
}
|
|
123
|
+
lock = { token, lastSeenMs: now };
|
|
124
|
+
return true;
|
|
125
|
+
};
|
|
126
|
+
// The device can serve one interaction at a time — serialize the device endpoints
|
|
127
|
+
// through a promise chain (requests queue in arrival order).
|
|
128
|
+
let queue = Promise.resolve();
|
|
129
|
+
const serialize = (fn) => {
|
|
130
|
+
const next = queue.then(fn, fn);
|
|
131
|
+
queue = next.catch(() => undefined);
|
|
132
|
+
return next;
|
|
133
|
+
};
|
|
134
|
+
async function handleExec(req, res) {
|
|
135
|
+
const body = await readBody(req, EXEC_BODY_CAP);
|
|
136
|
+
let parsed;
|
|
137
|
+
try {
|
|
138
|
+
parsed = JSON.parse(body.toString('utf8'));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new HttpError(400, 'invalid JSON body');
|
|
142
|
+
}
|
|
143
|
+
// The exact gate that guards model repairs: only KNOWN_COMMANDS action verbs
|
|
144
|
+
// pass — a client cannot run `ui`, `log`, or anything outside the grammar.
|
|
145
|
+
let node;
|
|
146
|
+
try {
|
|
147
|
+
node = (0, ir_1.validateNode)({ type: 'command', command: parsed?.command, positionals: parsed?.positionals, flags: flagsToSpecs(parsed?.flags) }, 'rpc');
|
|
148
|
+
}
|
|
149
|
+
catch (e) {
|
|
150
|
+
if (e instanceof ir_1.InvalidPlanError)
|
|
151
|
+
throw new HttpError(400, `rejected: ${e.message}`);
|
|
152
|
+
throw e;
|
|
153
|
+
}
|
|
154
|
+
if (node.type !== 'command')
|
|
155
|
+
throw new HttpError(400, 'rejected: not a command leaf');
|
|
156
|
+
const t0 = Date.now();
|
|
157
|
+
const { code, error, step, artifacts } = await (0, cli_1.executeForServer)(node.command, node.positionals, (0, ir_1.leafToFlags)(node), config.driver, config.platform);
|
|
158
|
+
(0, output_1.err)(`[server] exec ${node.command} ${node.positionals.join(' ')} → exit ${code} (${Date.now() - t0}ms)`);
|
|
159
|
+
const payload = {
|
|
160
|
+
code,
|
|
161
|
+
...(error ? { error: (0, rpc_1.describeError)(error) } : {}),
|
|
162
|
+
...(step ? { step } : {}),
|
|
163
|
+
...(artifacts && Object.keys(artifacts).length ? { artifacts: encodeArtifacts(artifacts) } : {}),
|
|
164
|
+
};
|
|
165
|
+
sendJson(res, 200, payload);
|
|
166
|
+
}
|
|
167
|
+
async function handleElements(req, res) {
|
|
168
|
+
await readBody(req, EXEC_BODY_CAP); // drain (the body is unused; keeps keep-alive sane)
|
|
169
|
+
const elements = config.driver.getElements(); // CliError(3) on dump failure → 500 below
|
|
170
|
+
sendJson(res, 200, { elements });
|
|
171
|
+
}
|
|
172
|
+
async function handleInstall(req, res) {
|
|
173
|
+
const ext = String(req.headers['x-verikun-ext'] ?? '').toLowerCase();
|
|
174
|
+
if (ext !== 'apk' && ext !== 'ipa') {
|
|
175
|
+
throw new HttpError(400, `x-verikun-ext must be 'apk' or 'ipa' (got '${ext || '(missing)'}')`);
|
|
176
|
+
}
|
|
177
|
+
// The temp path is server-generated — the client never supplies a path, so
|
|
178
|
+
// there is no traversal surface. Streamed (backpressured), never buffered.
|
|
179
|
+
const dir = (0, node_path_1.join)((0, node_os_1.tmpdir)(), 'verikun-server');
|
|
180
|
+
(0, node_fs_1.mkdirSync)(dir, { recursive: true });
|
|
181
|
+
const tmpPath = (0, node_path_1.join)(dir, `${(0, node_crypto_1.randomUUID)()}.${ext}`);
|
|
182
|
+
const hasher = (0, node_crypto_1.createHash)('sha256');
|
|
183
|
+
let size = 0;
|
|
184
|
+
const counter = new node_stream_1.Transform({
|
|
185
|
+
transform(chunk, _enc, cb) {
|
|
186
|
+
size += chunk.length;
|
|
187
|
+
if (size > INSTALL_BODY_CAP) {
|
|
188
|
+
cb(new HttpError(413, `install body exceeds ${INSTALL_BODY_CAP} bytes`));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
hasher.update(chunk);
|
|
192
|
+
cb(null, chunk);
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
try {
|
|
196
|
+
await (0, promises_1.pipeline)(req, counter, (0, node_fs_1.createWriteStream)(tmpPath));
|
|
197
|
+
const digest = hasher.digest('hex');
|
|
198
|
+
const expected = String(req.headers['x-verikun-sha256'] ?? '').toLowerCase();
|
|
199
|
+
if (expected && expected !== digest) {
|
|
200
|
+
throw new HttpError(400, `sha256 mismatch: upload arrived corrupted (got ${digest.slice(0, 12)}…, expected ${expected.slice(0, 12)}…)`);
|
|
201
|
+
}
|
|
202
|
+
(0, output_1.err)(`[server] install: received ${size} bytes (.${ext}), installing…`);
|
|
203
|
+
config.driver.install(tmpPath);
|
|
204
|
+
(0, output_1.err)('[server] install: done');
|
|
205
|
+
sendJson(res, 200, { ok: true, bytes: size, sha256: digest });
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
try {
|
|
209
|
+
(0, node_fs_1.unlinkSync)(tmpPath);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
/* upload may have failed before the file existed */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function handle(req, res) {
|
|
217
|
+
const path = (req.url ?? '').split('?')[0];
|
|
218
|
+
if (req.method === 'GET' && path === '/v1/health') {
|
|
219
|
+
// Unauthenticated preflight — but if the caller DID send a key, verify it, so
|
|
220
|
+
// a client with a wrong key fails fast at ping instead of at its first step.
|
|
221
|
+
if (config.authKey && req.headers.authorization && !authorized(req)) {
|
|
222
|
+
throw new HttpError(401, 'invalid auth key');
|
|
223
|
+
}
|
|
224
|
+
const health = {
|
|
225
|
+
ok: true,
|
|
226
|
+
version: version_1.VERSION,
|
|
227
|
+
platform: config.platform,
|
|
228
|
+
serial: config.serial,
|
|
229
|
+
installEnabled: config.allowInstall,
|
|
230
|
+
};
|
|
231
|
+
sendJson(res, 200, health);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (!authorized(req))
|
|
235
|
+
throw new HttpError(401, 'missing or invalid auth key');
|
|
236
|
+
const token = String(req.headers['x-verikun-run'] ?? '(anonymous)');
|
|
237
|
+
const deviceEndpoint = (fn) => serialize(async () => {
|
|
238
|
+
if (!acquireLock(token)) {
|
|
239
|
+
throw new HttpError(409, 'device is locked by another active run — retry when it finishes');
|
|
240
|
+
}
|
|
241
|
+
await fn();
|
|
242
|
+
});
|
|
243
|
+
if (req.method === 'POST' && path === '/v1/release') {
|
|
244
|
+
// A finished client frees its lock so the NEXT command (a fresh run token)
|
|
245
|
+
// proceeds immediately instead of waiting out the idle takeover. Serialized
|
|
246
|
+
// so it lands after any in-flight request; only the holder can release.
|
|
247
|
+
return serialize(async () => {
|
|
248
|
+
await readBody(req, EXEC_BODY_CAP); // drain
|
|
249
|
+
const released = lock !== null && lock.token === token;
|
|
250
|
+
if (released)
|
|
251
|
+
lock = null;
|
|
252
|
+
sendJson(res, 200, { ok: true, released });
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
if (req.method === 'POST' && path === '/v1/exec')
|
|
256
|
+
return deviceEndpoint(() => handleExec(req, res));
|
|
257
|
+
if (req.method === 'POST' && path === '/v1/elements')
|
|
258
|
+
return deviceEndpoint(() => handleElements(req, res));
|
|
259
|
+
if (req.method === 'POST' && path === '/v1/install') {
|
|
260
|
+
if (!config.allowInstall) {
|
|
261
|
+
throw new HttpError(403, 'install is disabled on this server (start it with --allow-install)', 3);
|
|
262
|
+
}
|
|
263
|
+
return deviceEndpoint(() => handleInstall(req, res));
|
|
264
|
+
}
|
|
265
|
+
throw new HttpError(404, `unknown endpoint ${req.method} ${path}`);
|
|
266
|
+
}
|
|
267
|
+
const server = (0, node_http_1.createServer)((req, res) => {
|
|
268
|
+
const started = Date.now();
|
|
269
|
+
handle(req, res)
|
|
270
|
+
.catch((e) => {
|
|
271
|
+
const mapped = e instanceof HttpError
|
|
272
|
+
? e
|
|
273
|
+
: e instanceof errors_1.CliError
|
|
274
|
+
? new HttpError(e.exitCode === 2 ? 400 : 500, e.message, e.exitCode)
|
|
275
|
+
: new HttpError(500, e.message || 'internal error', 3);
|
|
276
|
+
if (!res.headersSent) {
|
|
277
|
+
const body = { error: mapped.message, exitCode: mapped.exitCode };
|
|
278
|
+
sendJson(res, mapped.status, body);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
res.destroy();
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
.finally(() => {
|
|
285
|
+
(0, output_1.err)(`[server] ${req.method} ${(req.url ?? '').split('?')[0]} → ${res.statusCode} (${Date.now() - started}ms)`);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
// A 512 MB upload over a slow link can legitimately exceed Node's 5-minute
|
|
289
|
+
// default request window.
|
|
290
|
+
server.requestTimeout = 30 * 60 * 1000;
|
|
291
|
+
return server;
|
|
292
|
+
}
|
|
293
|
+
async function cmdServer(positionals, flags) {
|
|
294
|
+
if (positionals.length > 0) {
|
|
295
|
+
throw new errors_1.CliError(`server: unexpected argument '${positionals[0]}'. Usage: verikun server [--bind addr] [--port n] [--auth-key k] [--allow-install] [--allow-unsafe-anonymous]`, 2);
|
|
296
|
+
}
|
|
297
|
+
const platform = (0, cli_1.platformFromFlags)(flags);
|
|
298
|
+
const device = (0, cli_1.deviceFromFlags)(flags, platform);
|
|
299
|
+
const bind = (0, args_1.flagStr)(flags, 'bind') || '127.0.0.1';
|
|
300
|
+
const port = (0, args_1.flagNum)(flags, 'port') ?? DEFAULT_PORT;
|
|
301
|
+
const allowInstall = (0, args_1.flagBool)(flags, 'allow-install');
|
|
302
|
+
const anonymous = (0, args_1.flagBool)(flags, 'allow-unsafe-anonymous');
|
|
303
|
+
// The env var is the documented channel for the key (keeps it out of argv/ps).
|
|
304
|
+
let authKey = (0, args_1.flagStr)(flags, 'auth-key') || process.env.VERIKUN_SERVER_AUTH_KEY || undefined;
|
|
305
|
+
if (anonymous && authKey) {
|
|
306
|
+
throw new errors_1.CliError('--allow-unsafe-anonymous cannot be combined with an auth key (--auth-key / VERIKUN_SERVER_AUTH_KEY) — pick one.', 2);
|
|
307
|
+
}
|
|
308
|
+
let generated = false;
|
|
309
|
+
if (!anonymous && !authKey) {
|
|
310
|
+
authKey = (0, node_crypto_1.randomBytes)(32).toString('base64url');
|
|
311
|
+
generated = true;
|
|
312
|
+
}
|
|
313
|
+
// Build the ONE driver the server will ever use, and fail fast (exit 2/3) if no
|
|
314
|
+
// device resolves — before binding a port.
|
|
315
|
+
const driver = (0, drivers_1.getDriver)(platform, device);
|
|
316
|
+
const serial = driver.resolvedSerial();
|
|
317
|
+
// Handlers print "tapped …" confirmations via out(); a server's stdout is not a
|
|
318
|
+
// data channel, so silence them — request logging goes to stderr instead.
|
|
319
|
+
(0, output_1.setOutputQuiet)(true);
|
|
320
|
+
const server = buildServer({ driver, platform, serial, authKey, allowInstall });
|
|
321
|
+
return new Promise((resolve, reject) => {
|
|
322
|
+
server.on('error', (e) => reject(new errors_1.CliError(`server: could not listen on ${bind}:${port} (${e.message})`, 3)));
|
|
323
|
+
server.listen(port, bind, () => {
|
|
324
|
+
(0, output_1.err)(`[server] verikun ${version_1.VERSION} listening on http://${bind}:${port}`);
|
|
325
|
+
(0, output_1.err)(`[server] device: ${platform} · ${serial}`);
|
|
326
|
+
(0, output_1.err)(`[server] install endpoint: ${allowInstall ? 'ENABLED (--allow-install)' : 'disabled (pass --allow-install to accept builds)'}`);
|
|
327
|
+
if (generated) {
|
|
328
|
+
(0, output_1.err)('[server] auth key generated for this session — clients pass it via VERIKUN_SERVER_AUTH_KEY or --auth-key:');
|
|
329
|
+
(0, output_1.err)(`[server] ${authKey}`);
|
|
330
|
+
}
|
|
331
|
+
else if (anonymous) {
|
|
332
|
+
(0, output_1.err)('[server] WARNING: --allow-unsafe-anonymous — NO AUTHENTICATION. Anyone who can reach this');
|
|
333
|
+
(0, output_1.err)('[server] address fully controls the connected device. Only use when the network');
|
|
334
|
+
(0, output_1.err)('[server] itself is the boundary (e.g. a private tailnet), never on a shared LAN.');
|
|
335
|
+
}
|
|
336
|
+
else {
|
|
337
|
+
(0, output_1.err)('[server] auth: key configured');
|
|
338
|
+
}
|
|
339
|
+
(0, output_1.err)('[server] stop with Ctrl-C');
|
|
340
|
+
});
|
|
341
|
+
const close = () => {
|
|
342
|
+
(0, output_1.err)('[server] shutting down');
|
|
343
|
+
server.close();
|
|
344
|
+
resolve(0);
|
|
345
|
+
};
|
|
346
|
+
process.once('SIGINT', close);
|
|
347
|
+
process.once('SIGTERM', close);
|
|
348
|
+
});
|
|
349
|
+
}
|
package/dist/suite.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// `vk suite <dir>` — run a directory of natural-language tests sequentially as one
|
|
3
|
+
// suite: reset the app between tests, collect each test's result, and write a suite
|
|
4
|
+
// overview (index.json manifest + index.html) that links every test's archived
|
|
5
|
+
// report. Exits 1 when any test failed, so the command doubles as the CI gate.
|
|
6
|
+
//
|
|
7
|
+
// Dependency-injected like agent/engine.ts: this module imports NOTHING from cli.ts
|
|
8
|
+
// — the actual test execution (`runTest`, which is cli.ts's runAiTest bound to a
|
|
9
|
+
// local-or-remote backend) and the between-test reset come in via SuiteDeps. That
|
|
10
|
+
// keeps the enumeration/tally/manifest logic pure enough to unit-test without a
|
|
11
|
+
// device, and cli.ts free of a suite→cli import cycle.
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.sortTestFiles = sortTestFiles;
|
|
14
|
+
exports.listTestFiles = listTestFiles;
|
|
15
|
+
exports.toSuiteResult = toSuiteResult;
|
|
16
|
+
exports.cmdSuite = cmdSuite;
|
|
17
|
+
const node_fs_1 = require("node:fs");
|
|
18
|
+
const node_path_1 = require("node:path");
|
|
19
|
+
const args_1 = require("./args");
|
|
20
|
+
const errors_1 = require("./errors");
|
|
21
|
+
const output_1 = require("./output");
|
|
22
|
+
const run_1 = require("./run");
|
|
23
|
+
const report_1 = require("./report");
|
|
24
|
+
const version_1 = require("./version");
|
|
25
|
+
/** Lexicographic order, so authors sequence flows with 01-…, 02-… prefixes. */
|
|
26
|
+
function sortTestFiles(files) {
|
|
27
|
+
return [...files].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
28
|
+
}
|
|
29
|
+
/** The *.md files in a suite dir (non-recursive). README.md is documentation for
|
|
30
|
+
* the suite, not a test — skipped by convention. */
|
|
31
|
+
function listTestFiles(dir) {
|
|
32
|
+
const files = (0, node_fs_1.readdirSync)(dir).filter((f) => {
|
|
33
|
+
if (!f.toLowerCase().endsWith('.md') || f.toLowerCase() === 'readme.md')
|
|
34
|
+
return false;
|
|
35
|
+
try {
|
|
36
|
+
return (0, node_fs_1.statSync)((0, node_path_1.join)(dir, f)).isFile();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
return sortTestFiles(files);
|
|
43
|
+
}
|
|
44
|
+
/** Fold one test's AiRunResult into the manifest row (pure; unit-tested). */
|
|
45
|
+
function toSuiteResult(file, r, durationMs) {
|
|
46
|
+
const steps = r.state?.steps ?? [];
|
|
47
|
+
const passedSteps = steps.filter((s) => s.status === 'passed').length;
|
|
48
|
+
const failure = r.failure
|
|
49
|
+
? `FAIL at ${r.failure.where}: ${r.failure.reason}`
|
|
50
|
+
: r.abortedForBudget
|
|
51
|
+
? 'aborted: cost ceiling reached'
|
|
52
|
+
: r.abortedForTimeout
|
|
53
|
+
? 'aborted: run timeout reached'
|
|
54
|
+
: undefined;
|
|
55
|
+
return {
|
|
56
|
+
id: r.runDir ? (0, node_path_1.basename)(r.runDir) : '',
|
|
57
|
+
file,
|
|
58
|
+
name: (0, node_path_1.basename)(file, (0, node_path_1.extname)(file)),
|
|
59
|
+
ok: r.ok,
|
|
60
|
+
durationMs,
|
|
61
|
+
costUsd: r.costUsd,
|
|
62
|
+
steps: steps.length,
|
|
63
|
+
passedSteps,
|
|
64
|
+
failedSteps: steps.length - passedSteps,
|
|
65
|
+
modelRepairs: r.modelRepairs,
|
|
66
|
+
...(r.ok ? {} : { failure: failure ?? 'failed' }),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
async function cmdSuite(dirArg, flags, deps) {
|
|
70
|
+
const dir = (0, node_path_1.resolve)(process.cwd(), dirArg);
|
|
71
|
+
if (!(0, node_fs_1.existsSync)(dir) || !(0, node_fs_1.statSync)(dir).isDirectory()) {
|
|
72
|
+
throw new errors_1.CliError(`suite: '${dirArg}' is not a directory`, 2);
|
|
73
|
+
}
|
|
74
|
+
const files = listTestFiles(dir);
|
|
75
|
+
if (files.length === 0) {
|
|
76
|
+
throw new errors_1.CliError(`suite: no test files (*.md) in '${dirArg}'`, 2);
|
|
77
|
+
}
|
|
78
|
+
const suiteId = (0, run_1.runId)();
|
|
79
|
+
const name = (0, args_1.flagStr)(flags, 'name') || (0, node_path_1.basename)(dir);
|
|
80
|
+
const startedAt = new Date().toISOString();
|
|
81
|
+
(0, output_1.err)(`[suite] '${name}': ${files.length} test(s) from ${dirArg} (${deps.platform}${deps.device ? ` · ${deps.device}` : ''})`);
|
|
82
|
+
const results = [];
|
|
83
|
+
for (let i = 0; i < files.length; i++) {
|
|
84
|
+
const file = files[i];
|
|
85
|
+
(0, output_1.err)(`[suite] ── (${i + 1}/${files.length}) ${file} ──`);
|
|
86
|
+
if (deps.reset) {
|
|
87
|
+
try {
|
|
88
|
+
await deps.reset();
|
|
89
|
+
(0, output_1.err)('[suite] app state reset');
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
// Surface but continue: a flaky reset should not zero out the whole suite —
|
|
93
|
+
// the test itself will fail loudly if the stale state actually matters.
|
|
94
|
+
(0, output_1.err)(`[suite] reset failed (${e.message}) — continuing`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const t0 = Date.now();
|
|
98
|
+
try {
|
|
99
|
+
const r = await deps.runTest((0, node_path_1.join)(dir, file));
|
|
100
|
+
results.push(toSuiteResult(file, r, Date.now() - t0));
|
|
101
|
+
}
|
|
102
|
+
catch (e) {
|
|
103
|
+
// A test that THREW (env error: device gone, server unreachable, bad file)
|
|
104
|
+
// still becomes a failed row — one broken test must not vaporize the suite
|
|
105
|
+
// report for the tests that already ran.
|
|
106
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
107
|
+
(0, output_1.err)(`[suite] ${file} errored: ${msg}`);
|
|
108
|
+
results.push({
|
|
109
|
+
id: '',
|
|
110
|
+
file,
|
|
111
|
+
name: (0, node_path_1.basename)(file, (0, node_path_1.extname)(file)),
|
|
112
|
+
ok: false,
|
|
113
|
+
durationMs: Date.now() - t0,
|
|
114
|
+
costUsd: 0,
|
|
115
|
+
steps: 0,
|
|
116
|
+
passedSteps: 0,
|
|
117
|
+
failedSteps: 0,
|
|
118
|
+
modelRepairs: 0,
|
|
119
|
+
failure: msg.split('\n')[0],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const suite = {
|
|
124
|
+
schemaVersion: 1,
|
|
125
|
+
id: suiteId,
|
|
126
|
+
name,
|
|
127
|
+
startedAt,
|
|
128
|
+
finishedAt: new Date().toISOString(),
|
|
129
|
+
platform: deps.platform,
|
|
130
|
+
device: deps.device,
|
|
131
|
+
verikun: version_1.VERSION,
|
|
132
|
+
totals: (0, report_1.suiteTotals)(results),
|
|
133
|
+
tests: results,
|
|
134
|
+
};
|
|
135
|
+
// .verikun/suites/<id>/ sits beside .verikun/runs/<id>/, so index.html reaches a
|
|
136
|
+
// test report at ../../runs/<id>/report.html — the linkBase below.
|
|
137
|
+
const outDir = (0, run_1.uniqueDir)((0, node_path_1.join)((0, output_1.artifactDir)(), 'suites', suiteId));
|
|
138
|
+
(0, node_fs_1.mkdirSync)(outDir, { recursive: true });
|
|
139
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(outDir, 'index.json'), (0, report_1.toSuiteIndexJson)(suite));
|
|
140
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(outDir, 'index.html'), (0, report_1.toSuiteHtml)(suite, { linkBase: '../../' }));
|
|
141
|
+
const t = suite.totals;
|
|
142
|
+
(0, output_1.err)(`[suite] ${t.passed}/${t.tests} passed · ${t.steps} steps · $${t.costUsd.toFixed(4)} · ${(t.durationMs / 1000).toFixed(1)}s`);
|
|
143
|
+
for (const r of results)
|
|
144
|
+
(0, output_1.err)(` ${r.ok ? 'PASS' : 'FAIL'} ${r.file}${r.failure ? ` — ${r.failure}` : ''}`);
|
|
145
|
+
(0, output_1.err)(`[suite] overview: ${(0, node_path_1.join)(outDir, 'index.html')}`);
|
|
146
|
+
if ((0, args_1.flagBool)(flags, 'json'))
|
|
147
|
+
(0, output_1.json)(suite);
|
|
148
|
+
else
|
|
149
|
+
(0, output_1.out)(outDir); // primary machine result: the suite directory
|
|
150
|
+
// The CI gate: any failed test fails the invocation (mirrors `vk run archive`).
|
|
151
|
+
return t.failed > 0 ? 1 : 0;
|
|
152
|
+
}
|
package/dist/version.js
CHANGED
|
@@ -3,4 +3,4 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.VERSION = void 0;
|
|
4
4
|
// GENERATED by scripts/gen-version.mjs from package.json's "version" at build time
|
|
5
5
|
// (the `prebuild` script). Do NOT edit by hand; bump package.json instead.
|
|
6
|
-
exports.VERSION = '0.
|
|
6
|
+
exports.VERSION = '0.7.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "verikun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Drive Android emulators/devices and iOS simulators for AI agents: tap, type, swipe, screenshot, and inspect the UI hierarchy by semantic identifiers — like Puppeteer for native apps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"android",
|