visor-ai 0.2.6 → 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/adapters.js +18 -8
- package/dist/appMap.js +721 -0
- package/dist/cli.js +158 -13
- package/dist/daemon.js +87 -11
- package/dist/localRuntime.js +116 -0
- package/dist/main.js +9 -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
|
+
}
|