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