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/dist/run.js ADDED
@@ -0,0 +1,434 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Recorder = void 0;
4
+ exports.isRecordable = isRecordable;
5
+ exports.rolloverReason = rolloverReason;
6
+ exports.stepName = stepName;
7
+ const node_fs_1 = require("node:fs");
8
+ const node_path_1 = require("node:path");
9
+ const format_1 = require("./ui/format");
10
+ const errors_1 = require("./errors");
11
+ const output_1 = require("./output");
12
+ const report_1 = require("./report");
13
+ // Commands that become a recorded step (a JUnit testcase). Inspection commands
14
+ // (ui, find, devices, doctor, current) are deliberately excluded — they are how
15
+ // an agent decides what to do, not assertions about the app. `log` is the one
16
+ // exception: it inspects, but is recorded so its captured device logs land in
17
+ // the archived report (the agent pulls them on-demand after a failure).
18
+ const RECORDABLE = new Set([
19
+ 'tap', 'click',
20
+ 'text', 'type',
21
+ 'key', 'back', 'home', 'enter',
22
+ 'swipe', 'scroll',
23
+ 'screenshot', 'shot',
24
+ 'wait', 'assert',
25
+ 'launch', 'open', 'stop', 'clear',
26
+ 'log', 'logs',
27
+ ]);
28
+ function isRecordable(command) {
29
+ return RECORDABLE.has(command);
30
+ }
31
+ const HIERARCHY_CAP = 24000; // chars of failure hierarchy kept inline in run.json
32
+ const LOG_CAP = 50000; // chars of device logs kept inline in run.json (tail-kept)
33
+ // --- paths & persistence --------------------------------------------------
34
+ const activeDir = () => (0, node_path_1.join)((0, output_1.artifactDir)(), 'run');
35
+ const archiveBase = () => (0, node_path_1.join)((0, output_1.artifactDir)(), 'runs');
36
+ const statePath = (dir) => (0, node_path_1.join)(dir, 'run.json');
37
+ function loadState(dir) {
38
+ if (!(0, node_fs_1.existsSync)(statePath(dir)))
39
+ return null;
40
+ try {
41
+ return JSON.parse((0, node_fs_1.readFileSync)(statePath(dir), 'utf8'));
42
+ }
43
+ catch (e) {
44
+ // The state file exists but won't parse — it's corrupt. Surface it (then treat as no
45
+ // active run, which the next run start overwrites) instead of swallowing it silently.
46
+ (0, output_1.err)(`[verikun] ignoring unreadable run state ${statePath(dir)} (${e.message})`);
47
+ return null;
48
+ }
49
+ }
50
+ function saveState(dir, state) {
51
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
52
+ (0, node_fs_1.writeFileSync)(statePath(dir), JSON.stringify(state, null, 2));
53
+ }
54
+ function nowIso() {
55
+ return new Date().toISOString();
56
+ }
57
+ function runId() {
58
+ const d = new Date();
59
+ const p = (n) => String(n).padStart(2, '0');
60
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
61
+ }
62
+ function uniqueDir(base) {
63
+ if (!(0, node_fs_1.existsSync)(base))
64
+ return base;
65
+ for (let i = 2;; i++) {
66
+ const candidate = `${base}-${i}`;
67
+ if (!(0, node_fs_1.existsSync)(candidate))
68
+ return candidate;
69
+ }
70
+ }
71
+ // --- run-context rollover -------------------------------------------------
72
+ //
73
+ // An implicit run should not silently swallow unrelated activity. Before adding
74
+ // a step to an existing run we check whether the context still matches; if not,
75
+ // the old run is auto-closed (archived — never discarded) and a fresh one starts.
76
+ /** A stable-per-session id, if the environment provides one. Opt-in by design:
77
+ * in an agent harness each command may be a fresh shell, so we never derive it
78
+ * from the process tree (that would roll over on every action). */
79
+ function currentSession() {
80
+ return process.env.VERIKUN_SESSION || process.env.TERM_SESSION_ID || undefined;
81
+ }
82
+ /** Idle-timeout in minutes (0 disables). Default 30. */
83
+ function idleMinutes() {
84
+ const v = process.env.VERIKUN_RUN_IDLE_MIN;
85
+ if (v === undefined)
86
+ return 30;
87
+ const n = Number(v);
88
+ return Number.isFinite(n) && n >= 0 ? n : 30;
89
+ }
90
+ function lastActiveMs(state) {
91
+ const ts = state.updatedAt ||
92
+ (state.steps.length ? state.steps[state.steps.length - 1].startedAt : '') ||
93
+ state.startedAt;
94
+ const ms = Date.parse(ts);
95
+ return Number.isNaN(ms) ? Date.now() : ms;
96
+ }
97
+ function ageMs(state) {
98
+ return Date.now() - lastActiveMs(state);
99
+ }
100
+ function fmtAge(ms) {
101
+ const m = Math.floor(ms / 60000);
102
+ if (m < 1)
103
+ return '<1m';
104
+ if (m < 60)
105
+ return `${m}m`;
106
+ return `${Math.floor(m / 60)}h${m % 60}m`;
107
+ }
108
+ /** Why the active run should be closed before recording the next step, or null to keep it. */
109
+ function rolloverReason(state, serial, session) {
110
+ // A different device or session is a hard context change — applies to any run.
111
+ if (state.device && serial && state.device !== serial)
112
+ return `device changed (${state.device} → ${serial})`;
113
+ if (state.session && session && state.session !== session)
114
+ return 'different session';
115
+ // Idle timeout only retires runs that were auto-started; an explicitly named
116
+ // run (`vk run start`) is the user's deliberate container and persists.
117
+ const idle = idleMinutes();
118
+ if (idle > 0 && state.implicit && ageMs(state) > idle * 60000)
119
+ return `idle for ${fmtAge(ageMs(state))} (>${idle}m)`;
120
+ return null;
121
+ }
122
+ function stepName(command, positionals, flags) {
123
+ const p = positionals;
124
+ const at = typeof flags['at'] === 'string' ? flags['at'] : undefined;
125
+ const from = typeof flags['from'] === 'string' ? flags['from'] : undefined;
126
+ const to = typeof flags['to'] === 'string' ? flags['to'] : undefined;
127
+ const on = typeof flags['on'] === 'string' ? flags['on'] : undefined;
128
+ switch (command) {
129
+ case 'tap':
130
+ case 'click':
131
+ return at ? `tap (${at})` : `tap ${p[0] ?? ''}`.trim();
132
+ case 'text':
133
+ return `text ${p[0] ?? ''}`.trim(); // omit the typed value (may be secret)
134
+ case 'type':
135
+ return 'type';
136
+ case 'swipe':
137
+ case 'scroll':
138
+ if (from && to)
139
+ return `swipe ${from}->${to}`;
140
+ return `swipe ${p[0] ?? ''}${on ? ` on ${on}` : ''}`.trim();
141
+ case 'screenshot':
142
+ case 'shot':
143
+ return 'screenshot';
144
+ case 'back':
145
+ case 'home':
146
+ case 'enter':
147
+ return command;
148
+ default:
149
+ return `${command} ${p[0] ?? ''}`.trim();
150
+ }
151
+ }
152
+ // --- recorder -------------------------------------------------------------
153
+ class Recorder {
154
+ state;
155
+ dir;
156
+ step;
157
+ startMs;
158
+ constructor(state, dir, step, startMs) {
159
+ this.state = state;
160
+ this.dir = dir;
161
+ this.step = step;
162
+ this.startMs = startMs;
163
+ }
164
+ /**
165
+ * Open a step for a recordable command, auto-starting an implicit run if none
166
+ * is active. Returns null when recording is disabled via VERIKUN_NO_RUN.
167
+ */
168
+ static beginStep(command, positionals, flags, platform, deviceReq, serial, driver) {
169
+ if (process.env.VERIKUN_NO_RUN)
170
+ return null;
171
+ const dir = activeDir();
172
+ let state = loadState(dir);
173
+ const session = currentSession();
174
+ let rolledOver = false;
175
+ // Close a stale / context-mismatched run before continuing.
176
+ if (state) {
177
+ const reason = rolloverReason(state, serial, session);
178
+ if (reason) {
179
+ try {
180
+ const dest = Recorder.seal(state, dir);
181
+ (0, output_1.err)(`[verikun] previous run '${state.name}' (${state.steps.length} step(s)) auto-closed → ${dest} (${reason}); starting a fresh run`);
182
+ state = null;
183
+ rolledOver = true;
184
+ }
185
+ catch (e) {
186
+ (0, output_1.err)(`[verikun] could not close stale run (${e.message}); appending to it instead`);
187
+ }
188
+ }
189
+ }
190
+ if (!state) {
191
+ state = {
192
+ id: runId(),
193
+ name: 'run',
194
+ startedAt: nowIso(),
195
+ updatedAt: nowIso(),
196
+ platform,
197
+ device: serial || deviceReq,
198
+ session,
199
+ implicit: true,
200
+ steps: [],
201
+ };
202
+ if (!rolledOver) {
203
+ (0, output_1.err)('[verikun] recording test run (implicit) — archive: `vk run archive` · discard: `vk run clear`');
204
+ }
205
+ }
206
+ else {
207
+ // Backfill identity once it becomes known (e.g. a run started without a device).
208
+ if (!state.device && (serial || deviceReq))
209
+ state.device = serial || deviceReq;
210
+ if (!state.session && session)
211
+ state.session = session;
212
+ }
213
+ // Anchor the log window at the session's first step (covers both implicit
214
+ // creation and an explicit `vk run start`, which records no marker itself).
215
+ // Best-effort — a missing marker just means `vk log` falls back to last-N.
216
+ if (driver && !state.logStart) {
217
+ try {
218
+ const t = driver.deviceTime();
219
+ if (t)
220
+ state.logStart = t;
221
+ }
222
+ catch {
223
+ /* device clock unavailable */
224
+ }
225
+ }
226
+ const step = {
227
+ index: state.steps.length,
228
+ command,
229
+ name: stepName(command, positionals, flags),
230
+ startedAt: nowIso(),
231
+ durationMs: 0,
232
+ status: 'passed',
233
+ exitCode: 0,
234
+ };
235
+ return new Recorder(state, dir, step, Date.now());
236
+ }
237
+ /** Attach selector / heal-tier / resolved-element / message detail to the current step. */
238
+ note(info) {
239
+ const s = this.step;
240
+ if (info.selector) {
241
+ s.selector = {
242
+ raw: info.selector.raw,
243
+ kind: info.selector.kind,
244
+ value: info.selector.value,
245
+ contains: info.selector.contains || undefined,
246
+ index: info.selector.index,
247
+ };
248
+ }
249
+ if (info.tier && info.tier !== 'exact')
250
+ s.tier = info.tier;
251
+ if (info.element) {
252
+ const el = info.element;
253
+ s.resolved = {
254
+ type: el.type,
255
+ id: el.id || undefined,
256
+ idShort: el.idShort || undefined,
257
+ text: el.text || undefined,
258
+ desc: el.desc || undefined,
259
+ center: el.center,
260
+ };
261
+ }
262
+ if (info.message)
263
+ s.message = info.message;
264
+ }
265
+ /** Store an image captured by a `screenshot` step in the run's artifacts. */
266
+ attachImage(buf) {
267
+ const rel = `artifacts/step-${this.step.index}-screenshot.png`;
268
+ this.writeArtifact(rel, buf);
269
+ this.step.image = rel;
270
+ }
271
+ /** Store device logs captured by a `log` step. Keeps the TAIL — the newest
272
+ * lines, where a crash/stack trace is — unlike failHierarchy which keeps the head. */
273
+ attachLog(text) {
274
+ this.step.logs = text.length > LOG_CAP ? '…(truncated)\n' + text.slice(-LOG_CAP) : text;
275
+ }
276
+ /** The session-start log marker — but only once a prior step exists, so a `log`
277
+ * that is itself the first action isn't scoped to an empty (just-now) window. */
278
+ logWindowStart() {
279
+ return this.state.steps.length > 0 ? this.state.logStart : undefined;
280
+ }
281
+ /** Finalize a step that returned a normal exit code (0 pass, 1 fail, ≥2 error). */
282
+ finish(exitCode, driver) {
283
+ this.step.exitCode = exitCode;
284
+ this.step.status = exitCode === 0 ? 'passed' : exitCode === 1 ? 'failed' : 'error';
285
+ if (exitCode !== 0)
286
+ this.capture(driver);
287
+ this.commit();
288
+ }
289
+ /** Finalize a step whose command threw. */
290
+ finishError(e, driver) {
291
+ const exitCode = e instanceof errors_1.CliError ? e.exitCode : 3;
292
+ this.step.exitCode = exitCode;
293
+ this.step.status = exitCode === 1 ? 'failed' : 'error';
294
+ if (!this.step.message)
295
+ this.step.message = e.message;
296
+ this.capture(driver);
297
+ this.commit();
298
+ }
299
+ // Best-effort: grab a screenshot and the UI hierarchy of the failing page.
300
+ // The device may be unreachable (that may be why we failed) — swallow errors.
301
+ capture(driver) {
302
+ if (!driver)
303
+ return;
304
+ try {
305
+ this.writeArtifact(`artifacts/step-${this.step.index}-fail.png`, driver.screenshot());
306
+ this.step.failImage = `artifacts/step-${this.step.index}-fail.png`;
307
+ }
308
+ catch (e) {
309
+ // Best-effort evidence: the device may be gone (often why the step failed). Surface
310
+ // it so a screenshot bug isn't hidden, but never let it derail failure recording.
311
+ (0, output_1.err)(`[verikun] could not capture failure screenshot (${e.message})`);
312
+ }
313
+ try {
314
+ const text = (0, format_1.formatCompact)(driver.getElements({ all: false }));
315
+ this.step.failHierarchy = text.length > HIERARCHY_CAP ? text.slice(0, HIERARCHY_CAP) + '\n…(truncated)' : text;
316
+ }
317
+ catch (e) {
318
+ (0, output_1.err)(`[verikun] could not capture failure hierarchy (${e.message})`);
319
+ }
320
+ }
321
+ writeArtifact(rel, buf) {
322
+ (0, node_fs_1.mkdirSync)((0, node_path_1.join)(this.dir, 'artifacts'), { recursive: true });
323
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(this.dir, rel), buf);
324
+ }
325
+ commit() {
326
+ this.step.durationMs = Date.now() - this.startMs;
327
+ this.state.steps.push(this.step);
328
+ this.state.updatedAt = nowIso();
329
+ saveState(this.dir, this.state);
330
+ }
331
+ /** Finalize a run: write reports next to it, then move it into ./.verikun/runs/<id>/. */
332
+ static seal(state, dir) {
333
+ state.finishedAt = nowIso();
334
+ state.updatedAt = nowIso();
335
+ saveState(dir, state);
336
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, 'report.xml'), (0, report_1.toJUnitXml)(state));
337
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(dir, 'report.html'), (0, report_1.toHtml)(state));
338
+ (0, node_fs_1.mkdirSync)(archiveBase(), { recursive: true });
339
+ const dest = uniqueDir((0, node_path_1.join)(archiveBase(), state.id));
340
+ (0, node_fs_1.renameSync)(dir, dest);
341
+ return dest;
342
+ }
343
+ /** One-line context summary for `vk run status`. */
344
+ static contextLine(state) {
345
+ const bits = [];
346
+ if (state.device)
347
+ bits.push(`device ${state.device}`);
348
+ if (state.session)
349
+ bits.push(`session ${state.session}`);
350
+ bits.push(`last active ${fmtAge(ageMs(state))} ago`);
351
+ return bits.join(' · ');
352
+ }
353
+ // --- lifecycle (vk run <sub>) ------------------------------------------
354
+ static status() {
355
+ return loadState(activeDir());
356
+ }
357
+ /** Merge a patch into the active run (used by `vk ai` to attach its summary
358
+ * before archiving). No-op if there is no active run. */
359
+ static annotateRun(patch) {
360
+ const dir = activeDir();
361
+ const state = loadState(dir);
362
+ if (!state)
363
+ return;
364
+ Object.assign(state, patch);
365
+ saveState(dir, state);
366
+ }
367
+ /** Downgrade the most-recently-recorded step from a failed attempt to a healed
368
+ * pass — the `vk ai` engine calls this after a successful repair so a
369
+ * self-healed leaf does not count as a failure (and the run that the engine
370
+ * considers green has a clean report). No-op if there is no active run. */
371
+ static markLastStepHealed(message) {
372
+ const dir = activeDir();
373
+ const state = loadState(dir);
374
+ if (!state || state.steps.length === 0)
375
+ return;
376
+ const last = state.steps[state.steps.length - 1];
377
+ last.healed = true;
378
+ last.status = 'passed';
379
+ last.exitCode = 0;
380
+ // The attempt that just failed had failure evidence captured (screenshot +
381
+ // hierarchy); drop it so a now-green healed step doesn't render as a failure.
382
+ delete last.failImage;
383
+ delete last.failHierarchy;
384
+ if (message)
385
+ last.message = message;
386
+ saveState(dir, state);
387
+ }
388
+ static start(name, platform, device, force) {
389
+ const dir = activeDir();
390
+ const existing = loadState(dir);
391
+ if (existing && existing.steps.length > 0 && !force) {
392
+ throw new errors_1.CliError(`A test run ('${existing.name}', ${existing.steps.length} step(s)) is already active. ` +
393
+ 'Archive it (`vk run archive`), discard it (`vk run clear`), or pass --force to replace.', 2);
394
+ }
395
+ if ((0, node_fs_1.existsSync)(dir))
396
+ (0, node_fs_1.rmSync)(dir, { recursive: true, force: true });
397
+ const state = {
398
+ id: runId(),
399
+ name: name || 'run',
400
+ startedAt: nowIso(),
401
+ updatedAt: nowIso(),
402
+ platform,
403
+ device,
404
+ session: currentSession(),
405
+ implicit: false,
406
+ steps: [],
407
+ };
408
+ saveState(dir, state);
409
+ return state;
410
+ }
411
+ /** Discard the active run without producing a report. Returns what was cleared. */
412
+ static clear() {
413
+ const dir = activeDir();
414
+ const existing = loadState(dir);
415
+ if ((0, node_fs_1.existsSync)(dir))
416
+ (0, node_fs_1.rmSync)(dir, { recursive: true, force: true });
417
+ return existing;
418
+ }
419
+ /** Write JUnit + HTML reports and move the run into ./.verikun/runs/<id>/. */
420
+ static archive(name) {
421
+ const dir = activeDir();
422
+ if (!(0, node_fs_1.existsSync)(statePath(dir))) {
423
+ throw new errors_1.CliError('No active test run to archive. Run an action first, or `vk run start`.', 1);
424
+ }
425
+ const state = loadState(dir);
426
+ if (!state)
427
+ throw new errors_1.CliError('Active run state is unreadable (.verikun/run/run.json is corrupt).', 3);
428
+ if (name)
429
+ state.name = name;
430
+ const dest = Recorder.seal(state, dir);
431
+ return { dir: dest, xmlPath: (0, node_path_1.join)(dest, 'report.xml'), htmlPath: (0, node_path_1.join)(dest, 'report.html'), state };
432
+ }
433
+ }
434
+ exports.Recorder = Recorder;
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ // Shared types for verikun. The Element model is normalized across platforms so
3
+ // the selector / formatting / command layers are fully platform-agnostic — each
4
+ // Driver is responsible only for producing Element[] from its native source.
5
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isInteresting = isInteresting;
4
+ exports.parseHierarchy = parseHierarchy;
5
+ // Parses uiautomator XML (from `adb shell uiautomator dump`) into normalized
6
+ // Element[]. We hand-roll a tiny tag scanner rather than pulling in an XML
7
+ // dependency: uiautomator output is regular and entity-escaped, so a
8
+ // quote-aware scan + attribute regex is robust for this specific format.
9
+ const NAMED_ENTITIES = {
10
+ lt: '<',
11
+ gt: '>',
12
+ amp: '&',
13
+ quot: '"',
14
+ apos: "'",
15
+ };
16
+ function decodeEntities(s) {
17
+ return s.replace(/&(#x?[0-9a-fA-F]+|lt|gt|amp|quot|apos);/g, (whole, body) => {
18
+ if (body[0] === '#') {
19
+ const code = body[1] === 'x' || body[1] === 'X'
20
+ ? parseInt(body.slice(2), 16)
21
+ : parseInt(body.slice(1), 10);
22
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
23
+ }
24
+ return NAMED_ENTITIES[body] ?? whole;
25
+ });
26
+ }
27
+ function parseAttrs(tagBody) {
28
+ const attrs = {};
29
+ // Values are double-quoted and any literal '"' is escaped to &quot;, so [^"]*
30
+ // safely captures the whole value.
31
+ const re = /([\w:.-]+)="([^"]*)"/g;
32
+ let m;
33
+ while ((m = re.exec(tagBody))) {
34
+ attrs[m[1]] = decodeEntities(m[2]);
35
+ }
36
+ return attrs;
37
+ }
38
+ function simpleType(cls) {
39
+ if (!cls)
40
+ return 'View';
41
+ const parts = cls.split('.');
42
+ return parts[parts.length - 1] || cls;
43
+ }
44
+ function parseBounds(s) {
45
+ const m = /\[(-?\d+),(-?\d+)\]\[(-?\d+),(-?\d+)\]/.exec(s ?? '');
46
+ if (!m)
47
+ return { x1: 0, y1: 0, x2: 0, y2: 0 };
48
+ return { x1: +m[1], y1: +m[2], x2: +m[3], y2: +m[4] };
49
+ }
50
+ const asBool = (v) => v === 'true';
51
+ function buildElement(a, depth) {
52
+ const cls = a['class'] ?? '';
53
+ const id = a['resource-id'] ?? '';
54
+ const bounds = parseBounds(a['bounds']);
55
+ return {
56
+ index: -1,
57
+ class: cls,
58
+ type: simpleType(cls),
59
+ id,
60
+ idShort: id.includes('/') ? id.slice(id.lastIndexOf('/') + 1) : id,
61
+ text: a['text'] ?? '',
62
+ desc: a['content-desc'] ?? '',
63
+ bounds,
64
+ center: {
65
+ x: Math.floor((bounds.x1 + bounds.x2) / 2),
66
+ y: Math.floor((bounds.y1 + bounds.y2) / 2),
67
+ },
68
+ depth,
69
+ clickable: asBool(a['clickable']),
70
+ longClickable: asBool(a['long-clickable']),
71
+ checkable: asBool(a['checkable']),
72
+ checked: asBool(a['checked']),
73
+ focusable: asBool(a['focusable']),
74
+ focused: asBool(a['focused']),
75
+ scrollable: asBool(a['scrollable']),
76
+ enabled: a['enabled'] === undefined ? true : asBool(a['enabled']),
77
+ selected: asBool(a['selected']),
78
+ password: asBool(a['password']),
79
+ };
80
+ }
81
+ /**
82
+ * "Interesting" = something an agent can act on or read. Pure layout containers
83
+ * (no text/id/desc and not interactive) are dropped to keep snapshots compact.
84
+ */
85
+ function isInteresting(el) {
86
+ const w = el.bounds.x2 - el.bounds.x1;
87
+ const h = el.bounds.y2 - el.bounds.y1;
88
+ if (w <= 0 || h <= 0)
89
+ return false; // not visible / not tappable
90
+ if (el.text.trim())
91
+ return true;
92
+ if (el.desc.trim())
93
+ return true;
94
+ if (el.id)
95
+ return true;
96
+ if (el.clickable || el.checkable || el.scrollable || el.longClickable)
97
+ return true;
98
+ if (/EditText|AutoComplete|TextField|Edit$/.test(el.class))
99
+ return true;
100
+ return false;
101
+ }
102
+ function parseHierarchy(xml, opts = {}) {
103
+ const all = [];
104
+ const n = xml.length;
105
+ let i = 0;
106
+ let depth = 0;
107
+ while (i < n) {
108
+ const lt = xml.indexOf('<', i);
109
+ if (lt < 0)
110
+ break;
111
+ // Find the matching '>' that is not inside a quoted attribute value.
112
+ let j = lt + 1;
113
+ let inQuote = false;
114
+ while (j < n) {
115
+ const c = xml[j];
116
+ if (c === '"')
117
+ inQuote = !inQuote;
118
+ else if (c === '>' && !inQuote)
119
+ break;
120
+ j++;
121
+ }
122
+ if (j >= n)
123
+ break;
124
+ const tag = xml.slice(lt + 1, j);
125
+ i = j + 1;
126
+ if (tag[0] === '?' || tag[0] === '!')
127
+ continue; // <?xml ...?> / comments
128
+ if (tag[0] === '/') {
129
+ const closeName = tag.slice(1).trim();
130
+ if (closeName === 'node')
131
+ depth = Math.max(0, depth - 1);
132
+ continue;
133
+ }
134
+ const selfClosing = tag.endsWith('/');
135
+ const body = selfClosing ? tag.slice(0, -1) : tag;
136
+ const sp = body.search(/\s/);
137
+ const name = sp < 0 ? body : body.slice(0, sp);
138
+ if (name === 'node') {
139
+ all.push(buildElement(parseAttrs(body), depth));
140
+ if (!selfClosing)
141
+ depth++;
142
+ }
143
+ }
144
+ const result = opts.all ? all : all.filter(isInteresting);
145
+ result.forEach((el, idx) => {
146
+ el.index = idx;
147
+ });
148
+ return result;
149
+ }
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatInline = formatInline;
4
+ exports.formatCompact = formatCompact;
5
+ exports.formatTree = formatTree;
6
+ exports.toJsonShape = toJsonShape;
7
+ // Compact one-line rendering tuned for AI agents: dense, scannable, and using
8
+ // the same `@idShort` token that the selector grammar accepts, so an element
9
+ // printed here can be copy-pasted straight back into a `tap`/`find` command.
10
+ function clip(s, max) {
11
+ return s.length > max ? s.slice(0, max - 1) + '…' : s;
12
+ }
13
+ function formatInline(el) {
14
+ const parts = [`[${el.index}]`, el.type];
15
+ if (el.text.trim())
16
+ parts.push(JSON.stringify(clip(el.text, 60)));
17
+ if (el.idShort)
18
+ parts.push('@' + el.idShort);
19
+ if (el.desc.trim() && el.desc !== el.text)
20
+ parts.push('desc=' + JSON.stringify(clip(el.desc, 40)));
21
+ parts.push(`(${el.center.x},${el.center.y})`);
22
+ const flags = [];
23
+ if (el.clickable)
24
+ flags.push('tap');
25
+ if (el.scrollable)
26
+ flags.push('scroll');
27
+ if (el.checkable)
28
+ flags.push(el.checked ? 'checked' : 'unchecked');
29
+ if (el.focused)
30
+ flags.push('focused');
31
+ if (el.password)
32
+ flags.push('pwd');
33
+ if (el.selected)
34
+ flags.push('selected');
35
+ if (!el.enabled)
36
+ flags.push('disabled');
37
+ if (flags.length)
38
+ parts.push(flags.join(','));
39
+ return parts.join(' ');
40
+ }
41
+ function formatCompact(elements) {
42
+ if (!elements.length)
43
+ return '(no elements)';
44
+ return elements.map(formatInline).join('\n');
45
+ }
46
+ function formatTree(elements) {
47
+ if (!elements.length)
48
+ return '(no elements)';
49
+ return elements.map((el) => ' '.repeat(el.depth) + formatInline(el)).join('\n');
50
+ }
51
+ /** Structured shape for --json. Omits empty/false fields to stay compact. */
52
+ function toJsonShape(el) {
53
+ return {
54
+ index: el.index,
55
+ type: el.type,
56
+ class: el.class,
57
+ id: el.id || undefined,
58
+ text: el.text || undefined,
59
+ desc: el.desc || undefined,
60
+ center: el.center,
61
+ bounds: el.bounds,
62
+ clickable: el.clickable || undefined,
63
+ scrollable: el.scrollable || undefined,
64
+ checkable: el.checkable || undefined,
65
+ checked: el.checkable ? el.checked : undefined,
66
+ focused: el.focused || undefined,
67
+ password: el.password || undefined,
68
+ enabled: el.enabled,
69
+ selected: el.selected || undefined,
70
+ };
71
+ }