visor-ai 0.2.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Aleksander Kapera
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # Visor
2
+
3
+ Visor is a TypeScript CLI for verified interaction with running mobile apps.
4
+
5
+ Instead of inferring UI behavior from code alone, Visor captures evidence from the live app: interactions, screenshots, UI source, assertions, and run artifacts.
6
+
7
+ ## What Visor does
8
+
9
+ - interacts with iOS and Android apps through Appium
10
+ - captures screenshots and UI source from live app state
11
+ - executes repeatable scenarios with ordered steps
12
+ - evaluates visibility assertions after execution
13
+ - writes artifacts and reports for review and automation
14
+ - measures determinism across repeated runs
15
+
16
+ ## Why teams use it
17
+
18
+ - reduce guesswork in agent-driven UI work
19
+ - verify real app behavior instead of code intent alone
20
+ - keep reproducible evidence for debugging and review
21
+ - add a repeatable verification layer around mobile UI changes
22
+
23
+ ## Scope
24
+
25
+ Visor is focused on mobile app verification.
26
+
27
+ Supported today:
28
+
29
+ - Android
30
+ - iOS
31
+ - real Appium-backed runs
32
+ - mock runs for predictable dry-run behavior
33
+
34
+ ## Install
35
+
36
+ Visor ships as the npm package `visor-ai` and requires Node `20` or later.
37
+
38
+ For a published install:
39
+
40
+ ```bash
41
+ npm install -g visor-ai
42
+ visor --help
43
+ ```
44
+
45
+ For a source checkout:
46
+
47
+ ```bash
48
+ npm install
49
+ npm run build
50
+ node dist/main.js --help
51
+ ```
52
+
53
+ ## Release automation
54
+
55
+ GitHub Actions publishes tagged releases of `visor-ai` to the npm registry.
56
+
57
+ Maintainer requirements:
58
+
59
+ - repository secret `NPM_TOKEN`
60
+ - release tags in the form `v<package.json version>`
61
+
62
+ The release workflow verifies the package with `npm ci`, `npm run build`, `npm test`, and `npm pack --dry-run` before publishing.
63
+
64
+ ## Documentation
65
+
66
+ Comprehensive product documentation lives in [the docs site](https://na-ca6c7a2b.mintlify.app).
@@ -0,0 +1,425 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { remote } from 'webdriverio';
4
+ import { errorMessage, parseServerUrl, sleep } from './utils.js';
5
+ export const DEFAULT_SERVER_URL = 'http://127.0.0.1:4723';
6
+ export const DEFAULT_ANDROID_APP = 'com.example.app';
7
+ export const DEFAULT_IOS_BUNDLE = 'com.example.app';
8
+ export const DEFAULT_ANDROID_DEVICE = 'emulator-5554';
9
+ export const DEFAULT_IOS_DEVICE = 'iPhone 17 Pro';
10
+ const MINI_PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7Z0x8AAAAASUVORK5CYII=';
11
+ const MINI_PNG = Buffer.from(MINI_PNG_BASE64, 'base64');
12
+ export const ACCESSIBILITY_ID = 'accessibility id';
13
+ export const XPATH = 'xpath';
14
+ export const ELEMENT_ID = 'id';
15
+ export const ANDROID_UIAUTOMATOR = '-android uiautomator';
16
+ export const IOS_PREDICATE = '-ios predicate string';
17
+ export const IOS_CLASS_CHAIN = '-ios class chain';
18
+ function env(preferred, legacy, defaultValue) {
19
+ return process.env[preferred] ?? process.env[legacy] ?? defaultValue;
20
+ }
21
+ function envBool(preferred, legacy, defaultValue = false) {
22
+ const raw = env(preferred, legacy);
23
+ if (raw === undefined) {
24
+ return defaultValue;
25
+ }
26
+ return ['1', 'true', 'yes', 'on'].includes(raw.trim().toLowerCase());
27
+ }
28
+ function pngDimensions(filePath) {
29
+ try {
30
+ const header = fs.readFileSync(filePath).subarray(0, 24);
31
+ if (header.length < 24 ||
32
+ !header.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
33
+ return { width: null, height: null };
34
+ }
35
+ return {
36
+ width: header.readUInt32BE(16),
37
+ height: header.readUInt32BE(20)
38
+ };
39
+ }
40
+ catch {
41
+ return { width: null, height: null };
42
+ }
43
+ }
44
+ export function formatDriverCreationError(platform, appId, attachToRunning, error) {
45
+ const message = errorMessage(error);
46
+ if (platform === 'ios' && message.includes('bundle identifier') && message.includes('unknown')) {
47
+ const targetApp = appId ?? DEFAULT_IOS_BUNDLE;
48
+ const attachHint = attachToRunning
49
+ ? ' When using --attach, launch that app on the simulator/device first.'
50
+ : '';
51
+ return `Failed to create WebdriverIO Appium session: ${message}. On iOS, --app-id must be the exact installed bundle identifier for the target app (${targetApp}). Android package names do not carry over automatically.${attachHint}`;
52
+ }
53
+ return `Failed to create WebdriverIO Appium session: ${message}`;
54
+ }
55
+ export function parseTarget(target) {
56
+ if (target.startsWith('text=')) {
57
+ const value = target.split('=', 2)[1] ?? '';
58
+ const xpath = `//*[contains(@text, '${value}') or contains(@content-desc, '${value}') or contains(@label, '${value}') or contains(@name, '${value}') or contains(@value, '${value}')]`;
59
+ return [XPATH, xpath];
60
+ }
61
+ if (target.startsWith('id=')) {
62
+ return [ELEMENT_ID, target.split('=', 2)[1] ?? ''];
63
+ }
64
+ if (target.startsWith('xpath=')) {
65
+ return [XPATH, target.split('=', 2)[1] ?? ''];
66
+ }
67
+ if (target.startsWith('uiautomator=')) {
68
+ return [ANDROID_UIAUTOMATOR, target.split('=', 2)[1] ?? ''];
69
+ }
70
+ if (target.startsWith('predicate=')) {
71
+ return [IOS_PREDICATE, target.split('=', 2)[1] ?? ''];
72
+ }
73
+ if (target.startsWith('classchain=')) {
74
+ return [IOS_CLASS_CHAIN, target.split('=', 2)[1] ?? ''];
75
+ }
76
+ if (target.startsWith('accessibility=')) {
77
+ return [ACCESSIBILITY_ID, target.split('=', 2)[1] ?? ''];
78
+ }
79
+ return [ACCESSIBILITY_ID, target];
80
+ }
81
+ function selectorForTarget(target) {
82
+ const [strategy, value] = parseTarget(target);
83
+ if (strategy === ACCESSIBILITY_ID) {
84
+ return { strategy, value, selector: `~${value}` };
85
+ }
86
+ if (strategy === XPATH) {
87
+ return { strategy, value, selector: value };
88
+ }
89
+ if (strategy === ELEMENT_ID) {
90
+ return { strategy, value, selector: `id=${value}` };
91
+ }
92
+ if (strategy === ANDROID_UIAUTOMATOR) {
93
+ return { strategy, value, selector: `android=${value}` };
94
+ }
95
+ if (strategy === IOS_PREDICATE) {
96
+ return { strategy, value, selector: `-ios predicate string:${value}` };
97
+ }
98
+ return { strategy, value, selector: `-ios class chain:${value}` };
99
+ }
100
+ export function resolveTapMode(args) {
101
+ const hasTarget = Object.hasOwn(args, 'target') && args.target !== undefined && args.target !== null;
102
+ const hasX = Object.hasOwn(args, 'x') && args.x !== undefined && args.x !== null;
103
+ const hasY = Object.hasOwn(args, 'y') && args.y !== undefined && args.y !== null;
104
+ if (hasTarget && (hasX || hasY)) {
105
+ throw new Error('tap cannot mix target with x/y coordinates');
106
+ }
107
+ if (hasTarget) {
108
+ return 'target';
109
+ }
110
+ if (hasX && hasY) {
111
+ return 'coordinates';
112
+ }
113
+ if (hasX !== hasY) {
114
+ throw new Error('tap coordinate mode requires both x and y');
115
+ }
116
+ throw new Error('tap requires target or x/y coordinates');
117
+ }
118
+ export class RealAppiumAdapter {
119
+ platform;
120
+ serverUrl;
121
+ device;
122
+ appId;
123
+ attachToRunning;
124
+ driver = null;
125
+ constructor(platform, serverUrl, device, appId, attachToRunning = false) {
126
+ this.platform = platform;
127
+ this.serverUrl = serverUrl;
128
+ this.device = device;
129
+ this.appId = appId;
130
+ this.attachToRunning = attachToRunning;
131
+ }
132
+ static async create(platform, serverUrl, device, appId, attachToRunning = false) {
133
+ const adapter = new RealAppiumAdapter(platform, serverUrl, device, appId, attachToRunning);
134
+ adapter.driver = await adapter.createDriver();
135
+ return adapter;
136
+ }
137
+ capability() {
138
+ return {
139
+ platform: this.platform,
140
+ commands: ['navigate', 'tap', 'act', 'screenshot', 'wait', 'source']
141
+ };
142
+ }
143
+ async navigate(args) {
144
+ const to = String(args.to ?? '');
145
+ if (to) {
146
+ await this.requireDriver().url(to);
147
+ }
148
+ return {
149
+ action: 'navigate',
150
+ platform: this.platform,
151
+ args: { to }
152
+ };
153
+ }
154
+ async tap(args) {
155
+ if (resolveTapMode(args) === 'coordinates') {
156
+ const { x, y } = await this.resolveCoordinates(args);
157
+ await this.tapPoint(x, y);
158
+ return {
159
+ action: 'tap',
160
+ platform: this.platform,
161
+ args: {
162
+ x,
163
+ y,
164
+ normalized: Boolean(args.normalized)
165
+ }
166
+ };
167
+ }
168
+ const target = String(args.target);
169
+ const selector = selectorForTarget(target);
170
+ const element = await this.requireDriver().$(selector.selector);
171
+ await element.click();
172
+ return {
173
+ action: 'tap',
174
+ platform: this.platform,
175
+ args: { target }
176
+ };
177
+ }
178
+ async act(args) {
179
+ const name = String(args.name ?? '');
180
+ const value = String(args.value ?? '');
181
+ const target = typeof args.target === 'string' ? args.target : undefined;
182
+ if (name === 'type' && target) {
183
+ const selector = selectorForTarget(target);
184
+ const element = await this.requireDriver().$(selector.selector);
185
+ await element.clearValue();
186
+ await element.addValue(value);
187
+ return {
188
+ action: 'act',
189
+ platform: this.platform,
190
+ args: { name, target, value }
191
+ };
192
+ }
193
+ if (name === 'back') {
194
+ await this.requireDriver().back();
195
+ return {
196
+ action: 'act',
197
+ platform: this.platform,
198
+ args: { name }
199
+ };
200
+ }
201
+ throw new Error('Unsupported act operation; use --name type --target <selector> --value <text> or --name back');
202
+ }
203
+ async screenshot(args) {
204
+ const label = String(args.label ?? 'capture');
205
+ const filePath = path.resolve(typeof args.path === 'string' ? args.path : `${label}.png`);
206
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
207
+ await this.requireDriver().saveScreenshot(filePath);
208
+ const { width, height } = pngDimensions(filePath);
209
+ return {
210
+ action: 'screenshot',
211
+ platform: this.platform,
212
+ args: {
213
+ label,
214
+ file: path.basename(filePath),
215
+ path: filePath,
216
+ width,
217
+ height
218
+ }
219
+ };
220
+ }
221
+ async wait(args) {
222
+ const ms = Number(args.ms ?? 0);
223
+ if (ms < 0) {
224
+ throw new Error('wait requires non-negative ms');
225
+ }
226
+ await sleep(ms);
227
+ return {
228
+ action: 'wait',
229
+ platform: this.platform,
230
+ args: { ms }
231
+ };
232
+ }
233
+ async source(args) {
234
+ const label = String(args.label ?? 'source');
235
+ const filePath = path.resolve(typeof args.path === 'string' ? args.path : `${label}.xml`);
236
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
237
+ const content = await this.requireDriver().getPageSource();
238
+ fs.writeFileSync(filePath, content, 'utf8');
239
+ return {
240
+ action: 'source',
241
+ platform: this.platform,
242
+ args: {
243
+ label,
244
+ file: path.basename(filePath),
245
+ path: filePath,
246
+ format: 'xml',
247
+ bytes: fs.statSync(filePath).size
248
+ }
249
+ };
250
+ }
251
+ async exists(target) {
252
+ const selector = selectorForTarget(target);
253
+ const elements = (await this.requireDriver().$$(selector.selector));
254
+ return elements.length > 0;
255
+ }
256
+ async close() {
257
+ if (!this.driver) {
258
+ return;
259
+ }
260
+ const currentDriver = this.driver;
261
+ this.driver = null;
262
+ await currentDriver.deleteSession();
263
+ }
264
+ requireDriver() {
265
+ if (!this.driver) {
266
+ throw new Error('Driver session is not initialized');
267
+ }
268
+ return this.driver;
269
+ }
270
+ async createDriver() {
271
+ const attachToRunning = this.attachToRunning ||
272
+ envBool('VISOR_ATTACH_TO_RUNNING', 'PATF_ATTACH_TO_RUNNING', false);
273
+ const server = parseServerUrl(this.serverUrl);
274
+ const capabilities = {};
275
+ if (this.platform === 'android') {
276
+ capabilities.platformName = 'Android';
277
+ capabilities['appium:automationName'] = 'UiAutomator2';
278
+ capabilities['appium:udid'] =
279
+ this.device ?? env('VISOR_ANDROID_DEVICE', 'PATF_ANDROID_DEVICE', DEFAULT_ANDROID_DEVICE);
280
+ capabilities['appium:appPackage'] =
281
+ this.appId ?? env('VISOR_ANDROID_APP_PACKAGE', 'PATF_ANDROID_APP_PACKAGE', DEFAULT_ANDROID_APP);
282
+ capabilities['appium:appActivity'] = env('VISOR_ANDROID_APP_ACTIVITY', 'PATF_ANDROID_APP_ACTIVITY', '.MainActivity');
283
+ capabilities['appium:newCommandTimeout'] = 60;
284
+ if (attachToRunning) {
285
+ capabilities['appium:noReset'] = true;
286
+ capabilities['appium:fullReset'] = false;
287
+ capabilities['appium:autoLaunch'] = false;
288
+ capabilities['appium:dontStopAppOnReset'] = true;
289
+ }
290
+ }
291
+ else {
292
+ capabilities.platformName = 'iOS';
293
+ capabilities['appium:automationName'] = 'XCUITest';
294
+ capabilities['appium:deviceName'] =
295
+ this.device ?? env('VISOR_IOS_DEVICE', 'PATF_IOS_DEVICE', DEFAULT_IOS_DEVICE);
296
+ capabilities['appium:bundleId'] =
297
+ this.appId ?? env('VISOR_IOS_BUNDLE_ID', 'PATF_IOS_BUNDLE_ID', DEFAULT_IOS_BUNDLE);
298
+ capabilities['appium:newCommandTimeout'] = 60;
299
+ if (attachToRunning) {
300
+ capabilities['appium:noReset'] = true;
301
+ capabilities['appium:fullReset'] = false;
302
+ capabilities['appium:autoLaunch'] = false;
303
+ capabilities['appium:shouldTerminateApp'] = false;
304
+ capabilities['appium:forceAppLaunch'] = false;
305
+ }
306
+ }
307
+ try {
308
+ return await remote({
309
+ protocol: server.protocol,
310
+ hostname: server.host,
311
+ port: server.port,
312
+ path: server.pathname,
313
+ capabilities,
314
+ logLevel: 'error'
315
+ });
316
+ }
317
+ catch (error) {
318
+ throw new Error(formatDriverCreationError(this.platform, this.appId, attachToRunning, error));
319
+ }
320
+ }
321
+ async resolveCoordinates(args) {
322
+ let x = Number(args.x);
323
+ let y = Number(args.y);
324
+ if (Boolean(args.normalized)) {
325
+ const size = await this.requireDriver().getWindowSize();
326
+ x *= Number(size.width);
327
+ y *= Number(size.height);
328
+ }
329
+ return {
330
+ x: Math.round(x),
331
+ y: Math.round(y)
332
+ };
333
+ }
334
+ async tapPoint(x, y) {
335
+ const driver = this.requireDriver();
336
+ if (this.platform === 'android') {
337
+ await driver.execute('mobile: clickGesture', [{ x, y }]);
338
+ return;
339
+ }
340
+ if (this.platform === 'ios') {
341
+ await driver.execute('mobile: tap', [{ x, y }]);
342
+ return;
343
+ }
344
+ throw new Error(`Coordinate tap is unsupported for platform: ${this.platform}`);
345
+ }
346
+ }
347
+ export class MockAdapter {
348
+ platform;
349
+ constructor(platform) {
350
+ this.platform = platform;
351
+ }
352
+ capability() {
353
+ return {
354
+ platform: this.platform,
355
+ commands: ['navigate', 'tap', 'act', 'screenshot', 'wait', 'source']
356
+ };
357
+ }
358
+ async navigate(args) {
359
+ return { action: 'navigate', platform: this.platform, args };
360
+ }
361
+ async tap(args) {
362
+ resolveTapMode(args);
363
+ return { action: 'tap', platform: this.platform, args };
364
+ }
365
+ async act(args) {
366
+ return { action: 'act', platform: this.platform, args };
367
+ }
368
+ async screenshot(args) {
369
+ const label = String(args.label ?? 'capture');
370
+ const filePath = path.resolve(typeof args.path === 'string' ? args.path : `${label}.png`);
371
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
372
+ fs.writeFileSync(filePath, MINI_PNG);
373
+ const { width, height } = pngDimensions(filePath);
374
+ return {
375
+ action: 'screenshot',
376
+ platform: this.platform,
377
+ args: {
378
+ label,
379
+ file: path.basename(filePath),
380
+ path: filePath,
381
+ width,
382
+ height
383
+ }
384
+ };
385
+ }
386
+ async wait(args) {
387
+ const ms = Number(args.ms ?? 0);
388
+ if (ms < 0) {
389
+ throw new Error('wait requires non-negative ms');
390
+ }
391
+ return { action: 'wait', platform: this.platform, args: { ms } };
392
+ }
393
+ async source(args) {
394
+ const label = String(args.label ?? 'source');
395
+ const filePath = path.resolve(typeof args.path === 'string' ? args.path : `${label}.xml`);
396
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
397
+ const content = `<hierarchy platform="${this.platform}"><node text="mock" /></hierarchy>\n`;
398
+ fs.writeFileSync(filePath, content, 'utf8');
399
+ return {
400
+ action: 'source',
401
+ platform: this.platform,
402
+ args: {
403
+ label,
404
+ file: path.basename(filePath),
405
+ path: filePath,
406
+ format: 'xml',
407
+ bytes: fs.statSync(filePath).size
408
+ }
409
+ };
410
+ }
411
+ async exists(target) {
412
+ const lowered = target.toLowerCase();
413
+ return !lowered.includes('missing') && !lowered.includes('not_found');
414
+ }
415
+ async close() {
416
+ return;
417
+ }
418
+ }
419
+ export async function getAdapter(platform, serverUrl = DEFAULT_SERVER_URL, device, useMock = false, appId, attachToRunning = false) {
420
+ const normalized = platform.toLowerCase();
421
+ if (useMock) {
422
+ return new MockAdapter(normalized);
423
+ }
424
+ return RealAppiumAdapter.create(normalized, serverUrl, device, appId, attachToRunning);
425
+ }