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/dist/report.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { canonicalJson, ensureDir } from './utils.js';
|
|
4
|
+
function escapeXml(value) {
|
|
5
|
+
return value
|
|
6
|
+
.replaceAll('&', '&')
|
|
7
|
+
.replaceAll('<', '<')
|
|
8
|
+
.replaceAll('>', '>')
|
|
9
|
+
.replaceAll('"', '"')
|
|
10
|
+
.replaceAll("'", ''');
|
|
11
|
+
}
|
|
12
|
+
function junitXml(run) {
|
|
13
|
+
const tests = run.steps.length;
|
|
14
|
+
const failures = run.steps.filter((step) => step.status !== 'ok').length;
|
|
15
|
+
const lines = [
|
|
16
|
+
`<testsuite name="${escapeXml(run.run_id)}" tests="${tests}" failures="${failures}">`
|
|
17
|
+
];
|
|
18
|
+
for (const step of run.steps) {
|
|
19
|
+
lines.push(` <testcase name="${escapeXml(step.id)}" classname="visor.${escapeXml(run.platform)}" time="${(step.duration_ms / 1000).toFixed(3)}">`);
|
|
20
|
+
if (step.status !== 'ok') {
|
|
21
|
+
const message = step.error?.message ?? 'step failed';
|
|
22
|
+
lines.push(` <failure message="${escapeXml(message)}" />`);
|
|
23
|
+
}
|
|
24
|
+
lines.push(' </testcase>');
|
|
25
|
+
}
|
|
26
|
+
lines.push('</testsuite>');
|
|
27
|
+
return `${lines.join('\n')}\n`;
|
|
28
|
+
}
|
|
29
|
+
function materializeArtifacts(root, artifactPaths) {
|
|
30
|
+
const screenshotsDir = ensureDir(path.join(root, 'screenshots'));
|
|
31
|
+
const sourcesDir = ensureDir(path.join(root, 'sources'));
|
|
32
|
+
const persisted = [];
|
|
33
|
+
artifactPaths.forEach((artifactPath, index) => {
|
|
34
|
+
if (!fs.existsSync(artifactPath)) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const sourcePath = path.resolve(artifactPath);
|
|
38
|
+
const destinationRoot = sourcePath.toLowerCase().endsWith('.png') ? screenshotsDir : sourcesDir;
|
|
39
|
+
const destinationPath = path.join(destinationRoot, `${String(index + 1).padStart(3, '0')}-${path.basename(sourcePath)}`);
|
|
40
|
+
if (sourcePath !== path.resolve(destinationPath)) {
|
|
41
|
+
fs.copyFileSync(sourcePath, destinationPath);
|
|
42
|
+
}
|
|
43
|
+
persisted.push(destinationPath);
|
|
44
|
+
});
|
|
45
|
+
return persisted;
|
|
46
|
+
}
|
|
47
|
+
export function writeReports(result, reportDir) {
|
|
48
|
+
const root = ensureDir(path.join(reportDir, result.run_id));
|
|
49
|
+
const env = ensureDir(path.join(root, 'env'));
|
|
50
|
+
const payload = structuredClone(result);
|
|
51
|
+
const persistedArtifacts = materializeArtifacts(root, result.artifacts);
|
|
52
|
+
payload.artifacts = persistedArtifacts;
|
|
53
|
+
result.artifacts = persistedArtifacts;
|
|
54
|
+
const summaryTxt = path.join(root, 'summary.txt');
|
|
55
|
+
const summaryJson = path.join(root, 'summary.json');
|
|
56
|
+
const junitPath = path.join(root, 'junit.xml');
|
|
57
|
+
const timelinePath = path.join(root, 'timeline.log');
|
|
58
|
+
const htmlPath = path.join(root, 'report.html');
|
|
59
|
+
fs.writeFileSync(summaryTxt, [
|
|
60
|
+
`run_id: ${result.run_id}`,
|
|
61
|
+
`platform: ${result.platform}`,
|
|
62
|
+
`device: ${result.device}`,
|
|
63
|
+
`status: ${result.status}`,
|
|
64
|
+
`determinism_signature: ${result.determinism_signature}`,
|
|
65
|
+
`artifact_count: ${result.artifacts.length}`,
|
|
66
|
+
''
|
|
67
|
+
].join('\n'), 'utf8');
|
|
68
|
+
fs.writeFileSync(summaryJson, `${canonicalJson(payload)}\n`, 'utf8');
|
|
69
|
+
fs.writeFileSync(junitPath, junitXml(payload), 'utf8');
|
|
70
|
+
fs.writeFileSync(timelinePath, `${payload.steps
|
|
71
|
+
.map((step, index) => `${String(index + 1).padStart(3, '0')} ${step.id} ${step.command} ${step.status} ${step.duration_ms}ms`)
|
|
72
|
+
.join('\n')}\n`, 'utf8');
|
|
73
|
+
fs.writeFileSync(path.join(env, 'runtime.json'), `${JSON.stringify({
|
|
74
|
+
seed: result.seed,
|
|
75
|
+
started_at: result.started_at,
|
|
76
|
+
ended_at: result.ended_at
|
|
77
|
+
}, null, 2)}\n`, 'utf8');
|
|
78
|
+
fs.writeFileSync(path.join(env, 'device.json'), `${JSON.stringify({
|
|
79
|
+
platform: result.platform,
|
|
80
|
+
device: result.device
|
|
81
|
+
}, null, 2)}\n`, 'utf8');
|
|
82
|
+
fs.writeFileSync(htmlPath, `<html><body><h1>${escapeXml(result.run_id)}</h1><p>Status: ${escapeXml(result.status)}</p><p>Artifacts: ${result.artifacts.length}</p></body></html>\n`, 'utf8');
|
|
83
|
+
return {
|
|
84
|
+
summary: summaryTxt,
|
|
85
|
+
json: summaryJson,
|
|
86
|
+
junit: junitPath,
|
|
87
|
+
timeline: timelinePath,
|
|
88
|
+
html: htmlPath
|
|
89
|
+
};
|
|
90
|
+
}
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { performance } from 'node:perf_hooks';
|
|
4
|
+
import { makeError } from './errors.js';
|
|
5
|
+
import { makeId, signatureFor, utcNowIso } from './utils.js';
|
|
6
|
+
async function runStep(adapter, command, args) {
|
|
7
|
+
switch (command) {
|
|
8
|
+
case 'tap':
|
|
9
|
+
return adapter.tap(args);
|
|
10
|
+
case 'navigate':
|
|
11
|
+
return adapter.navigate(args);
|
|
12
|
+
case 'act':
|
|
13
|
+
return adapter.act(args);
|
|
14
|
+
case 'screenshot':
|
|
15
|
+
return adapter.screenshot(args);
|
|
16
|
+
case 'wait':
|
|
17
|
+
return adapter.wait(args);
|
|
18
|
+
case 'source':
|
|
19
|
+
return adapter.source(args);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function evaluateAssertions(adapter, assertions) {
|
|
23
|
+
const results = [];
|
|
24
|
+
let ok = true;
|
|
25
|
+
const failedTargets = [];
|
|
26
|
+
for (const assertion of assertions) {
|
|
27
|
+
let status = 'passed';
|
|
28
|
+
let details = '';
|
|
29
|
+
if (assertion.type === 'visible') {
|
|
30
|
+
const visible = await adapter.exists(assertion.target);
|
|
31
|
+
if (!visible) {
|
|
32
|
+
status = 'failed';
|
|
33
|
+
details = `Target not visible: ${assertion.target}`;
|
|
34
|
+
ok = false;
|
|
35
|
+
failedTargets.push(assertion.target);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
status = 'failed';
|
|
40
|
+
details = `Unsupported assertion type: ${assertion.type}`;
|
|
41
|
+
ok = false;
|
|
42
|
+
failedTargets.push(assertion.target);
|
|
43
|
+
}
|
|
44
|
+
results.push({
|
|
45
|
+
id: assertion.id,
|
|
46
|
+
type: assertion.type,
|
|
47
|
+
target: assertion.target,
|
|
48
|
+
status,
|
|
49
|
+
details
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return { results, ok, failedTargets };
|
|
53
|
+
}
|
|
54
|
+
function signatureSafeDetails(details) {
|
|
55
|
+
const safe = structuredClone(details);
|
|
56
|
+
const args = safe.args;
|
|
57
|
+
if (args && typeof args === 'object' && !Array.isArray(args)) {
|
|
58
|
+
delete args.path;
|
|
59
|
+
}
|
|
60
|
+
return safe;
|
|
61
|
+
}
|
|
62
|
+
export async function runScenario(scenario, adapter, device = 'local', timeoutMs, artifactBaseDir) {
|
|
63
|
+
const started_at = utcNowIso();
|
|
64
|
+
const run_id = makeId('run');
|
|
65
|
+
const platform = scenario.meta.platform;
|
|
66
|
+
const stepResults = [];
|
|
67
|
+
const artifacts = [];
|
|
68
|
+
let topError;
|
|
69
|
+
let screenshotDir;
|
|
70
|
+
let sourceDir;
|
|
71
|
+
if (artifactBaseDir) {
|
|
72
|
+
screenshotDir = path.join(artifactBaseDir, run_id, 'screenshots');
|
|
73
|
+
sourceDir = path.join(artifactBaseDir, run_id, 'sources');
|
|
74
|
+
fs.mkdirSync(screenshotDir, { recursive: true });
|
|
75
|
+
fs.mkdirSync(sourceDir, { recursive: true });
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
for (const step of scenario.steps) {
|
|
79
|
+
const started = performance.now();
|
|
80
|
+
try {
|
|
81
|
+
const stepArgs = { ...step.args };
|
|
82
|
+
if (step.command === 'screenshot' && screenshotDir) {
|
|
83
|
+
const label = String(stepArgs.label ?? step.id);
|
|
84
|
+
stepArgs.path = path.join(screenshotDir, `${label}.png`);
|
|
85
|
+
}
|
|
86
|
+
if (step.command === 'source' && sourceDir) {
|
|
87
|
+
const label = String(stepArgs.label ?? step.id);
|
|
88
|
+
stepArgs.path = path.join(sourceDir, `${label}.xml`);
|
|
89
|
+
}
|
|
90
|
+
const details = await runStep(adapter, step.command, stepArgs);
|
|
91
|
+
const durationMs = Math.round(performance.now() - started);
|
|
92
|
+
const result = {
|
|
93
|
+
id: step.id,
|
|
94
|
+
command: step.command,
|
|
95
|
+
status: 'ok',
|
|
96
|
+
duration_ms: durationMs,
|
|
97
|
+
details
|
|
98
|
+
};
|
|
99
|
+
if (step.command === 'screenshot' || step.command === 'source') {
|
|
100
|
+
const args = details.args;
|
|
101
|
+
if (args && typeof args === 'object' && !Array.isArray(args)) {
|
|
102
|
+
const artifactPath = typeof args.path === 'string'
|
|
103
|
+
? args.path
|
|
104
|
+
: typeof args.file === 'string'
|
|
105
|
+
? args.file
|
|
106
|
+
: '';
|
|
107
|
+
if (artifactPath) {
|
|
108
|
+
artifacts.push(artifactPath);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (timeoutMs && durationMs > timeoutMs) {
|
|
113
|
+
result.status = 'fail';
|
|
114
|
+
result.error = makeError('ACTION_ERROR', `Step '${step.id}' exceeded timeout`, 'Device/app was too slow for configured timeout', 'Increase --timeout or optimize target action');
|
|
115
|
+
}
|
|
116
|
+
stepResults.push(result);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
const durationMs = Math.round(performance.now() - started);
|
|
120
|
+
stepResults.push({
|
|
121
|
+
id: step.id,
|
|
122
|
+
command: step.command,
|
|
123
|
+
status: 'fail',
|
|
124
|
+
duration_ms: durationMs,
|
|
125
|
+
details: {},
|
|
126
|
+
error: makeError('ACTION_ERROR', `Step '${step.id}' failed`, error instanceof Error ? error.message : String(error), 'Inspect step args and platform adapter support')
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const assertionEvaluation = await evaluateAssertions(adapter, scenario.assertions);
|
|
131
|
+
const stepsOk = stepResults.every((step) => step.status === 'ok');
|
|
132
|
+
if (!stepsOk) {
|
|
133
|
+
topError = makeError('ACTION_ERROR', 'Run failed due to one or more action step failures', 'At least one command execution step returned failure', 'Inspect failed step error payloads in run.steps and retry');
|
|
134
|
+
}
|
|
135
|
+
if (!assertionEvaluation.ok) {
|
|
136
|
+
const firstFailedTarget = assertionEvaluation.failedTargets[0] ?? 'unknown target';
|
|
137
|
+
topError = makeError('ASSERTION_ERROR', 'Run failed due to assertion failure', `Expected UI target was not satisfied: ${firstFailedTarget}`, 'Verify selector correctness and app state before assertion step');
|
|
138
|
+
}
|
|
139
|
+
const allPass = stepsOk && assertionEvaluation.ok;
|
|
140
|
+
const ended_at = utcNowIso();
|
|
141
|
+
const signatureInput = {
|
|
142
|
+
platform,
|
|
143
|
+
steps: stepResults.map((step) => ({
|
|
144
|
+
id: step.id,
|
|
145
|
+
command: step.command,
|
|
146
|
+
status: step.status,
|
|
147
|
+
details: signatureSafeDetails(step.details)
|
|
148
|
+
})),
|
|
149
|
+
assertions: assertionEvaluation.results
|
|
150
|
+
};
|
|
151
|
+
return {
|
|
152
|
+
run_id,
|
|
153
|
+
platform,
|
|
154
|
+
device,
|
|
155
|
+
started_at,
|
|
156
|
+
ended_at,
|
|
157
|
+
status: allPass ? 'ok' : 'fail',
|
|
158
|
+
steps: stepResults,
|
|
159
|
+
assertions: assertionEvaluation.results,
|
|
160
|
+
artifacts,
|
|
161
|
+
determinism_signature: signatureFor(signatureInput),
|
|
162
|
+
seed: typeof scenario.config.seed === 'number' ? scenario.config.seed : undefined,
|
|
163
|
+
error: topError
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
finally {
|
|
167
|
+
await adapter.close();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
export function determinismCheck(signatures) {
|
|
171
|
+
if (signatures.length === 0) {
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
const baseline = signatures[0];
|
|
175
|
+
const same = signatures.filter((signature) => signature === baseline).length;
|
|
176
|
+
return Math.round((same / signatures.length) * 10000) / 100;
|
|
177
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
export function utcNowIso() {
|
|
5
|
+
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
6
|
+
}
|
|
7
|
+
export function makeId(prefix) {
|
|
8
|
+
return `${prefix}_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
|
9
|
+
}
|
|
10
|
+
function sortValue(value) {
|
|
11
|
+
if (Array.isArray(value)) {
|
|
12
|
+
return value.map((item) => sortValue(item));
|
|
13
|
+
}
|
|
14
|
+
if (value && typeof value === 'object') {
|
|
15
|
+
const input = value;
|
|
16
|
+
return Object.keys(input)
|
|
17
|
+
.sort()
|
|
18
|
+
.reduce((acc, key) => {
|
|
19
|
+
acc[key] = sortValue(input[key]);
|
|
20
|
+
return acc;
|
|
21
|
+
}, {});
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
export function canonicalJson(data) {
|
|
26
|
+
return JSON.stringify(sortValue(data));
|
|
27
|
+
}
|
|
28
|
+
export function signatureFor(data) {
|
|
29
|
+
return createHash('sha256').update(canonicalJson(data), 'utf8').digest('hex');
|
|
30
|
+
}
|
|
31
|
+
export function ensureDir(targetPath) {
|
|
32
|
+
fs.mkdirSync(targetPath, { recursive: true });
|
|
33
|
+
return targetPath;
|
|
34
|
+
}
|
|
35
|
+
export async function sleep(ms) {
|
|
36
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
37
|
+
}
|
|
38
|
+
export function parseServerUrl(serverUrl) {
|
|
39
|
+
const parsed = new URL(serverUrl);
|
|
40
|
+
return {
|
|
41
|
+
server_url: serverUrl,
|
|
42
|
+
protocol: parsed.protocol.replace(':', '') || 'http',
|
|
43
|
+
host: parsed.hostname || '127.0.0.1',
|
|
44
|
+
port: Number(parsed.port || 4723),
|
|
45
|
+
pathname: parsed.pathname && parsed.pathname !== '' ? parsed.pathname : '/'
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
export function readJsonFile(filePath) {
|
|
49
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
50
|
+
}
|
|
51
|
+
export function errorMessage(error) {
|
|
52
|
+
if (error instanceof Error) {
|
|
53
|
+
return error.message;
|
|
54
|
+
}
|
|
55
|
+
return String(error);
|
|
56
|
+
}
|
|
57
|
+
export function resolveExecutable(baseName) {
|
|
58
|
+
const pathValue = process.env.PATH ?? '';
|
|
59
|
+
const pathEntries = pathValue.split(path.delimiter).filter(Boolean);
|
|
60
|
+
const candidates = process.platform === 'win32'
|
|
61
|
+
? [baseName, `${baseName}.cmd`, `${baseName}.exe`, `${baseName}.bat`]
|
|
62
|
+
: [baseName];
|
|
63
|
+
for (const entry of pathEntries) {
|
|
64
|
+
for (const candidate of candidates) {
|
|
65
|
+
const fullPath = path.join(entry, candidate);
|
|
66
|
+
try {
|
|
67
|
+
fs.accessSync(fullPath, fs.constants.X_OK);
|
|
68
|
+
return fullPath;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
export function splitCommandLine(command) {
|
|
78
|
+
const parts = [];
|
|
79
|
+
let current = '';
|
|
80
|
+
let quote = null;
|
|
81
|
+
let escaping = false;
|
|
82
|
+
for (const char of command) {
|
|
83
|
+
if (escaping) {
|
|
84
|
+
current += char;
|
|
85
|
+
escaping = false;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (char === '\\') {
|
|
89
|
+
escaping = true;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (quote) {
|
|
93
|
+
if (char === quote) {
|
|
94
|
+
quote = null;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
current += char;
|
|
98
|
+
}
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (char === '"' || char === "'") {
|
|
102
|
+
quote = char;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (/\s/.test(char)) {
|
|
106
|
+
if (current !== '') {
|
|
107
|
+
parts.push(current);
|
|
108
|
+
current = '';
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
current += char;
|
|
113
|
+
}
|
|
114
|
+
if (current !== '') {
|
|
115
|
+
parts.push(current);
|
|
116
|
+
}
|
|
117
|
+
return parts;
|
|
118
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
const ALLOWED_TOP = new Set(['meta', 'config', 'steps', 'assertions', 'output']);
|
|
3
|
+
const ALLOWED_META = new Set(['name', 'version', 'platform', 'tags']);
|
|
4
|
+
const ALLOWED_CONFIG = new Set(['timeoutMs', 'seed', 'artifactsDir']);
|
|
5
|
+
const ALLOWED_STEP = new Set(['id', 'command', 'args']);
|
|
6
|
+
const ALLOWED_ASSERT = new Set(['id', 'type', 'target']);
|
|
7
|
+
const ALLOWED_OUTPUT = new Set(['report']);
|
|
8
|
+
const SUPPORTED_COMMANDS = new Set(['tap', 'navigate', 'act', 'screenshot', 'wait', 'source']);
|
|
9
|
+
function validationIssue(severity, code, message, issuePath) {
|
|
10
|
+
return { severity, code, message, path: issuePath };
|
|
11
|
+
}
|
|
12
|
+
function isRecord(value) {
|
|
13
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
function unknownFields(obj, allowed, issuePath) {
|
|
16
|
+
return Object.keys(obj)
|
|
17
|
+
.filter((key) => !allowed.has(key))
|
|
18
|
+
.map((key) => validationIssue('error', 'UNKNOWN_FIELD', `Unknown field '${key}'`, issuePath ? `${issuePath}.${key}` : key));
|
|
19
|
+
}
|
|
20
|
+
function requiredFields(obj, fields, issuePath) {
|
|
21
|
+
return fields
|
|
22
|
+
.filter((field) => !(field in obj))
|
|
23
|
+
.map((field) => validationIssue('error', 'MISSING_REQUIRED', `Missing required field '${field}'`, issuePath));
|
|
24
|
+
}
|
|
25
|
+
function validateTapArgs(args, issuePath) {
|
|
26
|
+
const issues = [];
|
|
27
|
+
const hasTarget = Object.hasOwn(args, 'target');
|
|
28
|
+
const hasX = Object.hasOwn(args, 'x');
|
|
29
|
+
const hasY = Object.hasOwn(args, 'y');
|
|
30
|
+
if (hasTarget) {
|
|
31
|
+
if (hasX || hasY) {
|
|
32
|
+
issues.push(validationIssue('error', 'ARG_ERROR', 'tap cannot mix args.target with args.x/args.y', issuePath));
|
|
33
|
+
}
|
|
34
|
+
return issues;
|
|
35
|
+
}
|
|
36
|
+
if (hasX !== hasY) {
|
|
37
|
+
issues.push(validationIssue('error', 'ARG_ERROR', 'tap coordinate mode requires both args.x and args.y', issuePath));
|
|
38
|
+
return issues;
|
|
39
|
+
}
|
|
40
|
+
if (!hasX && !hasY) {
|
|
41
|
+
issues.push(validationIssue('error', 'ARG_ERROR', 'tap requires args.target or args.x/args.y', issuePath));
|
|
42
|
+
return issues;
|
|
43
|
+
}
|
|
44
|
+
if (Object.hasOwn(args, 'normalized') && typeof args.normalized !== 'boolean') {
|
|
45
|
+
issues.push(validationIssue('error', 'ARG_ERROR', 'tap args.normalized must be boolean', issuePath));
|
|
46
|
+
}
|
|
47
|
+
return issues;
|
|
48
|
+
}
|
|
49
|
+
export function parseAndValidate(filePath) {
|
|
50
|
+
const issues = [];
|
|
51
|
+
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
52
|
+
if (!isRecord(raw)) {
|
|
53
|
+
return {
|
|
54
|
+
scenario: null,
|
|
55
|
+
issues: [validationIssue('error', 'TYPE_ERROR', 'Scenario root must be object', '$')]
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
issues.push(...unknownFields(raw, ALLOWED_TOP, '$'));
|
|
59
|
+
issues.push(...requiredFields(raw, ['meta', 'config', 'steps'], '$'));
|
|
60
|
+
const metaRaw = raw.meta;
|
|
61
|
+
const configRaw = raw.config;
|
|
62
|
+
const stepsRaw = raw.steps;
|
|
63
|
+
const assertionsRaw = raw.assertions ?? [];
|
|
64
|
+
const outputRaw = raw.output ?? {};
|
|
65
|
+
let meta = null;
|
|
66
|
+
if (isRecord(metaRaw)) {
|
|
67
|
+
issues.push(...unknownFields(metaRaw, ALLOWED_META, '$.meta'));
|
|
68
|
+
issues.push(...requiredFields(metaRaw, ['name', 'version', 'platform'], '$.meta'));
|
|
69
|
+
if (metaRaw.platform !== 'ios' && metaRaw.platform !== 'android') {
|
|
70
|
+
issues.push(validationIssue('error', 'INPUT_ERROR', 'meta.platform must be ios|android', '$.meta.platform'));
|
|
71
|
+
}
|
|
72
|
+
else if (typeof metaRaw.name === 'string' && typeof metaRaw.version === 'string') {
|
|
73
|
+
meta = {
|
|
74
|
+
...metaRaw,
|
|
75
|
+
name: metaRaw.name,
|
|
76
|
+
version: metaRaw.version,
|
|
77
|
+
platform: metaRaw.platform
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
issues.push(validationIssue('error', 'TYPE_ERROR', 'meta must be object', '$.meta'));
|
|
83
|
+
}
|
|
84
|
+
const config = isRecord(configRaw) ? configRaw : {};
|
|
85
|
+
if (isRecord(configRaw)) {
|
|
86
|
+
issues.push(...unknownFields(configRaw, ALLOWED_CONFIG, '$.config'));
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
issues.push(validationIssue('error', 'TYPE_ERROR', 'config must be object', '$.config'));
|
|
90
|
+
}
|
|
91
|
+
if (!Object.hasOwn(config, 'seed')) {
|
|
92
|
+
issues.push(validationIssue('warning', 'DETERMINISM_WARNING', 'config.seed is missing', '$.config'));
|
|
93
|
+
}
|
|
94
|
+
const steps = [];
|
|
95
|
+
if (!Array.isArray(stepsRaw) || stepsRaw.length === 0) {
|
|
96
|
+
issues.push(validationIssue('error', 'TYPE_ERROR', 'steps must be non-empty list', '$.steps'));
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
for (const [index, rawStep] of stepsRaw.entries()) {
|
|
100
|
+
const issuePath = `$.steps[${index}]`;
|
|
101
|
+
if (!isRecord(rawStep)) {
|
|
102
|
+
issues.push(validationIssue('error', 'TYPE_ERROR', 'step must be object', issuePath));
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
issues.push(...unknownFields(rawStep, ALLOWED_STEP, issuePath));
|
|
106
|
+
issues.push(...requiredFields(rawStep, ['id', 'command', 'args'], issuePath));
|
|
107
|
+
const command = rawStep.command;
|
|
108
|
+
const args = rawStep.args;
|
|
109
|
+
if (typeof command === 'string' && !SUPPORTED_COMMANDS.has(command)) {
|
|
110
|
+
issues.push(validationIssue('error', 'UNSUPPORTED_COMMAND', `Unsupported command '${command}'`, `${issuePath}.command`));
|
|
111
|
+
}
|
|
112
|
+
if (command === 'tap' && isRecord(args)) {
|
|
113
|
+
issues.push(...validateTapArgs(args, `${issuePath}.args`));
|
|
114
|
+
}
|
|
115
|
+
if (command === 'navigate' && isRecord(args) && !Object.hasOwn(args, 'to')) {
|
|
116
|
+
issues.push(validationIssue('error', 'ARG_ERROR', 'navigate requires args.to', `${issuePath}.args`));
|
|
117
|
+
}
|
|
118
|
+
if (command === 'screenshot' && isRecord(args) && !Object.hasOwn(args, 'label')) {
|
|
119
|
+
issues.push(validationIssue('warning', 'DETERMINISM_WARNING', 'screenshot missing label may reduce determinism', `${issuePath}.args`));
|
|
120
|
+
}
|
|
121
|
+
if (command === 'wait' && isRecord(args) && !Object.hasOwn(args, 'ms')) {
|
|
122
|
+
issues.push(validationIssue('error', 'ARG_ERROR', 'wait requires args.ms', `${issuePath}.args`));
|
|
123
|
+
}
|
|
124
|
+
if (typeof rawStep.id === 'string' &&
|
|
125
|
+
typeof command === 'string' &&
|
|
126
|
+
SUPPORTED_COMMANDS.has(command) &&
|
|
127
|
+
isRecord(args)) {
|
|
128
|
+
steps.push({
|
|
129
|
+
id: rawStep.id,
|
|
130
|
+
command: command,
|
|
131
|
+
args
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const assertions = [];
|
|
137
|
+
if (Array.isArray(assertionsRaw)) {
|
|
138
|
+
for (const [index, rawAssertion] of assertionsRaw.entries()) {
|
|
139
|
+
const issuePath = `$.assertions[${index}]`;
|
|
140
|
+
if (!isRecord(rawAssertion)) {
|
|
141
|
+
issues.push(validationIssue('error', 'TYPE_ERROR', 'assertion must be object', issuePath));
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
issues.push(...unknownFields(rawAssertion, ALLOWED_ASSERT, issuePath));
|
|
145
|
+
issues.push(...requiredFields(rawAssertion, ['id', 'type', 'target'], issuePath));
|
|
146
|
+
if (typeof rawAssertion.id === 'string' &&
|
|
147
|
+
typeof rawAssertion.type === 'string' &&
|
|
148
|
+
typeof rawAssertion.target === 'string') {
|
|
149
|
+
assertions.push({
|
|
150
|
+
id: rawAssertion.id,
|
|
151
|
+
type: rawAssertion.type,
|
|
152
|
+
target: rawAssertion.target
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
issues.push(validationIssue('error', 'TYPE_ERROR', 'assertions must be list', '$.assertions'));
|
|
159
|
+
}
|
|
160
|
+
const output = isRecord(outputRaw) ? outputRaw : {};
|
|
161
|
+
if (isRecord(outputRaw)) {
|
|
162
|
+
issues.push(...unknownFields(outputRaw, ALLOWED_OUTPUT, '$.output'));
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
issues.push(validationIssue('error', 'TYPE_ERROR', 'output must be object', '$.output'));
|
|
166
|
+
}
|
|
167
|
+
if (issues.some((issue) => issue.severity === 'error') || meta === null) {
|
|
168
|
+
return {
|
|
169
|
+
scenario: null,
|
|
170
|
+
issues
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
scenario: {
|
|
175
|
+
meta,
|
|
176
|
+
config,
|
|
177
|
+
steps,
|
|
178
|
+
assertions,
|
|
179
|
+
output
|
|
180
|
+
},
|
|
181
|
+
issues
|
|
182
|
+
};
|
|
183
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "visor-ai",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Visor CLI for LLM-driven mobile app interaction and artifact capture",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=20"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"visor": "./dist/main.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "rm -rf dist && tsc -p tsconfig.json",
|
|
23
|
+
"dev": "tsx src/main.ts",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"check": "npm run build && npm run test"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"appium",
|
|
29
|
+
"mobile",
|
|
30
|
+
"automation",
|
|
31
|
+
"webdriverio",
|
|
32
|
+
"ios",
|
|
33
|
+
"android",
|
|
34
|
+
"cli"
|
|
35
|
+
],
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"appium": "^2.17.0",
|
|
38
|
+
"webdriverio": "^9.19.2"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.13.10",
|
|
42
|
+
"tsx": "^4.19.3",
|
|
43
|
+
"typescript": "^5.8.2",
|
|
44
|
+
"vitest": "^3.0.8"
|
|
45
|
+
}
|
|
46
|
+
}
|