visor-ai 0.2.7 → 0.2.9

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/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,124 @@
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
+ const name = String(args.name ?? '');
42
+ if (name === 'reset') {
43
+ this.counter = 0;
44
+ this.route = 'app://home';
45
+ }
46
+ if (name === 'home') {
47
+ this.route = 'app://home';
48
+ }
49
+ return {
50
+ action: 'act',
51
+ platform: 'android',
52
+ args
53
+ };
54
+ }
55
+ async scroll(args) {
56
+ return {
57
+ action: 'scroll',
58
+ platform: 'android',
59
+ args: {
60
+ direction: args.direction ?? 'down',
61
+ percent: args.percent ?? 70
62
+ }
63
+ };
64
+ }
65
+ async screenshot(args) {
66
+ const label = String(args.label ?? 'capture');
67
+ const filePath = resolveArtifactPath(args, 'png', label);
68
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
69
+ fs.writeFileSync(filePath, TINY_PNG);
70
+ return {
71
+ action: 'screenshot',
72
+ platform: 'android',
73
+ args: {
74
+ label,
75
+ file: path.basename(filePath),
76
+ path: filePath,
77
+ width: 1,
78
+ height: 1
79
+ }
80
+ };
81
+ }
82
+ async wait(args) {
83
+ return {
84
+ action: 'wait',
85
+ platform: 'android',
86
+ args: { ms: Number(args.ms ?? 0) }
87
+ };
88
+ }
89
+ async source(args) {
90
+ const label = String(args.label ?? 'source');
91
+ const filePath = resolveArtifactPath(args, 'xml', label);
92
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
93
+ const content = this.sourceXml();
94
+ fs.writeFileSync(filePath, content, 'utf8');
95
+ return {
96
+ action: 'source',
97
+ platform: 'android',
98
+ args: {
99
+ label,
100
+ file: path.basename(filePath),
101
+ path: filePath,
102
+ format: 'xml',
103
+ bytes: Buffer.byteLength(content, 'utf8')
104
+ }
105
+ };
106
+ }
107
+ async exists(target) {
108
+ const value = targetValue(target);
109
+ return value === 'Increment' || value === String(this.counter) || this.sourceXml().includes(value);
110
+ }
111
+ async close() {
112
+ return undefined;
113
+ }
114
+ sourceXml() {
115
+ return [
116
+ '<hierarchy>',
117
+ ` <node text="Route: ${this.route}" />`,
118
+ ` <node text="${this.counter}" label="${this.counter}" name="${this.counter}" value="${this.counter}" />`,
119
+ ' <node text="Increment" content-desc="Increment" label="Increment" name="Increment" />',
120
+ '</hierarchy>',
121
+ ''
122
+ ].join('\n');
123
+ }
124
+ }
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.9",
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",