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 +21 -0
- package/README.md +66 -0
- package/dist/adapters.js +425 -0
- package/dist/appiumLifecycle.js +267 -0
- package/dist/cli.js +610 -0
- package/dist/errors.js +8 -0
- package/dist/index.js +9 -0
- package/dist/main.js +43 -0
- package/dist/report.js +90 -0
- package/dist/runner.js +177 -0
- package/dist/types.js +1 -0
- package/dist/utils.js +118 -0
- package/dist/validator.js +183 -0
- package/package.json +46 -0
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).
|
package/dist/adapters.js
ADDED
|
@@ -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
|
+
}
|