visor-ai 0.2.7 → 0.2.8

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/appMap.js ADDED
@@ -0,0 +1,721 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { resolveTapMode } from './adapters.js';
6
+ import { canonicalJson, ensureDir, signatureFor, utcNowIso } from './utils.js';
7
+ export const APP_MAP_SCHEMA_VERSION = 1;
8
+ const RISKY_WORDS = [
9
+ 'delete',
10
+ 'remove',
11
+ 'logout',
12
+ 'log out',
13
+ 'sign out',
14
+ 'submit',
15
+ 'send',
16
+ 'pay',
17
+ 'purchase',
18
+ 'buy',
19
+ 'confirm',
20
+ 'destroy',
21
+ 'archive'
22
+ ];
23
+ const INPUT_WORDS = ['password', 'email', 'username', 'search', 'input', 'text field', 'textfield'];
24
+ const AUTH_WORDS = ['login', 'log in', 'sign in', 'password', 'username', 'auth', 'authentication'];
25
+ function envNoMap() {
26
+ const raw = process.env.VISOR_NO_MAP ?? process.env.VISOR_DISABLE_MAP;
27
+ return ['1', 'true', 'yes', 'on'].includes(String(raw ?? '').trim().toLowerCase());
28
+ }
29
+ function resolveOptions(platform, options) {
30
+ const enabled = options?.enabled !== false && !envNoMap();
31
+ return {
32
+ enabled,
33
+ rootDir: options?.rootDir ?? process.env.VISOR_APP_MAP_DIR ?? path.join(process.cwd(), '.visor', 'maps'),
34
+ appId: options?.appId ?? `default-${platform}`,
35
+ repairDepth: options?.repairDepth ?? 2,
36
+ repairTimeoutMs: options?.repairTimeoutMs ?? 30000
37
+ };
38
+ }
39
+ function mapIdentity(platform, appId) {
40
+ return `${platform}:${appId}`;
41
+ }
42
+ function mapFilePath(rootDir, identity) {
43
+ const digest = createHash('sha256').update(identity, 'utf8').digest('hex').slice(0, 16);
44
+ return path.join(ensureDir(rootDir), `${digest}.json`);
45
+ }
46
+ function newMap(platform, appId) {
47
+ const now = utcNowIso();
48
+ return {
49
+ schema_version: APP_MAP_SCHEMA_VERSION,
50
+ identity: mapIdentity(platform, appId),
51
+ app_id: appId,
52
+ platform,
53
+ created_at: now,
54
+ updated_at: now,
55
+ screens: [],
56
+ variants: [],
57
+ edges: []
58
+ };
59
+ }
60
+ function readMap(filePath, platform, appId) {
61
+ if (!fs.existsSync(filePath)) {
62
+ return { map: newMap(platform, appId), scrubbed: false };
63
+ }
64
+ try {
65
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
66
+ if (parsed.schema_version !== APP_MAP_SCHEMA_VERSION || parsed.identity !== mapIdentity(platform, appId)) {
67
+ return { map: newMap(platform, appId), scrubbed: false };
68
+ }
69
+ const originalEdgeCount = parsed.edges.length;
70
+ parsed.edges = parsed.edges.filter((edge) => !isValueBearingAction(edge.command, edge.args));
71
+ return { map: parsed, scrubbed: parsed.edges.length !== originalEdgeCount };
72
+ }
73
+ catch {
74
+ return { map: newMap(platform, appId), scrubbed: false };
75
+ }
76
+ }
77
+ function writeMap(filePath, map) {
78
+ ensureDir(path.dirname(filePath));
79
+ const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
80
+ fs.writeFileSync(tempPath, `${JSON.stringify(map, null, 2)}\n`, 'utf8');
81
+ fs.renameSync(tempPath, filePath);
82
+ }
83
+ export function createMapSummary(adapter, options) {
84
+ const platform = adapter.capability().platform;
85
+ const resolved = resolveOptions(platform, options);
86
+ const identity = mapIdentity(platform, resolved.appId);
87
+ return {
88
+ enabled: resolved.enabled,
89
+ used: false,
90
+ updated: false,
91
+ repaired: false,
92
+ repairs: 0,
93
+ schema_version: APP_MAP_SCHEMA_VERSION,
94
+ path: resolved.enabled ? mapFilePath(resolved.rootDir, identity) : undefined,
95
+ identity
96
+ };
97
+ }
98
+ export function createAppMapContext(adapter, options) {
99
+ const platform = adapter.capability().platform;
100
+ const resolved = resolveOptions(platform, options);
101
+ if (!resolved.enabled) {
102
+ return null;
103
+ }
104
+ const identity = mapIdentity(platform, resolved.appId);
105
+ const filePath = mapFilePath(resolved.rootDir, identity);
106
+ const summary = createMapSummary(adapter, options);
107
+ const loaded = readMap(filePath, platform, resolved.appId);
108
+ return {
109
+ adapter,
110
+ map: loaded.map,
111
+ filePath,
112
+ options: resolved,
113
+ summary,
114
+ changed: loaded.scrubbed
115
+ };
116
+ }
117
+ export function persistAppMapContext(context) {
118
+ if (!context) {
119
+ return undefined;
120
+ }
121
+ context.summary.updated = context.summary.updated || context.changed;
122
+ if (context.changed) {
123
+ context.map.updated_at = utcNowIso();
124
+ writeMap(context.filePath, context.map);
125
+ context.changed = false;
126
+ }
127
+ return context.summary;
128
+ }
129
+ function attributeMap(input) {
130
+ const attrs = {};
131
+ const pattern = /([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
132
+ for (const match of input.matchAll(pattern)) {
133
+ attrs[match[1]] = match[3] ?? match[4] ?? '';
134
+ }
135
+ return attrs;
136
+ }
137
+ function normalizeLabel(value) {
138
+ return value
139
+ .replace(/[0-9a-f]{8,}/gi, '<id>')
140
+ .replace(/\b\d{1,2}:\d{2}(?::\d{2})?\b/g, '<time>')
141
+ .replace(/\b\d+\b/g, '<number>')
142
+ .replace(/\s+/g, ' ')
143
+ .trim()
144
+ .toLowerCase();
145
+ }
146
+ function unique(values) {
147
+ return Array.from(new Set(values.filter((value) => value.trim() !== '')));
148
+ }
149
+ function isLikelyFormControl(tag, attrs) {
150
+ const haystack = [
151
+ tag,
152
+ attrs.type ?? '',
153
+ attrs.class ?? '',
154
+ attrs.name ?? '',
155
+ attrs.label ?? '',
156
+ attrs.placeholder ?? ''
157
+ ]
158
+ .join(' ')
159
+ .toLowerCase();
160
+ return [
161
+ 'input',
162
+ 'textfield',
163
+ 'text field',
164
+ 'edittext',
165
+ 'secure',
166
+ 'password',
167
+ 'email',
168
+ 'username',
169
+ 'search',
170
+ 'phone',
171
+ 'credit',
172
+ 'card'
173
+ ].some((word) => haystack.includes(word));
174
+ }
175
+ function redactLabel(value) {
176
+ return value
177
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '<redacted-email>')
178
+ .replace(/\b(?:\+?\d[\d .()-]{7,}\d)\b/g, '<redacted-phone>')
179
+ .replace(/\b(?:\d[ -]*?){13,19}\b/g, '<redacted-card>');
180
+ }
181
+ function classifyControl(tag, labels, attrs) {
182
+ const haystack = [...labels, tag, attrs.type ?? '', attrs.class ?? ''].join(' ').toLowerCase();
183
+ if (RISKY_WORDS.some((word) => haystack.includes(word))) {
184
+ return 'risky';
185
+ }
186
+ if (INPUT_WORDS.some((word) => haystack.includes(word))) {
187
+ return 'needs-input';
188
+ }
189
+ if (tag.toLowerCase().includes('button') ||
190
+ attrs.clickable === 'true' ||
191
+ attrs.enabled === 'true' ||
192
+ attrs.accessible === 'true') {
193
+ return 'safe';
194
+ }
195
+ return 'unknown';
196
+ }
197
+ function extractElements(source) {
198
+ const elements = [];
199
+ const tagPattern = /<([A-Za-z][A-Za-z0-9_.:-]*)([^<>]*?)(?:\/>|>([^<>]*)<\/\1>)/g;
200
+ for (const match of source.matchAll(tagPattern)) {
201
+ const tag = match[1];
202
+ const attrs = attributeMap(match[2] ?? '');
203
+ const bodyText = (match[3] ?? '').trim();
204
+ const formControl = isLikelyFormControl(tag, attrs);
205
+ const rawLabels = unique([
206
+ redactLabel(attrs.name ?? ''),
207
+ redactLabel(attrs.label ?? ''),
208
+ formControl ? '' : redactLabel(attrs.text ?? ''),
209
+ formControl ? '' : redactLabel(attrs.value ?? ''),
210
+ redactLabel(attrs['content-desc'] ?? ''),
211
+ redactLabel(attrs['contentDescription'] ?? ''),
212
+ formControl ? '' : redactLabel(bodyText)
213
+ ]);
214
+ const id = attrs['resource-id'] ?? attrs.id ?? attrs.identifier ?? '';
215
+ const targets = unique([
216
+ ...rawLabels,
217
+ ...rawLabels.map((label) => `text=${label}`),
218
+ id ? `id=${id}` : ''
219
+ ]);
220
+ if (targets.length === 0) {
221
+ continue;
222
+ }
223
+ const stable = Boolean(rawLabels.length > 0 || id);
224
+ const selector = id ? `id=${id}` : rawLabels[0] ?? targets[0];
225
+ elements.push({
226
+ tag,
227
+ selector,
228
+ targets,
229
+ labels: rawLabels,
230
+ stable,
231
+ safety: classifyControl(tag, rawLabels, attrs)
232
+ });
233
+ }
234
+ return elements;
235
+ }
236
+ function observeSource(source) {
237
+ const elements = extractElements(source);
238
+ const normalizedLabels = elements.flatMap((element) => element.labels.map(normalizeLabel));
239
+ const elementKeys = unique([
240
+ ...elements.flatMap((element) => element.targets.map(normalizeLabel)),
241
+ ...normalizedLabels
242
+ ]).sort();
243
+ const normalizedFingerprint = signatureFor(elementKeys);
244
+ const authRequired = AUTH_WORDS.some((word) => normalizedLabels.some((label) => label.includes(word)));
245
+ return {
246
+ fingerprint: signatureFor(source),
247
+ normalized_fingerprint: normalizedFingerprint,
248
+ elements,
249
+ element_keys: elementKeys,
250
+ auth_required: authRequired
251
+ };
252
+ }
253
+ function similarity(left, right) {
254
+ if (left.length === 0 && right.length === 0) {
255
+ return 1;
256
+ }
257
+ const leftSet = new Set(left);
258
+ const rightSet = new Set(right);
259
+ const intersection = Array.from(leftSet).filter((value) => rightSet.has(value)).length;
260
+ const union = new Set([...left, ...right]).size;
261
+ const jaccard = union === 0 ? 0 : intersection / union;
262
+ const smaller = Math.min(leftSet.size, rightSet.size);
263
+ const containment = smaller === 0 ? 0 : intersection / smaller;
264
+ return containment >= 0.8 ? Math.max(jaccard, containment) : jaccard;
265
+ }
266
+ function elementIdentityKeys(elements) {
267
+ return unique(elements.flatMap((element) => {
268
+ const tag = element.tag.toLowerCase();
269
+ const labels = element.labels.map(normalizeLabel);
270
+ const ids = element.targets.filter((target) => target.startsWith('id=')).map(normalizeLabel);
271
+ return [...labels, ...ids].map((value) => `${tag}:${element.safety}:${value}`);
272
+ })).sort();
273
+ }
274
+ function newVariant(map, observation) {
275
+ const screen = {
276
+ id: `screen_${map.screens.length + 1}`,
277
+ variant_ids: []
278
+ };
279
+ const variant = {
280
+ id: `variant_${map.variants.length + 1}`,
281
+ screen_id: screen.id,
282
+ fingerprint: observation.fingerprint,
283
+ normalized_fingerprint: observation.normalized_fingerprint,
284
+ elements: observation.elements,
285
+ element_keys: observation.element_keys,
286
+ auth_required: observation.auth_required,
287
+ observations: 1,
288
+ confidence: 0.6,
289
+ last_observed_at: utcNowIso()
290
+ };
291
+ screen.variant_ids.push(variant.id);
292
+ map.screens.push(screen);
293
+ map.variants.push(variant);
294
+ return variant;
295
+ }
296
+ function upsertVariant(map, observation) {
297
+ let best = null;
298
+ let bestScore = 0;
299
+ for (const variant of map.variants) {
300
+ const score = variant.normalized_fingerprint === observation.normalized_fingerprint
301
+ ? 1
302
+ : similarity(elementIdentityKeys(variant.elements), elementIdentityKeys(observation.elements));
303
+ if (score > bestScore) {
304
+ best = variant;
305
+ bestScore = score;
306
+ }
307
+ }
308
+ if (!best || bestScore < 0.45) {
309
+ return {
310
+ observation,
311
+ variant: newVariant(map, observation),
312
+ created: true
313
+ };
314
+ }
315
+ best.fingerprint = observation.fingerprint;
316
+ best.normalized_fingerprint = observation.normalized_fingerprint;
317
+ best.elements = observation.elements;
318
+ best.element_keys = observation.element_keys;
319
+ best.auth_required = observation.auth_required;
320
+ best.observations += 1;
321
+ best.confidence = Math.min(1, best.confidence + 0.05);
322
+ best.last_observed_at = utcNowIso();
323
+ return {
324
+ observation,
325
+ variant: best,
326
+ created: false
327
+ };
328
+ }
329
+ async function observeCurrentScreen(context) {
330
+ const sourceDir = ensureDir(path.join(os.tmpdir(), 'visor-app-map-source'));
331
+ const sourcePath = path.join(sourceDir, `${process.pid}-${Date.now()}-${randomUUID()}.xml`);
332
+ const details = await context.adapter.source({ label: 'app-map-source', path: sourcePath });
333
+ const args = details.args;
334
+ const sourceArgs = args && typeof args === 'object' && !Array.isArray(args)
335
+ ? args
336
+ : {};
337
+ const detailPath = typeof sourceArgs.path === 'string'
338
+ ? sourceArgs.path
339
+ : sourcePath;
340
+ const source = fs.existsSync(detailPath) ? fs.readFileSync(detailPath, 'utf8') : '';
341
+ try {
342
+ fs.rmSync(detailPath, { force: true });
343
+ }
344
+ catch {
345
+ // Source observations are transient; stale temp files should not fail a run.
346
+ }
347
+ const observed = upsertVariant(context.map, observeSource(source));
348
+ context.changed = true;
349
+ return observed;
350
+ }
351
+ function targetDescriptor(command, args) {
352
+ if (isValueBearingAction(command, args)) {
353
+ return { kind: 'unknown', confidence: 0 };
354
+ }
355
+ if (command === 'tap') {
356
+ try {
357
+ if (resolveTapMode(args) === 'coordinates') {
358
+ return { kind: 'coordinate', confidence: 0 };
359
+ }
360
+ }
361
+ catch {
362
+ return { kind: 'unknown', confidence: 0 };
363
+ }
364
+ }
365
+ const target = typeof args.target === 'string' ? args.target : undefined;
366
+ if (!target) {
367
+ return { kind: 'unknown', confidence: 0 };
368
+ }
369
+ if (target.startsWith('text=')) {
370
+ return { kind: 'text', target, value: target.slice(5), confidence: 0.6 };
371
+ }
372
+ if (target.startsWith('xpath=')) {
373
+ return { kind: 'unknown', target, value: target.slice(6), confidence: 0.2 };
374
+ }
375
+ return { kind: 'stable', target, value: target.replace(/^(id=|accessibility=)/, ''), confidence: 0.9 };
376
+ }
377
+ function isValueBearingAction(command, args) {
378
+ return command === 'act' && args.name === 'type' && args.value !== undefined;
379
+ }
380
+ function replayableEdge(edge) {
381
+ return !isValueBearingAction(edge.command, edge.args);
382
+ }
383
+ function variantContainsTarget(variant, descriptor) {
384
+ if (!descriptor.target && !descriptor.value) {
385
+ return false;
386
+ }
387
+ const target = descriptor.target ?? '';
388
+ const value = descriptor.value ?? target;
389
+ return variant.elements.some((element) => {
390
+ if (element.targets.includes(target) || element.targets.includes(value)) {
391
+ return true;
392
+ }
393
+ if (descriptor.kind === 'text') {
394
+ return element.labels.some((label) => label === value);
395
+ }
396
+ return element.labels.some((label) => label === value);
397
+ });
398
+ }
399
+ function candidateVariants(map, descriptor) {
400
+ return map.variants.filter((variant) => variantContainsTarget(variant, descriptor));
401
+ }
402
+ function summarizeEdge(edge) {
403
+ return {
404
+ command: edge.command,
405
+ target: edge.target,
406
+ confidence: Math.round(edge.confidence * 100) / 100
407
+ };
408
+ }
409
+ function summarizeControl(command, target, confidence) {
410
+ return {
411
+ command,
412
+ target,
413
+ confidence: Math.round(confidence * 100) / 100
414
+ };
415
+ }
416
+ function planRoute(map, fromVariantId, descriptor) {
417
+ if (descriptor.kind === 'coordinate' || descriptor.kind === 'unknown') {
418
+ return { edges: [], ambiguous: false, authRequired: false };
419
+ }
420
+ const destinations = candidateVariants(map, descriptor);
421
+ if (destinations.length === 0) {
422
+ return { edges: [], ambiguous: false, authRequired: false };
423
+ }
424
+ if (descriptor.kind === 'text' && destinations.length > 1) {
425
+ return { edges: [], ambiguous: true, authRequired: false };
426
+ }
427
+ const destinationIds = new Set(destinations.map((variant) => variant.id));
428
+ const visited = new Set([fromVariantId]);
429
+ const queue = [{ variantId: fromVariantId, edges: [] }];
430
+ while (queue.length > 0) {
431
+ const current = queue.shift();
432
+ if (!current) {
433
+ break;
434
+ }
435
+ if (destinationIds.has(current.variantId)) {
436
+ const destination = map.variants.find((variant) => variant.id === current.variantId);
437
+ return { edges: current.edges, ambiguous: false, authRequired: Boolean(destination?.auth_required) };
438
+ }
439
+ const outgoing = map.edges
440
+ .filter((edge) => edge.from_variant_id === current.variantId && !edge.stale && edge.confidence >= 0.25 && replayableEdge(edge))
441
+ .sort((left, right) => right.confidence - left.confidence);
442
+ for (const edge of outgoing) {
443
+ if (visited.has(edge.to_variant_id)) {
444
+ continue;
445
+ }
446
+ visited.add(edge.to_variant_id);
447
+ queue.push({ variantId: edge.to_variant_id, edges: [...current.edges, edge] });
448
+ }
449
+ }
450
+ return { edges: [], ambiguous: false, authRequired: destinations.some((variant) => variant.auth_required) };
451
+ }
452
+ function contractTargets(variant) {
453
+ return variant.elements
454
+ .filter((element) => element.stable)
455
+ .flatMap((element) => element.targets)
456
+ .slice(0, 8);
457
+ }
458
+ function matchingEdge(map, fromVariantId, command, target) {
459
+ return map.edges.find((edge) => edge.from_variant_id === fromVariantId &&
460
+ edge.command === command &&
461
+ String(edge.target ?? '') === String(target ?? ''));
462
+ }
463
+ function recordEdgeSuccess(context, from, to, command, args, candidate) {
464
+ if (from.id === to.id) {
465
+ return;
466
+ }
467
+ const descriptor = targetDescriptor(command, args);
468
+ if (descriptor.kind === 'coordinate' || descriptor.kind === 'unknown') {
469
+ return;
470
+ }
471
+ const edge = matchingEdge(context.map, from.id, command, descriptor.target);
472
+ if (edge) {
473
+ edge.to_variant_id = to.id;
474
+ edge.successes += 1;
475
+ edge.failures = 0;
476
+ edge.stale = false;
477
+ edge.candidate = edge.candidate && edge.successes < 2;
478
+ edge.confidence = Math.min(1, edge.confidence + 0.15);
479
+ edge.destination_contract = {
480
+ variant_id: to.id,
481
+ required_targets: contractTargets(to),
482
+ normalized_fingerprint: to.normalized_fingerprint
483
+ };
484
+ edge.last_observed_at = utcNowIso();
485
+ context.changed = true;
486
+ return;
487
+ }
488
+ context.map.edges.push({
489
+ id: `edge_${context.map.edges.length + 1}`,
490
+ from_variant_id: from.id,
491
+ to_variant_id: to.id,
492
+ command,
493
+ args: structuredClone(args),
494
+ target: descriptor.target,
495
+ confidence: candidate ? 0.45 : descriptor.confidence,
496
+ successes: 1,
497
+ failures: 0,
498
+ stale: false,
499
+ candidate,
500
+ destination_contract: {
501
+ variant_id: to.id,
502
+ required_targets: contractTargets(to),
503
+ normalized_fingerprint: to.normalized_fingerprint
504
+ },
505
+ last_observed_at: utcNowIso()
506
+ });
507
+ context.changed = true;
508
+ }
509
+ function demoteEdge(context, edge) {
510
+ edge.failures += 1;
511
+ edge.confidence = Math.max(0, edge.confidence - 0.25);
512
+ if (edge.failures >= 2 || edge.confidence < 0.25) {
513
+ edge.stale = true;
514
+ }
515
+ edge.last_observed_at = utcNowIso();
516
+ context.changed = true;
517
+ }
518
+ function satisfiesContract(edge, observed) {
519
+ if (observed.variant.id === edge.destination_contract.variant_id) {
520
+ return true;
521
+ }
522
+ const observedTargets = new Set(observed.variant.elements.flatMap((element) => element.targets));
523
+ const required = edge.destination_contract.required_targets;
524
+ if (required.length === 0) {
525
+ return observed.variant.normalized_fingerprint === edge.destination_contract.normalized_fingerprint;
526
+ }
527
+ const matched = required.filter((target) => observedTargets.has(target)).length;
528
+ return matched / required.length >= 0.6;
529
+ }
530
+ async function driveRoute(context, current, route) {
531
+ for (const edge of route) {
532
+ const args = structuredClone(edge.args);
533
+ let observed;
534
+ try {
535
+ await context.adapter[edge.command](args);
536
+ observed = await observeCurrentScreen(context);
537
+ }
538
+ catch {
539
+ demoteEdge(context, edge);
540
+ try {
541
+ current = await observeCurrentScreen(context);
542
+ }
543
+ catch {
544
+ // Keep the last known screen if the app cannot provide source after a failed cached action.
545
+ }
546
+ return { current, failedEdge: edge };
547
+ }
548
+ if (!satisfiesContract(edge, observed)) {
549
+ demoteEdge(context, edge);
550
+ return { current: observed, failedEdge: edge };
551
+ }
552
+ recordEdgeSuccess(context, current.variant, observed.variant, edge.command, args, false);
553
+ current = observed;
554
+ }
555
+ return { current };
556
+ }
557
+ function safeTapTargets(variant) {
558
+ return unique(variant.elements
559
+ .filter((element) => element.safety === 'safe' && element.stable)
560
+ .map((element) => {
561
+ const plainLabel = element.targets.find((target) => !target.includes('='));
562
+ return plainLabel ?? element.targets[0] ?? '';
563
+ }));
564
+ }
565
+ async function repairToTarget(context, current, descriptor, depth, deadline, blockedTargets) {
566
+ if (Date.now() > deadline || depth <= 0) {
567
+ return null;
568
+ }
569
+ const attemptedTargets = new Map();
570
+ const attemptFrom = async (start, remainingDepth, route) => {
571
+ let cursor = start;
572
+ let depthLeft = remainingDepth;
573
+ let routeSoFar = route;
574
+ while (Date.now() <= deadline) {
575
+ if (variantContainsTarget(cursor.variant, descriptor)) {
576
+ return { found: true, current: cursor, route: routeSoFar, remainingDepth: depthLeft };
577
+ }
578
+ if (depthLeft <= 0) {
579
+ return { found: false, current: cursor, route: routeSoFar, remainingDepth: depthLeft };
580
+ }
581
+ const attemptsForVariant = attemptedTargets.get(cursor.variant.id) ?? new Set();
582
+ attemptedTargets.set(cursor.variant.id, attemptsForVariant);
583
+ const target = safeTapTargets(cursor.variant).find((candidate) => !blockedTargets.has(candidate) && !attemptsForVariant.has(candidate));
584
+ if (!target) {
585
+ return { found: false, current: cursor, route: routeSoFar, remainingDepth: depthLeft };
586
+ }
587
+ attemptsForVariant.add(target);
588
+ const args = { target };
589
+ let observed;
590
+ try {
591
+ await context.adapter.tap(args);
592
+ observed = await observeCurrentScreen(context);
593
+ }
594
+ catch {
595
+ try {
596
+ cursor = await observeCurrentScreen(context);
597
+ }
598
+ catch {
599
+ // If both the exploratory tap and source read fail, keep the last observed screen.
600
+ }
601
+ continue;
602
+ }
603
+ recordEdgeSuccess(context, cursor.variant, observed.variant, 'tap', args, true);
604
+ const nextRoute = [...routeSoFar, summarizeControl('tap', target, 0.45)];
605
+ const nested = await attemptFrom(observed, depthLeft - 1, nextRoute);
606
+ if (nested.found) {
607
+ return nested;
608
+ }
609
+ cursor = nested.current;
610
+ depthLeft = nested.remainingDepth;
611
+ routeSoFar = nested.route;
612
+ }
613
+ return { found: false, current: cursor, route: routeSoFar, remainingDepth: depthLeft };
614
+ };
615
+ const attempt = await attemptFrom(current, depth, []);
616
+ return attempt.found ? { current: attempt.current, route: attempt.route } : null;
617
+ }
618
+ export async function runMappedCommand(context, command, args) {
619
+ const descriptor = targetDescriptor(command, args);
620
+ const metadata = {
621
+ enabled: true,
622
+ routed: false,
623
+ route: [],
624
+ repaired: false,
625
+ repairs: 0,
626
+ path: context.filePath
627
+ };
628
+ if (descriptor.kind === 'unknown' || descriptor.kind === 'coordinate') {
629
+ const details = await context.adapter[command](args);
630
+ return { ...details, map: metadata };
631
+ }
632
+ let before = await observeCurrentScreen(context);
633
+ if (!variantContainsTarget(before.variant, descriptor)) {
634
+ if (before.variant.auth_required) {
635
+ throw new Error(`Target '${descriptor.target ?? descriptor.value}' requires authentication from the current screen`);
636
+ }
637
+ const route = planRoute(context.map, before.variant.id, descriptor);
638
+ if (route.ambiguous) {
639
+ throw new Error(`Target '${descriptor.target ?? descriptor.value}' is ambiguous in the app map`);
640
+ }
641
+ if (route.authRequired) {
642
+ throw new Error(`Target '${descriptor.target ?? descriptor.value}' is behind an auth-required screen`);
643
+ }
644
+ if (route.edges.length > 0) {
645
+ context.summary.used = true;
646
+ metadata.routed = true;
647
+ metadata.route = route.edges.map(summarizeEdge);
648
+ const driven = await driveRoute(context, before, route.edges);
649
+ before = driven.current;
650
+ if (driven.failedEdge) {
651
+ const repair = await repairToTarget(context, before, descriptor, context.options.repairDepth, Date.now() + context.options.repairTimeoutMs, new Set(driven.failedEdge.target ? [driven.failedEdge.target] : []));
652
+ if (!repair) {
653
+ throw new Error(`Cached app-map route for '${driven.failedEdge.target ?? driven.failedEdge.command}' could not be repaired within the bounded repair budget`);
654
+ }
655
+ context.summary.repaired = true;
656
+ context.summary.repairs += 1;
657
+ metadata.repaired = true;
658
+ metadata.repairs = context.summary.repairs;
659
+ metadata.route = [...metadata.route, ...repair.route];
660
+ before = repair.current;
661
+ }
662
+ }
663
+ else {
664
+ const repair = await repairToTarget(context, before, descriptor, context.options.repairDepth, Date.now() + context.options.repairTimeoutMs, new Set());
665
+ if (repair) {
666
+ context.summary.used = true;
667
+ context.summary.repaired = true;
668
+ context.summary.repairs += 1;
669
+ metadata.routed = repair.route.length > 0;
670
+ metadata.repaired = true;
671
+ metadata.repairs = context.summary.repairs;
672
+ metadata.route = repair.route;
673
+ before = repair.current;
674
+ }
675
+ }
676
+ }
677
+ if (!variantContainsTarget(before.variant, descriptor)) {
678
+ throw new Error(`Target '${descriptor.target ?? descriptor.value}' is not reachable from the current app-map state`);
679
+ }
680
+ const details = await context.adapter[command](args);
681
+ const after = await observeCurrentScreen(context);
682
+ recordEdgeSuccess(context, before.variant, after.variant, command, args, false);
683
+ return { ...details, map: metadata };
684
+ }
685
+ export async function discoverAppMap(adapter, options) {
686
+ const context = createAppMapContext(adapter, options);
687
+ if (!context) {
688
+ return {
689
+ action: 'discover',
690
+ map: createMapSummary(adapter, options),
691
+ screen: null
692
+ };
693
+ }
694
+ try {
695
+ const observed = await observeCurrentScreen(context);
696
+ const summary = persistAppMapContext(context);
697
+ return {
698
+ action: 'discover',
699
+ map: summary,
700
+ screen: {
701
+ variant_id: observed.variant.id,
702
+ screen_id: observed.variant.screen_id,
703
+ element_count: observed.variant.elements.length,
704
+ auth_required: observed.variant.auth_required,
705
+ created: observed.created
706
+ }
707
+ };
708
+ }
709
+ finally {
710
+ persistAppMapContext(context);
711
+ }
712
+ }
713
+ export function mapDebugSnapshot(rootDir, platform, appId) {
714
+ const identity = mapIdentity(platform, appId);
715
+ const filePath = mapFilePath(rootDir, identity);
716
+ if (!fs.existsSync(filePath)) {
717
+ return null;
718
+ }
719
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
720
+ return JSON.parse(canonicalJson(parsed));
721
+ }
package/dist/cli.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import fs from 'node:fs';
2
2
  import { DEFAULT_SERVER_URL } from './adapters.js';
3
- import { DaemonRequestTimeoutError, DaemonUnavailableError, runDaemonAction, runDaemonScenario, startVisorDaemon, statusVisorDaemon, stopVisorDaemon } from './daemon.js';
3
+ import { DaemonOperationError, DaemonRequestTimeoutError, DaemonUnavailableError, runDaemonAction, runDaemonDiscover, runDaemonScenario, startVisorDaemon, statusVisorDaemon, stopVisorDaemon } from './daemon.js';
4
4
  import { DeviceSelectionError, resolveRunningDevice } from './devices.js';
5
5
  import { makeError } from './errors.js';
6
+ import { LocalRuntimeAdapter } from './localRuntime.js';
6
7
  import { writeReports } from './report.js';
7
- import { determinismCheck } from './runner.js';
8
+ import { determinismCheck, runScenario } from './runner.js';
8
9
  import { errorMessage, makeId, utcNowIso } from './utils.js';
9
10
  import { parseAndValidate } from './validator.js';
10
11
  function packageVersion() {
@@ -29,6 +30,7 @@ const ALL_COMMANDS = new Set([
29
30
  ...ACTION_COMMANDS,
30
31
  'validate',
31
32
  'run',
33
+ 'discover',
32
34
  'benchmark',
33
35
  'report',
34
36
  'start',
@@ -44,6 +46,7 @@ const GLOBAL_SPEC = {
44
46
  'server-url': 'string',
45
47
  'app-id': 'string',
46
48
  attach: 'boolean',
49
+ 'no-map': 'boolean',
47
50
  verbose: 'boolean'
48
51
  };
49
52
  const ACTION_SPEC = {
@@ -68,9 +71,20 @@ const COMMAND_SPECS = {
68
71
  timeout: 'number',
69
72
  output: 'string',
70
73
  format: 'string',
74
+ runtime: 'string',
71
75
  'server-url': 'string',
72
76
  'app-id': 'string',
73
- attach: 'boolean'
77
+ attach: 'boolean',
78
+ 'no-map': 'boolean'
79
+ },
80
+ discover: {
81
+ device: 'string',
82
+ timeout: 'number',
83
+ format: 'string',
84
+ 'server-url': 'string',
85
+ 'app-id': 'string',
86
+ attach: 'boolean',
87
+ 'no-map': 'boolean'
74
88
  },
75
89
  benchmark: {
76
90
  runs: 'number',
@@ -81,7 +95,8 @@ const COMMAND_SPECS = {
81
95
  format: 'string',
82
96
  'server-url': 'string',
83
97
  'app-id': 'string',
84
- attach: 'boolean'
98
+ attach: 'boolean',
99
+ 'no-map': 'boolean'
85
100
  },
86
101
  report: { format: 'string' },
87
102
  start: {
@@ -117,8 +132,9 @@ function helpText() {
117
132
  '',
118
133
  'Commands:',
119
134
  ' validate <scenario>',
120
- ' run <scenario> [--output <dir>]',
135
+ ' run <scenario> [--output <dir>] [--runtime appium|local]',
121
136
  ' benchmark <scenario> [--runs <n>] [--threshold <percent>]',
137
+ ' discover [--app-id <id>]',
122
138
  ' report [path]',
123
139
  ' start [--server-url <url>] [--appium-cmd <cmd>]',
124
140
  ' status [--server-url <url>]',
@@ -127,8 +143,11 @@ function helpText() {
127
143
  '',
128
144
  'Examples:',
129
145
  ' visor validate scenarios/checkout-smoke.json',
146
+ ' visor run path/to/scenario.json --runtime local --output artifacts-local',
130
147
  ' visor start --server-url http://127.0.0.1:4723',
131
148
  ' visor run scenarios/checkout-smoke.json --output artifacts-test',
149
+ ' visor run scenarios/checkout-smoke.json --no-map',
150
+ ' visor discover --app-id com.example.app',
132
151
  ' visor scroll --device emulator-5554 --direction down',
133
152
  ' visor status'
134
153
  ].join('\n');
@@ -216,7 +235,10 @@ async function resolvedRuntime(options) {
216
235
  output_dir: String(options.output ?? 'artifacts'),
217
236
  server_url: String(options['server-url'] ?? DEFAULT_SERVER_URL),
218
237
  app_id: typeof options['app-id'] === 'string' ? options['app-id'] : undefined,
219
- attach_to_running: Boolean(options.attach)
238
+ attach_to_running: Boolean(options.attach),
239
+ map: {
240
+ enabled: !Boolean(options['no-map'])
241
+ }
220
242
  };
221
243
  }
222
244
  function actionArgs(command, options) {
@@ -228,8 +250,10 @@ function actionArgs(command, options) {
228
250
  'verbose',
229
251
  'server-url',
230
252
  'seed',
253
+ 'runtime',
231
254
  'app-id',
232
- 'attach'
255
+ 'attach',
256
+ 'no-map'
233
257
  ]);
234
258
  const args = Object.entries(options).reduce((acc, [key, value]) => {
235
259
  if (!commonIgnored.has(key) && value !== undefined) {
@@ -242,6 +266,10 @@ function actionArgs(command, options) {
242
266
  }
243
267
  return args;
244
268
  }
269
+ function unsupportedRuntimeResult(commandId, startedAt, runtime) {
270
+ const response = envelopeFail(commandId, startedAt, 'INPUT_ERROR', 'Unsupported runtime', `Unsupported runtime '${String(runtime)}'`, 'Use --runtime appium or --runtime local');
271
+ return { code: 1, response };
272
+ }
245
273
  function isTargetInitializationError(error) {
246
274
  const message = errorMessage(error);
247
275
  return (message.includes('Failed to create WebdriverIO Appium session') ||
@@ -270,6 +298,7 @@ function cmdHelp() {
270
298
  commands: Array.from(ALL_COMMANDS),
271
299
  examples: [
272
300
  'visor validate scenarios/checkout-smoke.json',
301
+ 'visor run path/to/scenario.json --runtime local --output artifacts-local',
273
302
  'visor start --server-url http://127.0.0.1:4723',
274
303
  'visor run scenarios/checkout-smoke.json --output artifacts-test',
275
304
  'visor scroll --device emulator-5554 --direction down',
@@ -313,6 +342,19 @@ export async function cmdValidate(parsed) {
313
342
  return { code: 1, response };
314
343
  }
315
344
  }
345
+ async function cmdRunLocal(commandId, startedAt, scenario, warnings, runtime) {
346
+ const result = await runScenario(scenario, new LocalRuntimeAdapter(), runtime.device, runtime.timeout, runtime.output_dir);
347
+ const outputs = writeReports(result, runtime.output_dir);
348
+ const response = result.status === 'ok'
349
+ ? envelopeOk(commandId, startedAt, Object.values(outputs), 'report')
350
+ : envelopeFail(commandId, startedAt, result.error?.code ?? 'ACTION_ERROR', result.error?.message ?? 'Local runtime scenario failed', result.error?.likely_cause ?? 'The deterministic local runtime did not satisfy the scenario', result.error?.next_step ?? 'Inspect local runtime scenario artifacts and retry');
351
+ response.artifacts = Object.values(outputs);
352
+ response.data = {
353
+ run: result,
354
+ warnings
355
+ };
356
+ return { code: result.status === 'ok' ? 0 : 2, response };
357
+ }
316
358
  export async function cmdRun(parsed) {
317
359
  const commandId = makeId('cmd');
318
360
  const startedAt = utcNowIso();
@@ -323,6 +365,24 @@ export async function cmdRun(parsed) {
323
365
  response.data = { issues };
324
366
  return { code: 1, response };
325
367
  }
368
+ if (parsed.options.runtime !== undefined &&
369
+ parsed.options.runtime !== 'appium' &&
370
+ parsed.options.runtime !== 'local') {
371
+ return unsupportedRuntimeResult(commandId, startedAt, parsed.options.runtime);
372
+ }
373
+ if (parsed.options.runtime === 'local') {
374
+ const runtime = {
375
+ device: 'local',
376
+ timeout: typeof parsed.options.timeout === 'number'
377
+ ? parsed.options.timeout
378
+ : typeof scenario.config.timeoutMs === 'number'
379
+ ? scenario.config.timeoutMs
380
+ : undefined,
381
+ output_dir: String(parsed.options.output ??
382
+ (typeof scenario.config.artifactsDir === 'string' ? scenario.config.artifactsDir : 'artifacts'))
383
+ };
384
+ return cmdRunLocal(commandId, startedAt, scenario, warningIssues(issues), runtime);
385
+ }
326
386
  let runtime;
327
387
  try {
328
388
  runtime = await resolvedRuntime({
@@ -351,7 +411,10 @@ export async function cmdRun(parsed) {
351
411
  device: runtime.device,
352
412
  app_id: runtime.app_id,
353
413
  attach_to_running: runtime.attach_to_running
354
- }, scenario, runtime.device, runtime.timeout, runtime.output_dir);
414
+ }, scenario, runtime.device, runtime.timeout, runtime.output_dir, {
415
+ ...runtime.map,
416
+ appId: runtime.app_id
417
+ });
355
418
  const outputs = writeReports(result, runtime.output_dir);
356
419
  if (result.status === 'fail' && result.error) {
357
420
  const response = envelopeFail(commandId, startedAt, result.error.code, result.error.message, result.error.likely_cause, result.error.next_step);
@@ -384,6 +447,51 @@ export async function cmdRun(parsed) {
384
447
  return { code: 1, response };
385
448
  }
386
449
  }
450
+ export async function cmdDiscover(parsed) {
451
+ const commandId = makeId('cmd');
452
+ const startedAt = utcNowIso();
453
+ let runtime;
454
+ try {
455
+ runtime = await resolvedRuntime(parsed.options);
456
+ }
457
+ catch (error) {
458
+ if (!(error instanceof DeviceSelectionError)) {
459
+ throw error;
460
+ }
461
+ const response = envelopeFail(commandId, startedAt, 'TARGET_ERROR', 'Device selection failed', error.message, 'Start one target device or rerun with --device <device-id>');
462
+ response.data = { devices: error.devices };
463
+ return { code: 1, response };
464
+ }
465
+ try {
466
+ const result = await runDaemonDiscover({
467
+ platform: runtime.platform,
468
+ server_url: runtime.server_url,
469
+ device: runtime.device,
470
+ app_id: runtime.app_id,
471
+ attach_to_running: runtime.attach_to_running
472
+ }, {
473
+ ...runtime.map,
474
+ appId: runtime.app_id
475
+ });
476
+ const response = envelopeOk(commandId, startedAt, [], 'run');
477
+ response.data = result;
478
+ return { code: 0, response };
479
+ }
480
+ catch (error) {
481
+ const isDaemonUnavailable = error instanceof DaemonUnavailableError;
482
+ const isDaemonTimeout = error instanceof DaemonRequestTimeoutError;
483
+ const response = envelopeFail(commandId, startedAt, isDaemonUnavailable || isDaemonTimeout ? 'TARGET_ERROR' : 'ACTION_ERROR', isDaemonUnavailable
484
+ ? 'discover requires visor start'
485
+ : isDaemonTimeout
486
+ ? 'discover timed out waiting for Visor daemon'
487
+ : 'discover failed', errorMessage(error), isDaemonUnavailable
488
+ ? 'Run `visor start`, verify the target emulator/simulator is booted, and retry.'
489
+ : isDaemonTimeout
490
+ ? 'Inspect .visor/daemon/daemon.log and .visor/appium/*.log, then retry or run `visor stop --force` before restarting.'
491
+ : 'Check target app state and retry');
492
+ return { code: 1, response };
493
+ }
494
+ }
387
495
  export async function cmdBenchmark(parsed) {
388
496
  const commandId = makeId('cmd');
389
497
  const startedAt = utcNowIso();
@@ -428,7 +536,10 @@ export async function cmdBenchmark(parsed) {
428
536
  device: runtime.device,
429
537
  app_id: runtime.app_id,
430
538
  attach_to_running: runtime.attach_to_running
431
- }, scenario, runtime.device, runtime.timeout, runtime.output_dir);
539
+ }, scenario, runtime.device, runtime.timeout, runtime.output_dir, {
540
+ ...runtime.map,
541
+ appId: runtime.app_id
542
+ });
432
543
  writeReports(result, runtime.output_dir);
433
544
  signatures.push(result.determinism_signature);
434
545
  runIds.push(result.run_id);
@@ -499,7 +610,10 @@ export async function cmdAction(command, parsed) {
499
610
  device: options.device,
500
611
  app_id: options.app_id,
501
612
  attach_to_running: options.attach_to_running
502
- }, command, actionArgs(command, parsed.options));
613
+ }, command, actionArgs(command, parsed.options), {
614
+ ...options.map,
615
+ appId: options.app_id
616
+ });
503
617
  const actionPayload = payload.args;
504
618
  if (actionPayload && typeof actionPayload === 'object' && !Array.isArray(actionPayload)) {
505
619
  const maybePath = actionPayload.path;
@@ -509,6 +623,9 @@ export async function cmdAction(command, parsed) {
509
623
  }
510
624
  }
511
625
  catch (error) {
626
+ if (error instanceof DaemonOperationError && error.data) {
627
+ payload = error.data;
628
+ }
512
629
  actionError = error;
513
630
  }
514
631
  if (actionError) {
@@ -589,6 +706,9 @@ export async function executeCommand(argv) {
589
706
  if (parsed.command === 'run') {
590
707
  return cmdRun(parsed);
591
708
  }
709
+ if (parsed.command === 'discover') {
710
+ return cmdDiscover(parsed);
711
+ }
592
712
  if (parsed.command === 'benchmark') {
593
713
  return cmdBenchmark(parsed);
594
714
  }
package/dist/daemon.js CHANGED
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { DEFAULT_SERVER_URL, getAdapter } from './adapters.js';
7
+ import { createAppMapContext, createMapSummary, discoverAppMap, persistAppMapContext, runMappedCommand } from './appMap.js';
7
8
  import { DEFAULT_STARTUP_TIMEOUT_SECONDS, startManagedAppium, statusManagedAppium, stopManagedAppium } from './appiumLifecycle.js';
8
9
  import { runScenario } from './runner.js';
9
10
  import { ensureDir, errorMessage, resolveExecutable, sleep } from './utils.js';
@@ -19,6 +20,22 @@ export class DaemonRequestTimeoutError extends Error {
19
20
  this.name = 'DaemonRequestTimeoutError';
20
21
  }
21
22
  }
23
+ export class DaemonOperationError extends Error {
24
+ data;
25
+ constructor(message, data) {
26
+ super(message);
27
+ this.name = 'DaemonOperationError';
28
+ this.data = data;
29
+ }
30
+ }
31
+ class DaemonResponseError extends Error {
32
+ data;
33
+ constructor(message, data) {
34
+ super(message);
35
+ this.name = 'DaemonResponseError';
36
+ this.data = data;
37
+ }
38
+ }
22
39
  function daemonDir() {
23
40
  return ensureDir(path.join(process.cwd(), '.visor', 'daemon'));
24
41
  }
@@ -139,7 +156,7 @@ async function requestDaemon(request, timeoutMs = 1000) {
139
156
  async function daemonRequestData(request, timeoutMs = 1000) {
140
157
  const response = await requestDaemon(request, timeoutMs);
141
158
  if (!response.ok) {
142
- throw new Error(response.error);
159
+ throw new DaemonOperationError(response.error, response.data);
143
160
  }
144
161
  return response.data;
145
162
  }
@@ -343,11 +360,14 @@ export async function stopVisorDaemon(serverUrl = DEFAULT_SERVER_URL, force = fa
343
360
  appium: await statusManagedAppium(serverUrl)
344
361
  };
345
362
  }
346
- export async function runDaemonAction(runtime, command, args) {
347
- return daemonRequestData({ type: 'action', runtime, command, args }, 300000);
363
+ export async function runDaemonAction(runtime, command, args, mapOptions) {
364
+ return daemonRequestData({ type: 'action', runtime, command, args, mapOptions }, 300000);
348
365
  }
349
- export async function runDaemonScenario(runtime, scenario, device, timeout, outputDir) {
350
- return daemonRequestData({ type: 'scenario', runtime, scenario, device, timeout, outputDir }, 600000);
366
+ export async function runDaemonScenario(runtime, scenario, device, timeout, outputDir, mapOptions) {
367
+ return daemonRequestData({ type: 'scenario', runtime, scenario, device, timeout, outputDir, mapOptions }, 600000);
368
+ }
369
+ export async function runDaemonDiscover(runtime, mapOptions) {
370
+ return daemonRequestData({ type: 'discover', runtime, mapOptions }, 300000);
351
371
  }
352
372
  export async function runDaemonFromEnv() {
353
373
  const options = {
@@ -398,10 +418,10 @@ export async function runDaemonFromEnv() {
398
418
  // The backing WebDriver session is already gone; removing it from the cache is enough.
399
419
  }
400
420
  }
401
- async function runActionWithSessionRetry(runtime, command, args) {
421
+ async function runActionWithSessionRetry(runtime, command, args, mapOptions) {
402
422
  const adapter = await sessionFor(runtime);
403
423
  try {
404
- return await adapter[command](args);
424
+ return await runActionWithMap(adapter, command, args, mapOptions);
405
425
  }
406
426
  catch (error) {
407
427
  if (!isRecoverableSessionCacheError(error)) {
@@ -409,7 +429,46 @@ export async function runDaemonFromEnv() {
409
429
  }
410
430
  await discardSession(runtime);
411
431
  const freshAdapter = await sessionFor(runtime);
412
- return freshAdapter[command](args);
432
+ return runActionWithMap(freshAdapter, command, args, mapOptions);
433
+ }
434
+ }
435
+ async function runActionWithMap(adapter, command, args, mapOptions) {
436
+ const options = mapOptions;
437
+ const context = createAppMapContext(adapter, options);
438
+ if (!context) {
439
+ const summary = createMapSummary(adapter, options);
440
+ try {
441
+ const details = await adapter[command](args);
442
+ return {
443
+ ...details,
444
+ map: summary
445
+ };
446
+ }
447
+ catch (error) {
448
+ throw new DaemonResponseError(errorMessage(error), {
449
+ map: summary
450
+ });
451
+ }
452
+ }
453
+ try {
454
+ const details = await runMappedCommand(context, command, args);
455
+ const stepMap = details.map && typeof details.map === 'object' ? details.map : {};
456
+ const summary = persistAppMapContext(context);
457
+ return {
458
+ ...details,
459
+ map: {
460
+ ...summary,
461
+ ...stepMap
462
+ }
463
+ };
464
+ }
465
+ catch (error) {
466
+ throw new DaemonResponseError(errorMessage(error), {
467
+ map: persistAppMapContext(context)
468
+ });
469
+ }
470
+ finally {
471
+ persistAppMapContext(context);
413
472
  }
414
473
  }
415
474
  function enqueue(work) {
@@ -463,11 +522,24 @@ export async function runDaemonFromEnv() {
463
522
  });
464
523
  return;
465
524
  }
525
+ else if (request.type === 'discover') {
526
+ const data = await enqueue(async () => {
527
+ activeOperation = `discover:${runtimeKey(request.runtime)}`;
528
+ try {
529
+ const adapter = await sessionFor(request.runtime);
530
+ return await discoverAppMap(adapter, request.mapOptions);
531
+ }
532
+ finally {
533
+ activeOperation = null;
534
+ }
535
+ });
536
+ response = { ok: true, data };
537
+ }
466
538
  else if (request.type === 'action') {
467
539
  const data = await enqueue(async () => {
468
540
  activeOperation = `${request.command}:${runtimeKey(request.runtime)}`;
469
541
  try {
470
- return await runActionWithSessionRetry(request.runtime, request.command, request.args);
542
+ return await runActionWithSessionRetry(request.runtime, request.command, request.args, request.mapOptions);
471
543
  }
472
544
  finally {
473
545
  activeOperation = null;
@@ -480,7 +552,7 @@ export async function runDaemonFromEnv() {
480
552
  activeOperation = `scenario:${runtimeKey(request.runtime)}`;
481
553
  try {
482
554
  const adapter = await sessionFor(request.runtime);
483
- return await runScenario(request.scenario, adapter, request.device, request.timeout, request.outputDir, false);
555
+ return await runScenario(request.scenario, adapter, request.device, request.timeout, request.outputDir, false, request.mapOptions);
484
556
  }
485
557
  finally {
486
558
  activeOperation = null;
@@ -490,7 +562,11 @@ export async function runDaemonFromEnv() {
490
562
  }
491
563
  }
492
564
  catch (error) {
493
- response = { ok: false, error: errorMessage(error) };
565
+ response = {
566
+ ok: false,
567
+ error: errorMessage(error),
568
+ data: error instanceof DaemonResponseError ? error.data : undefined
569
+ };
494
570
  }
495
571
  socket.end(`${JSON.stringify(response)}\n`);
496
572
  })();
@@ -0,0 +1,116 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const TINY_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/axDRf0AAAAASUVORK5CYII=', 'base64');
4
+ function resolveArtifactPath(args, extension, fallbackLabel) {
5
+ const label = String(args.label ?? fallbackLabel);
6
+ return path.resolve(typeof args.path === 'string' ? args.path : `${label}.${extension}`);
7
+ }
8
+ function targetValue(target) {
9
+ const separatorIndex = target.indexOf('=');
10
+ return separatorIndex === -1 ? target : target.slice(separatorIndex + 1);
11
+ }
12
+ export class LocalRuntimeAdapter {
13
+ counter = 0;
14
+ route = 'app://home';
15
+ capability() {
16
+ return {
17
+ platform: 'android',
18
+ commands: ['navigate', 'tap', 'act', 'scroll', 'screenshot', 'wait', 'source']
19
+ };
20
+ }
21
+ async navigate(args) {
22
+ this.route = String(args.to ?? this.route);
23
+ return {
24
+ action: 'navigate',
25
+ platform: 'android',
26
+ args: { to: this.route }
27
+ };
28
+ }
29
+ async tap(args) {
30
+ const target = String(args.target ?? '');
31
+ if (targetValue(target) === 'Increment') {
32
+ this.counter += 1;
33
+ }
34
+ return {
35
+ action: 'tap',
36
+ platform: 'android',
37
+ args: { target }
38
+ };
39
+ }
40
+ async act(args) {
41
+ return {
42
+ action: 'act',
43
+ platform: 'android',
44
+ args
45
+ };
46
+ }
47
+ async scroll(args) {
48
+ return {
49
+ action: 'scroll',
50
+ platform: 'android',
51
+ args: {
52
+ direction: args.direction ?? 'down',
53
+ percent: args.percent ?? 70
54
+ }
55
+ };
56
+ }
57
+ async screenshot(args) {
58
+ const label = String(args.label ?? 'capture');
59
+ const filePath = resolveArtifactPath(args, 'png', label);
60
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
61
+ fs.writeFileSync(filePath, TINY_PNG);
62
+ return {
63
+ action: 'screenshot',
64
+ platform: 'android',
65
+ args: {
66
+ label,
67
+ file: path.basename(filePath),
68
+ path: filePath,
69
+ width: 1,
70
+ height: 1
71
+ }
72
+ };
73
+ }
74
+ async wait(args) {
75
+ return {
76
+ action: 'wait',
77
+ platform: 'android',
78
+ args: { ms: Number(args.ms ?? 0) }
79
+ };
80
+ }
81
+ async source(args) {
82
+ const label = String(args.label ?? 'source');
83
+ const filePath = resolveArtifactPath(args, 'xml', label);
84
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
85
+ const content = this.sourceXml();
86
+ fs.writeFileSync(filePath, content, 'utf8');
87
+ return {
88
+ action: 'source',
89
+ platform: 'android',
90
+ args: {
91
+ label,
92
+ file: path.basename(filePath),
93
+ path: filePath,
94
+ format: 'xml',
95
+ bytes: Buffer.byteLength(content, 'utf8')
96
+ }
97
+ };
98
+ }
99
+ async exists(target) {
100
+ const value = targetValue(target);
101
+ return value === 'Increment' || value === String(this.counter) || this.sourceXml().includes(value);
102
+ }
103
+ async close() {
104
+ return undefined;
105
+ }
106
+ sourceXml() {
107
+ return [
108
+ '<hierarchy>',
109
+ ` <node text="Route: ${this.route}" />`,
110
+ ` <node text="${this.counter}" label="${this.counter}" name="${this.counter}" value="${this.counter}" />`,
111
+ ' <node text="Increment" content-desc="Increment" label="Increment" name="Increment" />',
112
+ '</hierarchy>',
113
+ ''
114
+ ].join('\n');
115
+ }
116
+ }
package/dist/runner.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { performance } from 'node:perf_hooks';
4
+ import { createAppMapContext, createMapSummary, persistAppMapContext, runMappedCommand } from './appMap.js';
4
5
  import { makeError } from './errors.js';
5
6
  import { makeId, signatureFor, utcNowIso } from './utils.js';
6
7
  async function runStep(adapter, command, args) {
@@ -59,15 +60,32 @@ function signatureSafeDetails(details) {
59
60
  if (args && typeof args === 'object' && !Array.isArray(args)) {
60
61
  delete args.path;
61
62
  }
63
+ const map = safe.map;
64
+ if (map && typeof map === 'object' && !Array.isArray(map)) {
65
+ const mapDetails = map;
66
+ const route = Array.isArray(mapDetails.route)
67
+ ? mapDetails.route
68
+ .filter((step) => Boolean(step) && typeof step === 'object' && !Array.isArray(step))
69
+ .map((step) => ({
70
+ command: step.command,
71
+ target: step.target
72
+ }))
73
+ : [];
74
+ safe.map = {
75
+ route
76
+ };
77
+ }
62
78
  return safe;
63
79
  }
64
- export async function runScenario(scenario, adapter, device = 'local', timeoutMs, artifactBaseDir, closeAdapter = true) {
80
+ export async function runScenario(scenario, adapter, device = 'local', timeoutMs, artifactBaseDir, closeAdapter = true, mapOptions) {
65
81
  const started_at = utcNowIso();
66
82
  const run_id = makeId('run');
67
83
  const platform = adapter.capability().platform;
68
84
  const stepResults = [];
69
85
  const artifacts = [];
70
86
  let topError;
87
+ const mapContext = createAppMapContext(adapter, mapOptions);
88
+ const disabledMapSummary = mapContext ? undefined : createMapSummary(adapter, mapOptions);
71
89
  let screenshotDir;
72
90
  let sourceDir;
73
91
  if (artifactBaseDir) {
@@ -89,7 +107,9 @@ export async function runScenario(scenario, adapter, device = 'local', timeoutMs
89
107
  const label = String(stepArgs.label ?? step.id);
90
108
  stepArgs.path = path.join(sourceDir, `${label}.xml`);
91
109
  }
92
- const details = await runStep(adapter, step.command, stepArgs);
110
+ const details = mapContext
111
+ ? await runMappedCommand(mapContext, step.command, stepArgs)
112
+ : await runStep(adapter, step.command, stepArgs);
93
113
  const durationMs = Math.round(performance.now() - started);
94
114
  const result = {
95
115
  id: step.id,
@@ -140,6 +160,7 @@ export async function runScenario(scenario, adapter, device = 'local', timeoutMs
140
160
  }
141
161
  const allPass = stepsOk && assertionEvaluation.ok;
142
162
  const ended_at = utcNowIso();
163
+ const mapSummary = persistAppMapContext(mapContext) ?? disabledMapSummary;
143
164
  const signatureInput = {
144
165
  platform,
145
166
  steps: stepResults.map((step) => ({
@@ -162,10 +183,12 @@ export async function runScenario(scenario, adapter, device = 'local', timeoutMs
162
183
  artifacts,
163
184
  determinism_signature: signatureFor(signatureInput),
164
185
  seed: typeof scenario.config.seed === 'number' ? scenario.config.seed : undefined,
186
+ map: mapSummary,
165
187
  error: topError
166
188
  };
167
189
  }
168
190
  finally {
191
+ persistAppMapContext(mapContext);
169
192
  if (closeAdapter) {
170
193
  await adapter.close();
171
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "visor-ai",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Visor CLI for LLM-driven mobile app interaction and artifact capture",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,9 @@
30
30
  "build": "rm -rf dist && tsc -p tsconfig.json",
31
31
  "dev": "tsx src/main.ts",
32
32
  "test": "vitest run",
33
+ "test:e2e:local": "node scripts/local-e2e.mjs",
33
34
  "check": "npm run build && npm run test",
35
+ "verify": "npm run build && npm run test && npm run test:e2e:local",
34
36
  "release": "node scripts/release.mjs",
35
37
  "release:patch": "node scripts/release.mjs patch",
36
38
  "release:minor": "node scripts/release.mjs minor",
@@ -51,6 +53,7 @@
51
53
  },
52
54
  "devDependencies": {
53
55
  "@types/node": "^22.13.10",
56
+ "appium-uiautomator2-driver": "^4.2.9",
54
57
  "appium-xcuitest-driver": "^9.10.5",
55
58
  "tsx": "^4.19.3",
56
59
  "typescript": "^5.8.2",