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/adapters.js +224 -22
- package/dist/appMap.js +2486 -0
- package/dist/cli.js +424 -45
- package/dist/daemon.js +87 -11
- package/dist/localRuntime.js +124 -0
- package/dist/runner.js +25 -2
- package/package.json +4 -1
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
|
|
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
|
|
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
|
|
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 = {
|
|
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 =
|
|
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.
|
|
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",
|