verikun 0.4.1 → 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.
@@ -233,6 +233,18 @@ class AdbDriver {
233
233
  throw new errors_1.CliError(`Failed to launch '${appId}': ${r.stderr.trim() || r.stdout.trim() || `exit code ${r.code}`}`, 3);
234
234
  }
235
235
  }
236
+ install(appPath) {
237
+ // `-r` reinstalls over an existing package keeping its data (the common
238
+ // update-the-build-under-test case). A large APK can legitimately take
239
+ // minutes to stream + install, so the timeout is far above the 30s default.
240
+ // adb reports failures both as a non-zero exit AND as a `Failure [REASON]`
241
+ // line on stdout with exit 0 (varies by adb version) — check both.
242
+ const r = (0, exec_1.runText)(ADB, this.withSerial(['install', '-r', appPath]), { timeout: 10 * 60 * 1000 });
243
+ const combined = `${r.stdout}\n${r.stderr}`;
244
+ if (r.code !== 0 || /^Failure\b/im.test(combined) || !/^Success\b/im.test(combined)) {
245
+ throw new errors_1.CliError(`Failed to install '${appPath}': ${combined.replace(/\s+/g, ' ').trim() || `exit code ${r.code}`}`, 3);
246
+ }
247
+ }
236
248
  stop(appId) {
237
249
  this.shell(['am', 'force-stop', appId]);
238
250
  }
@@ -1,13 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.SimctlDriver = exports.AdbDriver = void 0;
3
+ exports.IdbDriver = exports.AdbDriver = void 0;
4
4
  exports.getDriver = getDriver;
5
5
  const adb_1 = require("./adb");
6
- const simctl_1 = require("./simctl");
6
+ const ios_1 = require("./ios");
7
7
  var adb_2 = require("./adb");
8
8
  Object.defineProperty(exports, "AdbDriver", { enumerable: true, get: function () { return adb_2.AdbDriver; } });
9
- var simctl_2 = require("./simctl");
10
- Object.defineProperty(exports, "SimctlDriver", { enumerable: true, get: function () { return simctl_2.SimctlDriver; } });
9
+ var ios_2 = require("./ios");
10
+ Object.defineProperty(exports, "IdbDriver", { enumerable: true, get: function () { return ios_2.IdbDriver; } });
11
11
  function getDriver(platform, device) {
12
- return platform === 'ios' ? new simctl_1.SimctlDriver(device) : new adb_1.AdbDriver(device);
12
+ return platform === 'ios' ? new ios_1.IdbDriver(device) : new adb_1.AdbDriver(device);
13
13
  }
@@ -0,0 +1,358 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IdbDriver = void 0;
4
+ const node_os_1 = require("node:os");
5
+ const node_path_1 = require("node:path");
6
+ const node_fs_1 = require("node:fs");
7
+ const errors_1 = require("../errors");
8
+ const exec_1 = require("../exec");
9
+ const ios_parse_1 = require("../ui/ios-parse");
10
+ // iOS driver. `xcrun simctl` / `devicectl` cover device discovery and — on a
11
+ // simulator — screenshots, app lifecycle, and logs (no extra install needed).
12
+ // Everything interactive (UI hierarchy, tap, type, swipe, keys, screen size) and
13
+ // all interaction on physical devices go through Facebook's `idb`, a CLI shelled
14
+ // one-shot exactly like `adb` — so this driver keeps verikun's zero-runtime-dep,
15
+ // process-per-command shape. Install: `brew install idb-companion` + `pip install
16
+ // fb-idb` (see `vk doctor --ios`).
17
+ //
18
+ // Coordinates are in idb's point space: `idb ui describe-all` frames, `idb ui tap`,
19
+ // and `idb describe` screen_dimensions all agree, so element.center taps land.
20
+ // (simctl screenshots are pixels = points × scale — they are for viewing only and
21
+ // never feed back into a tap.)
22
+ const XCRUN = 'xcrun';
23
+ const IDB = process.env.IDB || 'idb';
24
+ const DEFAULT_LOG_LINES = 200;
25
+ const DEFAULT_LOG_WINDOW = '5m';
26
+ // Named keys -> USB-HID keyboard usage IDs, handed to `idb ui key <code>`. Numeric
27
+ // codes are accepted directly. Names mirror the adb driver so cross-platform
28
+ // batch/ai scripts share one vocabulary.
29
+ const IOS_KEYCODES = {
30
+ enter: 40,
31
+ return: 40,
32
+ escape: 41,
33
+ esc: 41,
34
+ del: 42,
35
+ delete: 42,
36
+ backspace: 42,
37
+ tab: 43,
38
+ space: 44,
39
+ forward_del: 76,
40
+ move_home: 74,
41
+ move_end: 77,
42
+ page_up: 75,
43
+ page_down: 78,
44
+ right: 79,
45
+ dpad_right: 79,
46
+ left: 80,
47
+ dpad_left: 80,
48
+ down: 81,
49
+ dpad_down: 81,
50
+ up: 82,
51
+ dpad_up: 82,
52
+ };
53
+ // Named hardware buttons -> `idb ui button <NAME>` (the only accepted set).
54
+ const IOS_BUTTONS = {
55
+ home: 'HOME',
56
+ lock: 'LOCK',
57
+ power: 'LOCK',
58
+ side_button: 'SIDE_BUTTON',
59
+ siri: 'SIRI',
60
+ apple_pay: 'APPLE_PAY',
61
+ };
62
+ function listPhysicalDevices() {
63
+ const r = (0, exec_1.runText)(XCRUN, ['devicectl', 'list', 'devices']);
64
+ if (r.code !== 0)
65
+ return [];
66
+ const lines = r.stdout.split('\n');
67
+ const headerIdx = lines.findIndex((l) => l.includes('Identifier'));
68
+ if (headerIdx < 0)
69
+ return [];
70
+ const header = lines[headerIdx];
71
+ const nameCol = header.indexOf('Name');
72
+ const hostCol = header.indexOf('Hostname');
73
+ const idCol = header.indexOf('Identifier');
74
+ const stateCol = header.indexOf('State');
75
+ const modelCol = header.indexOf('Model');
76
+ const devices = [];
77
+ for (const line of lines.slice(headerIdx + 2)) {
78
+ if (!line.trim() || line.startsWith('-'))
79
+ continue;
80
+ const identifier = line.slice(idCol, stateCol).trim();
81
+ const state = line.slice(stateCol, modelCol).trim();
82
+ const name = line.slice(nameCol, hostCol).trim();
83
+ const model = line.slice(modelCol).trim();
84
+ if (!identifier)
85
+ continue;
86
+ const productMatch = model.match(/\(([^)]+)\)$/);
87
+ devices.push({
88
+ serial: identifier,
89
+ state,
90
+ model: name,
91
+ product: productMatch?.[1],
92
+ platform: 'ios',
93
+ note: 'physical — via idb (Developer mode + idb_companion; logs limited)',
94
+ });
95
+ }
96
+ return devices;
97
+ }
98
+ class IdbDriver {
99
+ platform = 'ios';
100
+ requested;
101
+ cachedSerial;
102
+ cachedIsSim;
103
+ constructor(device) {
104
+ // 'booted' is a simctl-only alias idb can't address, so treat it as "auto-resolve".
105
+ this.requested = device && device !== 'booted' ? device : undefined;
106
+ }
107
+ /** All available simulators (booted or shutdown) via simctl. Tolerates odd output. */
108
+ simulators() {
109
+ const devices = [];
110
+ const { stdout } = (0, exec_1.runText)(XCRUN, ['simctl', 'list', 'devices', 'available', '--json']);
111
+ try {
112
+ const data = JSON.parse(stdout);
113
+ for (const [runtime, list] of Object.entries(data.devices)) {
114
+ for (const d of list) {
115
+ devices.push({
116
+ serial: d.udid,
117
+ state: d.state.toLowerCase(),
118
+ model: d.name,
119
+ product: runtime.split('.').pop(),
120
+ platform: 'ios',
121
+ });
122
+ }
123
+ }
124
+ }
125
+ catch {
126
+ /* tolerate unexpected simctl output */
127
+ }
128
+ return devices;
129
+ }
130
+ listDevices() {
131
+ return [...this.simulators(), ...listPhysicalDevices()];
132
+ }
133
+ resolvedSerial() {
134
+ if (this.cachedSerial)
135
+ return this.cachedSerial;
136
+ const sims = this.simulators();
137
+ const simUdids = new Set(sims.map((d) => d.serial));
138
+ if (this.requested) {
139
+ this.cachedSerial = this.requested;
140
+ this.cachedIsSim = simUdids.has(this.requested);
141
+ return this.cachedSerial;
142
+ }
143
+ // No explicit device: a booted simulator is the first-class, unambiguously
144
+ // drivable target, so prefer it. Only weigh physical devices when no simulator
145
+ // is booted — and only genuinely "connected" ones (devicectl also lists paired-
146
+ // but-idle devices as "available (paired)", which must not count as active).
147
+ const bootedSims = sims.filter((d) => d.state === 'booted');
148
+ const candidates = bootedSims.length > 0 ? bootedSims : listPhysicalDevices().filter((d) => /connected/i.test(d.state));
149
+ if (candidates.length === 0) {
150
+ throw new errors_1.CliError('No booted iOS simulator or connected device. Boot one (Simulator.app / `xcrun simctl boot`), then `verikun devices`.', 3);
151
+ }
152
+ if (candidates.length > 1) {
153
+ const list = candidates.map((d) => ' ' + d.serial + (d.model ? ` (${d.model})` : '')).join('\n');
154
+ throw new errors_1.CliError(`Multiple iOS targets; pass --device <udid> (or set VERIKUN_DEVICE):\n${list}`, 2);
155
+ }
156
+ this.cachedSerial = candidates[0].serial;
157
+ this.cachedIsSim = simUdids.has(candidates[0].serial);
158
+ return this.cachedSerial;
159
+ }
160
+ udid() {
161
+ return this.resolvedSerial();
162
+ }
163
+ isSimulator() {
164
+ this.resolvedSerial();
165
+ return this.cachedIsSim === true;
166
+ }
167
+ /** Run an idb subcommand against the resolved target, returning stdout. */
168
+ idbText(args, opts) {
169
+ const r = (0, exec_1.runText)(IDB, [...args, '--udid', this.udid()], opts);
170
+ if (r.code !== 0) {
171
+ throw new errors_1.CliError(`idb ${args.join(' ')} failed: ${r.stderr.trim() || `exit code ${r.code}`}`, 3);
172
+ }
173
+ return r.stdout;
174
+ }
175
+ getElements(opts = {}) {
176
+ // `idb ui describe-all` prints the accessibility tree as JSON (array or NDJSON);
177
+ // parseIosHierarchy handles either.
178
+ return (0, ios_parse_1.parseIosHierarchy)(this.idbText(['ui', 'describe-all'], { timeout: 15000 }), opts);
179
+ }
180
+ screenshot() {
181
+ const tmp = (0, node_path_1.join)((0, node_os_1.tmpdir)(), `verikun-ios-${process.pid}.png`);
182
+ if (this.isSimulator()) {
183
+ const r = (0, exec_1.runText)(XCRUN, ['simctl', 'io', this.udid(), 'screenshot', tmp]);
184
+ if (r.code !== 0)
185
+ throw new errors_1.CliError(`simctl screenshot failed: ${r.stderr.trim()}`, 3);
186
+ }
187
+ else {
188
+ this.idbText(['screenshot', tmp]);
189
+ }
190
+ let buf;
191
+ try {
192
+ buf = (0, node_fs_1.readFileSync)(tmp);
193
+ }
194
+ catch (e) {
195
+ throw new errors_1.CliError(`Could not read iOS screenshot: ${e.message}`, 3);
196
+ }
197
+ finally {
198
+ try {
199
+ (0, node_fs_1.unlinkSync)(tmp);
200
+ }
201
+ catch {
202
+ /* best-effort cleanup */
203
+ }
204
+ }
205
+ if (buf.length < 8 || buf[0] !== 0x89 || buf[1] !== 0x50) {
206
+ throw new errors_1.CliError('iOS screenshot was not a PNG.', 3);
207
+ }
208
+ return buf;
209
+ }
210
+ screenSize() {
211
+ // Prefer idb's target description (points, same space as tap/swipe).
212
+ try {
213
+ const d = JSON.parse(this.idbText(['describe', '--json']));
214
+ const w = Number(d.screen_dimensions?.width);
215
+ const h = Number(d.screen_dimensions?.height);
216
+ if (w > 0 && h > 0)
217
+ return { width: w, height: h };
218
+ }
219
+ catch {
220
+ /* fall through to deriving from the hierarchy */
221
+ }
222
+ // Fallback: the extent of the on-screen hierarchy (the window/app root frame).
223
+ let width = 0;
224
+ let height = 0;
225
+ for (const el of (0, ios_parse_1.parseIosHierarchy)(this.idbText(['ui', 'describe-all']), { all: true })) {
226
+ width = Math.max(width, el.bounds.x2);
227
+ height = Math.max(height, el.bounds.y2);
228
+ }
229
+ if (width > 0 && height > 0)
230
+ return { width, height };
231
+ throw new errors_1.CliError('Could not determine iOS screen size via idb.', 3);
232
+ }
233
+ tap(x, y) {
234
+ this.idbText(['ui', 'tap', String(Math.round(x)), String(Math.round(y))]);
235
+ }
236
+ swipe(x1, y1, x2, y2, _durationMs) {
237
+ // idb controls swipe speed via `--delta` (px per step), not a ms duration, and
238
+ // the flag's availability varies by version — so we pass coordinates only for
239
+ // maximum compatibility. durationMs is not honored (a documented iOS gap).
240
+ this.idbText([
241
+ 'ui',
242
+ 'swipe',
243
+ String(Math.round(x1)),
244
+ String(Math.round(y1)),
245
+ String(Math.round(x2)),
246
+ String(Math.round(y2)),
247
+ ]);
248
+ }
249
+ inputText(text) {
250
+ if (!text)
251
+ return;
252
+ // Passed as a single argv (spawnSync, no host shell), so no device-shell escaping
253
+ // is needed — idb synthesizes the keystrokes itself.
254
+ this.idbText(['ui', 'text', text]);
255
+ }
256
+ pressKey(name) {
257
+ const key = name.toLowerCase();
258
+ const button = IOS_BUTTONS[key];
259
+ if (button) {
260
+ this.idbText(['ui', 'button', button]);
261
+ return;
262
+ }
263
+ const code = IOS_KEYCODES[key] ?? (/^\d+$/.test(name) ? Number(name) : undefined);
264
+ if (code === undefined) {
265
+ const hint = key === 'back' ? ' (iOS has no hardware Back — tap the on-screen back control instead)' : '';
266
+ throw new errors_1.CliError(`Unknown iOS key '${name}'${hint}. Known keys: ${Object.keys(IOS_KEYCODES).join(', ')}; ` +
267
+ `hardware buttons: ${Object.keys(IOS_BUTTONS).join(', ')}; or a numeric HID keycode.`, 2);
268
+ }
269
+ this.idbText(['ui', 'key', String(code)]);
270
+ }
271
+ launch(appId) {
272
+ if (this.isSimulator()) {
273
+ const r = (0, exec_1.runText)(XCRUN, ['simctl', 'launch', this.udid(), appId]);
274
+ if (r.code !== 0)
275
+ throw new errors_1.CliError(`simctl launch failed: ${r.stderr.trim()}`, 3);
276
+ }
277
+ else {
278
+ this.idbText(['launch', appId]);
279
+ }
280
+ }
281
+ install(appPath) {
282
+ // idb install handles both simulators and physical devices, and both .ipa
283
+ // archives and .app bundles locally. (The REMOTE install path accepts only
284
+ // single-file .ipa uploads in v1 — a .app is a directory, which the streamed
285
+ // upload can't carry; see server.ts.) Installs can take minutes.
286
+ this.idbText(['install', appPath], { timeout: 10 * 60 * 1000 });
287
+ }
288
+ stop(appId) {
289
+ // Best-effort force-stop. `terminate` reports a non-zero exit when the app simply
290
+ // wasn't running; that's a no-op success for us (parity with adb `am force-stop`,
291
+ // which never fails), so we don't surface it — otherwise `launch`'s default
292
+ // restart (stop-then-launch) would break whenever the app is already closed.
293
+ try {
294
+ if (this.isSimulator()) {
295
+ (0, exec_1.runText)(XCRUN, ['simctl', 'terminate', this.udid(), appId]);
296
+ }
297
+ else {
298
+ (0, exec_1.runText)(IDB, ['terminate', appId, '--udid', this.udid()]);
299
+ }
300
+ }
301
+ catch {
302
+ /* tool missing / target gone — nothing to stop */
303
+ }
304
+ }
305
+ clearApp(appId) {
306
+ // Honest degrade (no clean per-app data reset on iOS): don't silently uninstall.
307
+ throw new errors_1.CliError(`iOS app-data clearing is not supported (requested for '${appId}').\n` +
308
+ 'iOS has no per-app data reset; the manual equivalent is uninstall + reinstall ' +
309
+ '(`xcrun simctl uninstall <udid> <bundleId>`), which removes the app too.', 3);
310
+ }
311
+ currentApp() {
312
+ // iOS exposes no reliable foreground-app query; degrade like adb's fallback.
313
+ return '(unknown)';
314
+ }
315
+ getLogs(opts = {}) {
316
+ if (!this.isSimulator()) {
317
+ throw new errors_1.CliError('iOS physical-device log capture is not supported (simulator logs work via `log show`).\n' +
318
+ 'Use Console.app or `idb log` directly for a connected device.', 3);
319
+ }
320
+ // `log show` on the whole store is huge, so ALWAYS bound it: a session marker
321
+ // (--start) or a recent window (--last). Args go through spawnSync (no shell),
322
+ // so the marker/predicate need no escaping and can't inject.
323
+ const args = ['simctl', 'spawn', this.udid(), 'log', 'show', '--style', 'syslog'];
324
+ if (opts.since) {
325
+ args.push('--start', opts.since);
326
+ }
327
+ else {
328
+ args.push('--last', DEFAULT_LOG_WINDOW);
329
+ }
330
+ if (opts.appId) {
331
+ // Best-effort process scope: the simulator process name is usually the bundle's
332
+ // last component. A loose predicate is better than none for a crash trace.
333
+ const proc = opts.appId.split('.').pop() || opts.appId;
334
+ args.push('--predicate', `process CONTAINS "${proc}"`);
335
+ }
336
+ const out = (0, exec_1.runText)(XCRUN, args, { timeout: 20000 }).stdout;
337
+ if (opts.since)
338
+ return out; // the whole session window
339
+ // No explicit since → keep the last N lines, like adb's `logcat -t N`.
340
+ const n = opts.lines && opts.lines > 0 ? Math.floor(opts.lines) : DEFAULT_LOG_LINES;
341
+ const lines = out.split('\n');
342
+ return lines.slice(Math.max(0, lines.length - n)).join('\n');
343
+ }
344
+ deviceTime() {
345
+ // Simulator shares the host clock; sample it in `log show --start` format so the
346
+ // run's log window (run.ts) can anchor on it. Physical devices → '' (no marker),
347
+ // which disables windowing gracefully. Never throws (called at run start).
348
+ try {
349
+ if (!this.isSimulator())
350
+ return '';
351
+ return (0, exec_1.runText)(XCRUN, ['simctl', 'spawn', this.udid(), 'date', '+%Y-%m-%d %H:%M:%S']).stdout.trim();
352
+ }
353
+ catch {
354
+ return '';
355
+ }
356
+ }
357
+ }
358
+ exports.IdbDriver = IdbDriver;
package/dist/report.js CHANGED
@@ -1,10 +1,14 @@
1
1
  "use strict";
2
- // Pure report rendering: a finished RunState in -> a JUnit XML / HTML string out.
2
+ // Pure report rendering: a finished RunState in -> a JUnit XML / HTML string out,
3
+ // and a finished SuiteRun in -> the suite index.json / index.html out.
3
4
  // No fs, no device, no side effects — so it is trivially testable and the run
4
- // recorder (run.ts) owns all the I/O. The data model lives in run.ts; we import
5
- // the types only.
5
+ // recorder (run.ts) / suite runner (suite.ts) own all the I/O. The RunState data
6
+ // model lives in run.ts; we import the types only.
6
7
  Object.defineProperty(exports, "__esModule", { value: true });
7
8
  exports.toJUnitXml = toJUnitXml;
9
+ exports.suiteTotals = suiteTotals;
10
+ exports.toSuiteIndexJson = toSuiteIndexJson;
11
+ exports.toSuiteHtml = toSuiteHtml;
8
12
  exports.toHtml = toHtml;
9
13
  // --- escaping -------------------------------------------------------------
10
14
  // XML 1.0 forbids most control chars even when escaped; drop them so a stray
@@ -180,6 +184,92 @@ function stepHtml(s) {
180
184
  parts.push(`<details><summary>Device logs</summary><pre>${htmlEsc(s.logs)}</pre></details>`);
181
185
  return `<li class="step ${s.status}">${parts.join('\n ')}</li>`;
182
186
  }
187
+ /** Tally a suite's tests into its totals (pure; used by suite.ts and tests). */
188
+ function suiteTotals(tests) {
189
+ const round = (n) => Number(n.toFixed(4));
190
+ return {
191
+ tests: tests.length,
192
+ passed: tests.filter((t) => t.ok).length,
193
+ failed: tests.filter((t) => !t.ok).length,
194
+ steps: tests.reduce((a, t) => a + t.steps, 0),
195
+ costUsd: round(tests.reduce((a, t) => a + t.costUsd, 0)),
196
+ durationMs: tests.reduce((a, t) => a + t.durationMs, 0),
197
+ };
198
+ }
199
+ function toSuiteIndexJson(suite) {
200
+ return JSON.stringify(suite, null, 2) + '\n';
201
+ }
202
+ const SUITE_STYLE = `
203
+ table.tests { width:100%; border-collapse:collapse; background:#fff; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
204
+ table.tests th, table.tests td { text-align:left; padding:10px 12px; border-top:1px solid var(--line); font-size:13px; }
205
+ table.tests th { background:#eaeef2; color:var(--muted); border-top:none; font-size:12px; letter-spacing:.03em; text-transform:uppercase; }
206
+ table.tests td.num { text-align:right; font-variant-numeric:tabular-nums; white-space:nowrap; }
207
+ table.tests a { color:inherit; }
208
+ .fail-reason { color:var(--fail); font-size:12px; margin-top:2px; }
209
+ `;
210
+ function suiteTestRow(t, linkBase) {
211
+ // A test that errored before its run started (id '') has no report to link.
212
+ const label = t.id
213
+ ? `<a href="${htmlEsc(`${linkBase}runs/${encodeURIComponent(t.id)}/report.html`)}">${htmlEsc(t.name)}</a>`
214
+ : htmlEsc(t.name);
215
+ const failure = t.failure ? `<div class="fail-reason">${htmlEsc(t.failure)}</div>` : '';
216
+ return ` <tr>
217
+ <td><span class="st ${t.ok ? 'passed' : 'failed'}">${t.ok ? 'PASS' : 'FAIL'}</span></td>
218
+ <td>${label}${failure}</td>
219
+ <td class="num">${t.passedSteps}/${t.steps}${t.failedSteps ? ` (${t.failedSteps} failed)` : ''}</td>
220
+ <td class="num">${t.modelRepairs || ''}</td>
221
+ <td class="num">$${t.costUsd.toFixed(4)}</td>
222
+ <td class="num">${fmtDuration(t.durationMs)}</td>
223
+ </tr>`;
224
+ }
225
+ /**
226
+ * The suite overview page. `linkBase` is the relative path from index.html to the
227
+ * directory holding `runs/<id>/report.html` — '../../' when the suite lives at
228
+ * .verikun/suites/<id>/ and runs at .verikun/runs/<id>/ (the default layout).
229
+ */
230
+ function toSuiteHtml(suite, opts = {}) {
231
+ const linkBase = opts.linkBase ?? '../../';
232
+ const t = suite.totals;
233
+ const chips = [
234
+ `<span class="chip pass">${t.passed} passed</span>`,
235
+ t.failed ? `<span class="chip fail">${t.failed} failed</span>` : '',
236
+ `<span class="chip muted">${t.tests} tests &middot; ${t.steps} steps &middot; ${fmtDuration(t.durationMs)} &middot; $${t.costUsd.toFixed(4)}</span>`,
237
+ ]
238
+ .filter(Boolean)
239
+ .join('\n ');
240
+ const metaBits = [
241
+ `<code>${htmlEsc(suite.id)}</code>`,
242
+ htmlEsc(suite.platform) + (suite.device ? ` · ${htmlEsc(suite.device)}` : ''),
243
+ `started ${htmlEsc(suite.startedAt)}`,
244
+ `finished ${htmlEsc(suite.finishedAt)}`,
245
+ `verikun ${htmlEsc(suite.verikun)}`,
246
+ ].filter(Boolean);
247
+ return `<!doctype html>
248
+ <html lang="en">
249
+ <head>
250
+ <meta charset="utf-8">
251
+ <meta name="viewport" content="width=device-width, initial-scale=1">
252
+ <title>verikun suite — ${htmlEsc(suite.name)}</title>
253
+ <style>${STYLE}${SUITE_STYLE}</style>
254
+ </head>
255
+ <body>
256
+ <div class="wrap">
257
+ <h1>verikun test suite — ${htmlEsc(suite.name)}</h1>
258
+ <div class="meta">${metaBits.join(' &middot; ')}</div>
259
+ <div class="summary">
260
+ ${chips}
261
+ </div>
262
+ <table class="tests">
263
+ <thead><tr><th></th><th>Test</th><th>Steps</th><th>Repairs</th><th>Cost</th><th>Duration</th></tr></thead>
264
+ <tbody>
265
+ ${suite.tests.map((x) => suiteTestRow(x, linkBase)).join('\n')}
266
+ </tbody>
267
+ </table>
268
+ </div>
269
+ </body>
270
+ </html>
271
+ `;
272
+ }
183
273
  function toHtml(run) {
184
274
  const c = counts(run);
185
275
  const chips = [
package/dist/rpc.js ADDED
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ // The remote-execution wire contract shared by `vk server` (src/server.ts) and the
3
+ // remote backend (src/agent/remote.ts): request/response shapes plus the error codec
4
+ // that carries a thrown error's SUBCLASS IDENTITY across the wire. The `vk ai` engine
5
+ // decides heal-vs-terminal via `instanceof SelectorNotFoundError/AmbiguousSelectorError`
6
+ // (agent/engine.ts), so a serialized error must rebuild into the same class — a plain
7
+ // `{message}` would silently turn every remote selector miss into a terminal failure.
8
+ //
9
+ // Pure types + pure functions only: no http, no fetch, no fs — so both sides (and the
10
+ // unit tests) can import it without dragging in transport code.
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.describeError = describeError;
13
+ exports.rebuildError = rebuildError;
14
+ const errors_1 = require("./errors");
15
+ // --- error codec ------------------------------------------------------------
16
+ /** Serialize a thrown error for the wire, preserving what the engine needs to
17
+ * tell a heal trigger from a terminal failure. */
18
+ function describeError(e) {
19
+ if (e instanceof errors_1.AmbiguousSelectorError) {
20
+ return { kind: 'AmbiguousSelectorError', name: e.name, message: e.message, exitCode: e.exitCode, candidates: e.candidates };
21
+ }
22
+ if (e instanceof errors_1.SelectorNotFoundError) {
23
+ return { kind: 'SelectorNotFoundError', name: e.name, message: e.message, exitCode: e.exitCode };
24
+ }
25
+ if (e instanceof errors_1.CliError) {
26
+ return { kind: 'CliError', name: e.name, message: e.message, exitCode: e.exitCode };
27
+ }
28
+ return { kind: 'Error', name: e.name || 'Error', message: e.message, exitCode: 3 };
29
+ }
30
+ /** Rebuild the error a server serialized, restoring its class so `instanceof`
31
+ * checks (and `candidates` / `exitCode`) behave as if it were thrown locally. */
32
+ function rebuildError(d) {
33
+ switch (d.kind) {
34
+ case 'AmbiguousSelectorError':
35
+ return new errors_1.AmbiguousSelectorError(d.message, d.candidates ?? []);
36
+ case 'SelectorNotFoundError':
37
+ return new errors_1.SelectorNotFoundError(d.message);
38
+ case 'CliError':
39
+ return new errors_1.CliError(d.message, d.exitCode);
40
+ default: {
41
+ const e = new Error(d.message);
42
+ e.name = d.name || 'Error';
43
+ return e;
44
+ }
45
+ }
46
+ }