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 +721 -0
- package/dist/cli.js +130 -10
- package/dist/daemon.js +87 -11
- package/dist/localRuntime.js +116 -0
- package/dist/runner.js +25 -2
- package/package.json +4 -1
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
|
|
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,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 =
|
|
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.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",
|