@inamul_hasan/vouch 1.0.0
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/CONTRIBUTING.md +29 -0
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1389 -0
- package/dist/index.js.map +1 -0
- package/examples/demo.vch +18 -0
- package/examples/real_login.vch +21 -0
- package/package.json +60 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1389 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/commands.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import * as fs7 from "fs";
|
|
6
|
+
|
|
7
|
+
// src/cli/runner.ts
|
|
8
|
+
import * as fs4 from "fs";
|
|
9
|
+
import * as path4 from "path";
|
|
10
|
+
|
|
11
|
+
// src/types/index.ts
|
|
12
|
+
var TERMINAL_ACTIONS = ["complete", "fail"];
|
|
13
|
+
var DEFAULT_CONFIG = {
|
|
14
|
+
provider: "openai",
|
|
15
|
+
model: "gpt-4o",
|
|
16
|
+
viewportWidth: 1280,
|
|
17
|
+
viewportHeight: 800,
|
|
18
|
+
headless: false,
|
|
19
|
+
maxRetries: 3,
|
|
20
|
+
actionDelay: 200,
|
|
21
|
+
stepTimeout: 3e4,
|
|
22
|
+
report: true,
|
|
23
|
+
reportDir: "./.vouch/reports",
|
|
24
|
+
recordVideo: false,
|
|
25
|
+
videoDir: "./.vouch/videos"
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// src/engine/prompts.ts
|
|
29
|
+
var VISION_QA_SYSTEM_PROMPT = `QA engine. Input: instruction, UI tree, history. Output: JSON only.
|
|
30
|
+
Grid: 0-1000. Format: role "name" v="val" @x,y
|
|
31
|
+
Rules: fix typos, self-heal from history, detect validation errors, use 'complete' when done, 'fail' if stuck.
|
|
32
|
+
{"reasoning":"...","action":"click|type|wait|scroll|hover|keypress|select|upload|complete|fail","x":0,"y":0,"textPayload":"","detectedValidationError":""}`;
|
|
33
|
+
function buildUserMessage(stepInstruction, historyLedger, screenReaderOutput) {
|
|
34
|
+
const parts = [];
|
|
35
|
+
parts.push(`INSTRUCTION: ${stepInstruction}`);
|
|
36
|
+
if (screenReaderOutput) {
|
|
37
|
+
parts.push(`
|
|
38
|
+
${screenReaderOutput}`);
|
|
39
|
+
}
|
|
40
|
+
if (historyLedger.length > 0) {
|
|
41
|
+
parts.push(`
|
|
42
|
+
HISTORY:`);
|
|
43
|
+
for (const e of historyLedger) {
|
|
44
|
+
parts.push(
|
|
45
|
+
`#${e.attempt}: ${e.action}@${e.x},${e.y}${e.textPayload ? ` t=${e.textPayload}` : ""} -> ${e.success ? "OK" : "FAIL"}${e.error ? ` err=${e.error}` : ""}${e.detectedValidationError ? ` val=${e.detectedValidationError}` : ""}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
parts.push("\nRespond with JSON.");
|
|
50
|
+
return parts.join("\n");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/engine/providers/openai.ts
|
|
54
|
+
import OpenAI from "openai";
|
|
55
|
+
|
|
56
|
+
// src/engine/providers/base.ts
|
|
57
|
+
var BaseProvider = class {
|
|
58
|
+
/**
|
|
59
|
+
* Parses the raw AI response text into a validated VisionQAResponse.
|
|
60
|
+
*/
|
|
61
|
+
parseResponse(raw) {
|
|
62
|
+
let cleaned = raw.trim();
|
|
63
|
+
if (cleaned.startsWith("```")) {
|
|
64
|
+
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "");
|
|
65
|
+
}
|
|
66
|
+
cleaned = cleaned.replace(/,\s*([\]}])/g, "$1");
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = JSON.parse(cleaned);
|
|
70
|
+
} catch {
|
|
71
|
+
const match = cleaned.match(/\{[\s\S]*\}/);
|
|
72
|
+
if (match) {
|
|
73
|
+
const extracted = match[0].replace(/,\s*([\]}])/g, "$1");
|
|
74
|
+
parsed = JSON.parse(extracted);
|
|
75
|
+
} else {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Failed to parse AI response as JSON:
|
|
78
|
+
${raw.slice(0, 500)}`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
reasoning: String(parsed.reasoning ?? ""),
|
|
84
|
+
action: String(parsed.action ?? "fail"),
|
|
85
|
+
x: Math.round(Number(parsed.x ?? 500)),
|
|
86
|
+
y: Math.round(Number(parsed.y ?? 500)),
|
|
87
|
+
textPayload: String(parsed.textPayload ?? ""),
|
|
88
|
+
detectedValidationError: String(parsed.detectedValidationError ?? "")
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Checks if accumulated text contains a complete JSON object.
|
|
93
|
+
* Returns true when we can safely stop streaming.
|
|
94
|
+
* Uses brace-depth tracking with proper string/escape handling.
|
|
95
|
+
*/
|
|
96
|
+
hasCompleteJSON(text) {
|
|
97
|
+
let depth = 0;
|
|
98
|
+
let inString = false;
|
|
99
|
+
let escape = false;
|
|
100
|
+
let started = false;
|
|
101
|
+
for (const ch of text) {
|
|
102
|
+
if (escape) {
|
|
103
|
+
escape = false;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (ch === "\\" && inString) {
|
|
107
|
+
escape = true;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (ch === '"') {
|
|
111
|
+
inString = !inString;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (inString) continue;
|
|
115
|
+
if (ch === "{") {
|
|
116
|
+
depth++;
|
|
117
|
+
started = true;
|
|
118
|
+
} else if (ch === "}") {
|
|
119
|
+
depth--;
|
|
120
|
+
if (started && depth === 0) return true;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// src/engine/providers/openai.ts
|
|
128
|
+
var OpenAIProvider = class extends BaseProvider {
|
|
129
|
+
client;
|
|
130
|
+
model;
|
|
131
|
+
constructor(apiKey, model = "gpt-4o", baseUrl) {
|
|
132
|
+
super();
|
|
133
|
+
this.client = new OpenAI({
|
|
134
|
+
apiKey,
|
|
135
|
+
...baseUrl ? { baseURL: baseUrl } : {}
|
|
136
|
+
});
|
|
137
|
+
this.model = model;
|
|
138
|
+
}
|
|
139
|
+
async analyze(systemPrompt, stepInstruction, screenReaderOutput, historyLedger) {
|
|
140
|
+
const userMessage = buildUserMessage(stepInstruction, historyLedger, screenReaderOutput);
|
|
141
|
+
const stream = await this.client.chat.completions.create({
|
|
142
|
+
model: this.model,
|
|
143
|
+
max_tokens: 300,
|
|
144
|
+
temperature: 0.1,
|
|
145
|
+
stream: true,
|
|
146
|
+
messages: [
|
|
147
|
+
{ role: "system", content: systemPrompt },
|
|
148
|
+
{ role: "user", content: userMessage }
|
|
149
|
+
]
|
|
150
|
+
});
|
|
151
|
+
let accumulated = "";
|
|
152
|
+
for await (const chunk of stream) {
|
|
153
|
+
const delta = chunk.choices[0]?.delta?.content ?? "";
|
|
154
|
+
accumulated += delta;
|
|
155
|
+
if (this.hasCompleteJSON(accumulated)) {
|
|
156
|
+
stream.controller.abort();
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (!accumulated.trim()) throw new Error("OpenAI returned empty response");
|
|
161
|
+
return this.parseResponse(accumulated);
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
// src/engine/providers/anthropic.ts
|
|
166
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
167
|
+
var AnthropicProvider = class extends BaseProvider {
|
|
168
|
+
client;
|
|
169
|
+
model;
|
|
170
|
+
constructor(apiKey, model = "claude-sonnet-4-20250514", baseUrl) {
|
|
171
|
+
super();
|
|
172
|
+
this.client = new Anthropic({
|
|
173
|
+
apiKey,
|
|
174
|
+
...baseUrl ? { baseURL: baseUrl } : {}
|
|
175
|
+
});
|
|
176
|
+
this.model = model;
|
|
177
|
+
}
|
|
178
|
+
async analyze(systemPrompt, stepInstruction, screenReaderOutput, historyLedger) {
|
|
179
|
+
const userMessage = buildUserMessage(
|
|
180
|
+
stepInstruction,
|
|
181
|
+
historyLedger,
|
|
182
|
+
screenReaderOutput
|
|
183
|
+
);
|
|
184
|
+
const stream = this.client.messages.stream({
|
|
185
|
+
model: this.model,
|
|
186
|
+
max_tokens: 300,
|
|
187
|
+
temperature: 0.1,
|
|
188
|
+
system: systemPrompt,
|
|
189
|
+
messages: [{ role: "user", content: userMessage }]
|
|
190
|
+
});
|
|
191
|
+
let accumulated = "";
|
|
192
|
+
for await (const event of stream) {
|
|
193
|
+
if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
|
|
194
|
+
accumulated += event.delta.text;
|
|
195
|
+
if (this.hasCompleteJSON(accumulated)) {
|
|
196
|
+
stream.abort();
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (!accumulated.trim()) {
|
|
202
|
+
throw new Error("Anthropic returned no text content");
|
|
203
|
+
}
|
|
204
|
+
return this.parseResponse(accumulated);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// src/engine/providers/google.ts
|
|
209
|
+
import { GoogleGenerativeAI } from "@google/generative-ai";
|
|
210
|
+
var GoogleProvider = class extends BaseProvider {
|
|
211
|
+
genAI;
|
|
212
|
+
model;
|
|
213
|
+
constructor(apiKey, model = "gemini-2.0-flash") {
|
|
214
|
+
super();
|
|
215
|
+
this.genAI = new GoogleGenerativeAI(apiKey);
|
|
216
|
+
this.model = model;
|
|
217
|
+
}
|
|
218
|
+
async analyze(systemPrompt, stepInstruction, screenReaderOutput, historyLedger) {
|
|
219
|
+
const userMessage = buildUserMessage(
|
|
220
|
+
stepInstruction,
|
|
221
|
+
historyLedger,
|
|
222
|
+
screenReaderOutput
|
|
223
|
+
);
|
|
224
|
+
const generativeModel = this.genAI.getGenerativeModel({
|
|
225
|
+
model: this.model,
|
|
226
|
+
systemInstruction: systemPrompt
|
|
227
|
+
});
|
|
228
|
+
const result = await generativeModel.generateContentStream([
|
|
229
|
+
{ text: userMessage }
|
|
230
|
+
]);
|
|
231
|
+
let accumulated = "";
|
|
232
|
+
for await (const chunk of result.stream) {
|
|
233
|
+
const text = chunk.text();
|
|
234
|
+
if (text) {
|
|
235
|
+
accumulated += text;
|
|
236
|
+
if (this.hasCompleteJSON(accumulated)) {
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (!accumulated.trim()) throw new Error("Google Gemini returned empty response");
|
|
242
|
+
return this.parseResponse(accumulated);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// src/engine/providers/ollama.ts
|
|
247
|
+
var OllamaProvider = class extends BaseProvider {
|
|
248
|
+
baseUrl;
|
|
249
|
+
model;
|
|
250
|
+
constructor(model = "llava", baseUrl = "http://localhost:11434") {
|
|
251
|
+
super();
|
|
252
|
+
this.model = model;
|
|
253
|
+
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
254
|
+
}
|
|
255
|
+
async analyze(systemPrompt, stepInstruction, screenReaderOutput, historyLedger) {
|
|
256
|
+
const userMessage = buildUserMessage(
|
|
257
|
+
stepInstruction,
|
|
258
|
+
historyLedger,
|
|
259
|
+
screenReaderOutput
|
|
260
|
+
);
|
|
261
|
+
const controller = new AbortController();
|
|
262
|
+
const response = await fetch(`${this.baseUrl}/api/chat`, {
|
|
263
|
+
method: "POST",
|
|
264
|
+
headers: { "Content-Type": "application/json" },
|
|
265
|
+
signal: controller.signal,
|
|
266
|
+
body: JSON.stringify({
|
|
267
|
+
model: this.model,
|
|
268
|
+
stream: true,
|
|
269
|
+
keep_alive: "5m",
|
|
270
|
+
options: {
|
|
271
|
+
temperature: 0.1,
|
|
272
|
+
num_predict: 300
|
|
273
|
+
},
|
|
274
|
+
messages: [
|
|
275
|
+
{ role: "system", content: systemPrompt },
|
|
276
|
+
{ role: "user", content: userMessage }
|
|
277
|
+
]
|
|
278
|
+
})
|
|
279
|
+
});
|
|
280
|
+
if (!response.ok) {
|
|
281
|
+
const errText = await response.text();
|
|
282
|
+
throw new Error(`Ollama request failed (${response.status}): ${errText}`);
|
|
283
|
+
}
|
|
284
|
+
const raw = await this.streamUntilJSON(response, controller);
|
|
285
|
+
if (!raw) throw new Error("Ollama returned empty response");
|
|
286
|
+
return this.parseResponse(raw);
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Reads the streaming response token-by-token.
|
|
290
|
+
* As soon as the accumulated text contains a complete JSON object
|
|
291
|
+
* (balanced braces), we abort the connection and return immediately.
|
|
292
|
+
* This saves significant inference time on trailing tokens.
|
|
293
|
+
*/
|
|
294
|
+
async streamUntilJSON(response, controller) {
|
|
295
|
+
const reader = response.body?.getReader();
|
|
296
|
+
if (!reader) throw new Error("No response body from Ollama");
|
|
297
|
+
const decoder = new TextDecoder();
|
|
298
|
+
let accumulated = "";
|
|
299
|
+
let braceDepth = 0;
|
|
300
|
+
let inString = false;
|
|
301
|
+
let escape = false;
|
|
302
|
+
let jsonStarted = false;
|
|
303
|
+
try {
|
|
304
|
+
while (true) {
|
|
305
|
+
const { done, value } = await reader.read();
|
|
306
|
+
if (done) break;
|
|
307
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
308
|
+
const lines = chunk.split("\n").filter((l) => l.trim());
|
|
309
|
+
for (const line of lines) {
|
|
310
|
+
try {
|
|
311
|
+
const data = JSON.parse(line);
|
|
312
|
+
const token = data.message?.content ?? "";
|
|
313
|
+
accumulated += token;
|
|
314
|
+
for (const ch of token) {
|
|
315
|
+
if (escape) {
|
|
316
|
+
escape = false;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (ch === "\\" && inString) {
|
|
320
|
+
escape = true;
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (ch === '"') {
|
|
324
|
+
inString = !inString;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (inString) continue;
|
|
328
|
+
if (ch === "{") {
|
|
329
|
+
braceDepth++;
|
|
330
|
+
jsonStarted = true;
|
|
331
|
+
} else if (ch === "}") {
|
|
332
|
+
braceDepth--;
|
|
333
|
+
if (jsonStarted && braceDepth === 0) {
|
|
334
|
+
controller.abort();
|
|
335
|
+
return accumulated;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if (data.done) {
|
|
340
|
+
return accumulated;
|
|
341
|
+
}
|
|
342
|
+
} catch {
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
} catch (err) {
|
|
347
|
+
if (err?.name === "AbortError") {
|
|
348
|
+
return accumulated;
|
|
349
|
+
}
|
|
350
|
+
if (accumulated.trim()) return accumulated;
|
|
351
|
+
throw err;
|
|
352
|
+
} finally {
|
|
353
|
+
reader.releaseLock();
|
|
354
|
+
}
|
|
355
|
+
return accumulated;
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// src/engine/vision.ts
|
|
360
|
+
function createProvider(config) {
|
|
361
|
+
const providers = {
|
|
362
|
+
openai: () => {
|
|
363
|
+
if (!config.apiKey)
|
|
364
|
+
throw new Error(
|
|
365
|
+
"OpenAI API key is required. Set VOUCH_API_KEY or provider.apiKey in config."
|
|
366
|
+
);
|
|
367
|
+
return new OpenAIProvider(config.apiKey, config.model, config.baseUrl);
|
|
368
|
+
},
|
|
369
|
+
anthropic: () => {
|
|
370
|
+
if (!config.apiKey)
|
|
371
|
+
throw new Error(
|
|
372
|
+
"Anthropic API key is required. Set VOUCH_API_KEY or provider.apiKey in config."
|
|
373
|
+
);
|
|
374
|
+
return new AnthropicProvider(config.apiKey, config.model, config.baseUrl);
|
|
375
|
+
},
|
|
376
|
+
google: () => {
|
|
377
|
+
if (!config.apiKey)
|
|
378
|
+
throw new Error(
|
|
379
|
+
"Google API key is required. Set VOUCH_API_KEY or provider.apiKey in config."
|
|
380
|
+
);
|
|
381
|
+
return new GoogleProvider(config.apiKey, config.model);
|
|
382
|
+
},
|
|
383
|
+
ollama: () => {
|
|
384
|
+
return new OllamaProvider(
|
|
385
|
+
config.model,
|
|
386
|
+
config.baseUrl || "http://localhost:11434"
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
const factory = providers[config.provider];
|
|
391
|
+
if (!factory) {
|
|
392
|
+
throw new Error(
|
|
393
|
+
`Unknown AI provider: "${config.provider}". Supported: openai, anthropic, google, ollama`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
return factory();
|
|
397
|
+
}
|
|
398
|
+
var VisionQAEngine = class {
|
|
399
|
+
provider;
|
|
400
|
+
config;
|
|
401
|
+
constructor(config) {
|
|
402
|
+
this.config = config;
|
|
403
|
+
this.provider = createProvider(config);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Analyze the page using screen reader output and step instruction.
|
|
407
|
+
*/
|
|
408
|
+
async analyze(stepInstruction, screenReaderOutput, historyLedger) {
|
|
409
|
+
return this.provider.analyze(
|
|
410
|
+
VISION_QA_SYSTEM_PROMPT,
|
|
411
|
+
stepInstruction,
|
|
412
|
+
screenReaderOutput,
|
|
413
|
+
historyLedger
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Convert normalized coordinates (0-1000) to pixel coordinates.
|
|
418
|
+
*/
|
|
419
|
+
toPixelCoords(normalizedX, normalizedY, viewportWidth, viewportHeight) {
|
|
420
|
+
return {
|
|
421
|
+
pixelX: Math.round(normalizedX / 1e3 * viewportWidth),
|
|
422
|
+
pixelY: Math.round(normalizedY / 1e3 * viewportHeight)
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Checks if the response indicates a terminal action.
|
|
427
|
+
*/
|
|
428
|
+
isTerminal(response) {
|
|
429
|
+
return TERMINAL_ACTIONS.includes(response.action);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Checks if the response contains a validation error.
|
|
433
|
+
*/
|
|
434
|
+
hasValidationError(response) {
|
|
435
|
+
return response.detectedValidationError.length > 0;
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
// src/browser/controller.ts
|
|
440
|
+
import puppeteer from "puppeteer";
|
|
441
|
+
import { PuppeteerScreenRecorder } from "puppeteer-screen-recorder";
|
|
442
|
+
import * as fs from "fs";
|
|
443
|
+
import * as path from "path";
|
|
444
|
+
var SKIP_ROLES = /* @__PURE__ */ new Set([
|
|
445
|
+
"none",
|
|
446
|
+
"generic",
|
|
447
|
+
"InlineTextBox",
|
|
448
|
+
"LineBreak",
|
|
449
|
+
"paragraph",
|
|
450
|
+
"Section",
|
|
451
|
+
"group",
|
|
452
|
+
"document",
|
|
453
|
+
"WebArea",
|
|
454
|
+
"main",
|
|
455
|
+
"navigation",
|
|
456
|
+
"banner",
|
|
457
|
+
"contentinfo",
|
|
458
|
+
"complementary",
|
|
459
|
+
"list",
|
|
460
|
+
"listitem",
|
|
461
|
+
"StaticText",
|
|
462
|
+
"rootWebArea"
|
|
463
|
+
]);
|
|
464
|
+
var BrowserController = class {
|
|
465
|
+
browser = null;
|
|
466
|
+
page = null;
|
|
467
|
+
cdpClient = null;
|
|
468
|
+
config;
|
|
469
|
+
recorder = null;
|
|
470
|
+
videoPath = null;
|
|
471
|
+
constructor(config) {
|
|
472
|
+
this.config = config;
|
|
473
|
+
}
|
|
474
|
+
async launch() {
|
|
475
|
+
this.browser = await puppeteer.launch({
|
|
476
|
+
headless: this.config.headless,
|
|
477
|
+
defaultViewport: {
|
|
478
|
+
width: this.config.viewportWidth,
|
|
479
|
+
height: this.config.viewportHeight
|
|
480
|
+
},
|
|
481
|
+
args: [
|
|
482
|
+
"--no-sandbox",
|
|
483
|
+
"--disable-setuid-sandbox",
|
|
484
|
+
"--disable-dev-shm-usage",
|
|
485
|
+
"--disable-extensions",
|
|
486
|
+
"--disable-background-networking",
|
|
487
|
+
"--disable-sync",
|
|
488
|
+
"--disable-translate",
|
|
489
|
+
"--metrics-recording-only",
|
|
490
|
+
"--no-first-run",
|
|
491
|
+
`--window-size=${this.config.viewportWidth},${this.config.viewportHeight}`
|
|
492
|
+
]
|
|
493
|
+
});
|
|
494
|
+
const pages = await this.browser.pages();
|
|
495
|
+
this.page = pages[0] || await this.browser.newPage();
|
|
496
|
+
this.page.setDefaultNavigationTimeout(this.config.stepTimeout);
|
|
497
|
+
this.page.setDefaultTimeout(this.config.stepTimeout);
|
|
498
|
+
this.cdpClient = await this.page.createCDPSession();
|
|
499
|
+
if (this.config.recordVideo) {
|
|
500
|
+
if (!fs.existsSync(this.config.videoDir)) {
|
|
501
|
+
fs.mkdirSync(this.config.videoDir, { recursive: true });
|
|
502
|
+
}
|
|
503
|
+
this.videoPath = path.join(
|
|
504
|
+
this.config.videoDir,
|
|
505
|
+
`vouch-recording-${Date.now()}.mp4`
|
|
506
|
+
);
|
|
507
|
+
this.recorder = new PuppeteerScreenRecorder(this.page);
|
|
508
|
+
await this.recorder.start(this.videoPath);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
async close() {
|
|
512
|
+
if (this.recorder) {
|
|
513
|
+
try {
|
|
514
|
+
await this.recorder.stop();
|
|
515
|
+
} catch {
|
|
516
|
+
}
|
|
517
|
+
this.recorder = null;
|
|
518
|
+
}
|
|
519
|
+
if (this.cdpClient) {
|
|
520
|
+
try {
|
|
521
|
+
await this.cdpClient.detach();
|
|
522
|
+
} catch {
|
|
523
|
+
}
|
|
524
|
+
this.cdpClient = null;
|
|
525
|
+
}
|
|
526
|
+
if (this.browser) {
|
|
527
|
+
try {
|
|
528
|
+
await this.browser.close();
|
|
529
|
+
} catch {
|
|
530
|
+
}
|
|
531
|
+
this.browser = null;
|
|
532
|
+
this.page = null;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
getVideoPath() {
|
|
536
|
+
return this.videoPath;
|
|
537
|
+
}
|
|
538
|
+
async navigate(url) {
|
|
539
|
+
this.assertPage();
|
|
540
|
+
await this.page.goto(url, { waitUntil: "domcontentloaded" });
|
|
541
|
+
await this.sleep(150);
|
|
542
|
+
}
|
|
543
|
+
async click(pixelX, pixelY) {
|
|
544
|
+
this.assertPage();
|
|
545
|
+
await this.page.mouse.click(pixelX, pixelY);
|
|
546
|
+
await this.sleep(50);
|
|
547
|
+
}
|
|
548
|
+
async type(pixelX, pixelY, text) {
|
|
549
|
+
this.assertPage();
|
|
550
|
+
await this.page.mouse.click(pixelX, pixelY, { count: 3 });
|
|
551
|
+
await this.sleep(50);
|
|
552
|
+
await this.page.keyboard.press("Backspace");
|
|
553
|
+
await this.page.keyboard.type(text, { delay: 0 });
|
|
554
|
+
await this.sleep(50);
|
|
555
|
+
}
|
|
556
|
+
async wait(ms) {
|
|
557
|
+
await this.sleep(ms);
|
|
558
|
+
}
|
|
559
|
+
getViewportSize() {
|
|
560
|
+
return {
|
|
561
|
+
width: this.config.viewportWidth,
|
|
562
|
+
height: this.config.viewportHeight
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
getPage() {
|
|
566
|
+
this.assertPage();
|
|
567
|
+
return this.page;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Uses Chrome DevTools Protocol Accessibility API to read the page
|
|
571
|
+
* like a screen reader. Returns a textual description of all visible
|
|
572
|
+
* interactive elements with their normalized (0-1000) coordinates.
|
|
573
|
+
*
|
|
574
|
+
* Performance: reuses persistent CDP session and batches box model lookups.
|
|
575
|
+
*/
|
|
576
|
+
async getScreenReaderOutput() {
|
|
577
|
+
this.assertPage();
|
|
578
|
+
const client = this.cdpClient;
|
|
579
|
+
const { nodes } = await client.send("Accessibility.getFullAXTree");
|
|
580
|
+
const { width: vw, height: vh } = this.getViewportSize();
|
|
581
|
+
const skipRoles = SKIP_ROLES;
|
|
582
|
+
const relevant = [];
|
|
583
|
+
for (const node of nodes) {
|
|
584
|
+
const role = node.role?.value;
|
|
585
|
+
const name = node.name?.value?.trim();
|
|
586
|
+
if (!role || skipRoles.has(role) || !name || name === "") continue;
|
|
587
|
+
const backendNodeId = node.backendDOMNodeId;
|
|
588
|
+
if (!backendNodeId) continue;
|
|
589
|
+
relevant.push({ role, name, value: node.value?.value, backendNodeId });
|
|
590
|
+
}
|
|
591
|
+
const boxPromises = relevant.map(
|
|
592
|
+
(n) => client.send("DOM.getBoxModel", { backendNodeId: n.backendNodeId }).then((r) => r.model).catch(() => null)
|
|
593
|
+
);
|
|
594
|
+
const boxes = await Promise.allSettled(boxPromises);
|
|
595
|
+
const results = [];
|
|
596
|
+
for (let i = 0; i < relevant.length; i++) {
|
|
597
|
+
const settled = boxes[i];
|
|
598
|
+
if (settled.status !== "fulfilled" || !settled.value) continue;
|
|
599
|
+
const model = settled.value;
|
|
600
|
+
const quad = model.content;
|
|
601
|
+
const centerX = (quad[0] + quad[4]) / 2;
|
|
602
|
+
const centerY = (quad[1] + quad[5]) / 2;
|
|
603
|
+
if (centerX < 0 || centerY < 0 || centerX > vw || centerY > vh) continue;
|
|
604
|
+
const normX = Math.round(centerX / vw * 1e3);
|
|
605
|
+
const normY = Math.round(centerY / vh * 1e3);
|
|
606
|
+
const n = relevant[i];
|
|
607
|
+
let desc = `${n.role} "${n.name}"`;
|
|
608
|
+
if (n.value) desc += ` v="${n.value}"`;
|
|
609
|
+
desc += ` @${normX},${normY}`;
|
|
610
|
+
results.push(desc);
|
|
611
|
+
}
|
|
612
|
+
return results.length > 0 ? "UI:\n" + results.join("\n") : "UI: empty";
|
|
613
|
+
}
|
|
614
|
+
assertPage() {
|
|
615
|
+
if (!this.page) {
|
|
616
|
+
throw new Error("Browser not launched. Call launch() first.");
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
sleep(ms) {
|
|
620
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
// src/actions/coordinator.ts
|
|
625
|
+
var ActionCoordinator = class {
|
|
626
|
+
engine;
|
|
627
|
+
browser;
|
|
628
|
+
config;
|
|
629
|
+
constructor(engine, browser, config) {
|
|
630
|
+
this.engine = engine;
|
|
631
|
+
this.browser = browser;
|
|
632
|
+
this.config = config;
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Execute a single test step with self-healing retries.
|
|
636
|
+
*/
|
|
637
|
+
async executeStep(step, isLastStep) {
|
|
638
|
+
const startTime = Date.now();
|
|
639
|
+
const history = [];
|
|
640
|
+
if (step.type === "comment") {
|
|
641
|
+
return { step, status: "skipped", duration: 0, attempts: [] };
|
|
642
|
+
}
|
|
643
|
+
if (step.type === "navigate") {
|
|
644
|
+
try {
|
|
645
|
+
const url = step.meta?.url ?? step.instruction;
|
|
646
|
+
await this.browser.navigate(url);
|
|
647
|
+
return {
|
|
648
|
+
step,
|
|
649
|
+
status: "passed",
|
|
650
|
+
duration: Date.now() - startTime,
|
|
651
|
+
attempts: []
|
|
652
|
+
};
|
|
653
|
+
} catch (err) {
|
|
654
|
+
return {
|
|
655
|
+
step,
|
|
656
|
+
status: "failed",
|
|
657
|
+
duration: Date.now() - startTime,
|
|
658
|
+
attempts: [],
|
|
659
|
+
error: err instanceof Error ? err.message : String(err)
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (step.type === "wait") {
|
|
664
|
+
const ms = parseInt(step.meta?.duration ?? "2000", 10);
|
|
665
|
+
await this.browser.wait(ms);
|
|
666
|
+
return {
|
|
667
|
+
step,
|
|
668
|
+
status: "passed",
|
|
669
|
+
duration: Date.now() - startTime,
|
|
670
|
+
attempts: []
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
for (let attempt = 1; attempt <= this.config.maxRetries; attempt++) {
|
|
674
|
+
try {
|
|
675
|
+
const screenReader = await this.browser.getScreenReaderOutput();
|
|
676
|
+
const instruction = step.type === "assert" ? `VERIFY: "${step.instruction}". If confirmed true/visible, respond "complete". If not, respond "fail" with reasoning.` : step.instruction;
|
|
677
|
+
const response = await this.engine.analyze(
|
|
678
|
+
instruction,
|
|
679
|
+
screenReader,
|
|
680
|
+
history
|
|
681
|
+
);
|
|
682
|
+
const entry = {
|
|
683
|
+
attempt,
|
|
684
|
+
action: response.action,
|
|
685
|
+
x: response.x,
|
|
686
|
+
y: response.y,
|
|
687
|
+
textPayload: response.textPayload || void 0,
|
|
688
|
+
timestamp: Date.now(),
|
|
689
|
+
success: false,
|
|
690
|
+
detectedValidationError: response.detectedValidationError || void 0
|
|
691
|
+
};
|
|
692
|
+
if (response.action === "complete") {
|
|
693
|
+
entry.success = true;
|
|
694
|
+
history.push(entry);
|
|
695
|
+
return {
|
|
696
|
+
step,
|
|
697
|
+
status: "passed",
|
|
698
|
+
duration: Date.now() - startTime,
|
|
699
|
+
attempts: history
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
if (response.action === "fail") {
|
|
703
|
+
entry.success = false;
|
|
704
|
+
entry.error = response.reasoning;
|
|
705
|
+
history.push(entry);
|
|
706
|
+
if (attempt < this.config.maxRetries) {
|
|
707
|
+
await this.browser.wait(this.config.actionDelay);
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
return {
|
|
711
|
+
step,
|
|
712
|
+
status: "failed",
|
|
713
|
+
duration: Date.now() - startTime,
|
|
714
|
+
attempts: history,
|
|
715
|
+
error: response.reasoning
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
if (step.type === "assert") {
|
|
719
|
+
entry.success = false;
|
|
720
|
+
entry.error = `Invalid action for assert step: "${response.action}". You must respond "complete" or "fail".`;
|
|
721
|
+
history.push(entry);
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
await this.dispatchAction(response);
|
|
725
|
+
await this.browser.wait(this.config.actionDelay);
|
|
726
|
+
if (response.detectedValidationError) {
|
|
727
|
+
entry.success = false;
|
|
728
|
+
entry.error = `Validation error: ${response.detectedValidationError}`;
|
|
729
|
+
history.push(entry);
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (response.action === "wait") {
|
|
733
|
+
entry.success = true;
|
|
734
|
+
history.push(entry);
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
entry.success = true;
|
|
738
|
+
history.push(entry);
|
|
739
|
+
return {
|
|
740
|
+
step,
|
|
741
|
+
status: "passed",
|
|
742
|
+
duration: Date.now() - startTime,
|
|
743
|
+
attempts: history
|
|
744
|
+
};
|
|
745
|
+
} catch (err) {
|
|
746
|
+
history.push({
|
|
747
|
+
attempt,
|
|
748
|
+
action: "fail",
|
|
749
|
+
x: 0,
|
|
750
|
+
y: 0,
|
|
751
|
+
timestamp: Date.now(),
|
|
752
|
+
success: false,
|
|
753
|
+
error: err instanceof Error ? err.message : String(err)
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return {
|
|
758
|
+
step,
|
|
759
|
+
status: "failed",
|
|
760
|
+
duration: Date.now() - startTime,
|
|
761
|
+
attempts: history,
|
|
762
|
+
error: `Step failed after ${this.config.maxRetries} attempts. Last errors: ${history.filter((h) => h.error).map((h) => h.error).join(" | ")}`
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Dispatch any action to the browser — extensible, not a fixed set.
|
|
767
|
+
*/
|
|
768
|
+
async dispatchAction(response) {
|
|
769
|
+
const { width, height } = this.browser.getViewportSize();
|
|
770
|
+
const { pixelX, pixelY } = this.engine.toPixelCoords(
|
|
771
|
+
response.x,
|
|
772
|
+
response.y,
|
|
773
|
+
width,
|
|
774
|
+
height
|
|
775
|
+
);
|
|
776
|
+
const page = this.browser.getPage();
|
|
777
|
+
const elementHandle = await page.evaluateHandle((x, y) => document.elementFromPoint(x, y), pixelX, pixelY).catch(() => null);
|
|
778
|
+
switch (response.action) {
|
|
779
|
+
case "click":
|
|
780
|
+
await this.browser.click(pixelX, pixelY);
|
|
781
|
+
break;
|
|
782
|
+
case "type":
|
|
783
|
+
await this.browser.type(pixelX, pixelY, response.textPayload);
|
|
784
|
+
break;
|
|
785
|
+
case "wait":
|
|
786
|
+
await this.browser.wait(2e3);
|
|
787
|
+
break;
|
|
788
|
+
case "scroll":
|
|
789
|
+
await page.mouse.wheel({ deltaY: response.y > 500 ? 300 : -300 });
|
|
790
|
+
break;
|
|
791
|
+
case "hover":
|
|
792
|
+
await page.mouse.move(pixelX, pixelY);
|
|
793
|
+
break;
|
|
794
|
+
case "keypress":
|
|
795
|
+
await page.keyboard.press(response.textPayload);
|
|
796
|
+
break;
|
|
797
|
+
case "select":
|
|
798
|
+
await this.browser.click(pixelX, pixelY);
|
|
799
|
+
break;
|
|
800
|
+
case "upload":
|
|
801
|
+
if (elementHandle && response.textPayload) {
|
|
802
|
+
const el = elementHandle.asElement();
|
|
803
|
+
if (el) {
|
|
804
|
+
await el.uploadFile(response.textPayload);
|
|
805
|
+
} else {
|
|
806
|
+
await this.browser.click(pixelX, pixelY);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
break;
|
|
810
|
+
default:
|
|
811
|
+
await this.browser.click(pixelX, pixelY);
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
if (elementHandle) {
|
|
815
|
+
await elementHandle.dispose().catch(() => {
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
// src/parser/vch-parser.ts
|
|
822
|
+
import * as fs2 from "fs";
|
|
823
|
+
import * as path2 from "path";
|
|
824
|
+
function parseVchFile(filePath) {
|
|
825
|
+
const absolutePath = path2.resolve(filePath);
|
|
826
|
+
if (!fs2.existsSync(absolutePath)) {
|
|
827
|
+
throw new Error(`Test file not found: ${absolutePath}`);
|
|
828
|
+
}
|
|
829
|
+
const content = fs2.readFileSync(absolutePath, "utf-8");
|
|
830
|
+
const lines = content.split("\n");
|
|
831
|
+
let suiteName = path2.basename(filePath, ".vch");
|
|
832
|
+
const steps = [];
|
|
833
|
+
for (let i = 0; i < lines.length; i++) {
|
|
834
|
+
const raw = lines[i];
|
|
835
|
+
const trimmed = raw.trim();
|
|
836
|
+
const lineNumber = i + 1;
|
|
837
|
+
if (!trimmed) continue;
|
|
838
|
+
if (trimmed.startsWith("#")) {
|
|
839
|
+
steps.push({
|
|
840
|
+
lineNumber,
|
|
841
|
+
raw,
|
|
842
|
+
instruction: trimmed.slice(1).trim(),
|
|
843
|
+
type: "comment"
|
|
844
|
+
});
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
if (trimmed.startsWith(">")) {
|
|
848
|
+
const metaContent = trimmed.slice(1).trim();
|
|
849
|
+
const colonIndex = metaContent.indexOf(":");
|
|
850
|
+
if (colonIndex > 0) {
|
|
851
|
+
const key = metaContent.slice(0, colonIndex).trim().toLowerCase();
|
|
852
|
+
const value = metaContent.slice(colonIndex + 1).trim();
|
|
853
|
+
if (key === "name") {
|
|
854
|
+
suiteName = value;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
continue;
|
|
858
|
+
}
|
|
859
|
+
if (trimmed.startsWith("@navigate")) {
|
|
860
|
+
const url = trimmed.replace(/^@navigate\s+/, "").trim();
|
|
861
|
+
steps.push({
|
|
862
|
+
lineNumber,
|
|
863
|
+
raw,
|
|
864
|
+
instruction: url,
|
|
865
|
+
type: "navigate",
|
|
866
|
+
meta: { url }
|
|
867
|
+
});
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
if (trimmed.startsWith("@wait")) {
|
|
871
|
+
const ms = trimmed.replace(/^@wait\s+/, "").trim();
|
|
872
|
+
steps.push({
|
|
873
|
+
lineNumber,
|
|
874
|
+
raw,
|
|
875
|
+
instruction: `Wait for ${ms}ms`,
|
|
876
|
+
type: "wait",
|
|
877
|
+
meta: { duration: ms }
|
|
878
|
+
});
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
if (trimmed.startsWith("@assert")) {
|
|
882
|
+
const assertion = trimmed.replace(/^@assert\s+/, "").trim();
|
|
883
|
+
steps.push({
|
|
884
|
+
lineNumber,
|
|
885
|
+
raw,
|
|
886
|
+
instruction: assertion,
|
|
887
|
+
type: "assert"
|
|
888
|
+
});
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
if (trimmed.startsWith("@if")) {
|
|
892
|
+
const condition = trimmed.replace(/^@if\s+/, "").trim();
|
|
893
|
+
steps.push({
|
|
894
|
+
lineNumber,
|
|
895
|
+
raw,
|
|
896
|
+
instruction: condition,
|
|
897
|
+
type: "conditional"
|
|
898
|
+
});
|
|
899
|
+
continue;
|
|
900
|
+
}
|
|
901
|
+
steps.push({
|
|
902
|
+
lineNumber,
|
|
903
|
+
raw,
|
|
904
|
+
instruction: trimmed,
|
|
905
|
+
type: "action"
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
return {
|
|
909
|
+
name: suiteName,
|
|
910
|
+
filePath: absolutePath,
|
|
911
|
+
steps
|
|
912
|
+
};
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// src/reporter/json-reporter.ts
|
|
916
|
+
import * as fs3 from "fs";
|
|
917
|
+
import * as path3 from "path";
|
|
918
|
+
function generateJSONReport(result, reportDir) {
|
|
919
|
+
if (!fs3.existsSync(reportDir)) {
|
|
920
|
+
fs3.mkdirSync(reportDir, { recursive: true });
|
|
921
|
+
}
|
|
922
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
923
|
+
const filename = `vouch-report-${timestamp}.json`;
|
|
924
|
+
const filePath = path3.join(reportDir, filename);
|
|
925
|
+
const reportData = JSON.stringify(result, null, 2);
|
|
926
|
+
fs3.writeFileSync(filePath, reportData, "utf-8");
|
|
927
|
+
return filePath;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// src/cli/runner.ts
|
|
931
|
+
function loadConfig(overrides = {}) {
|
|
932
|
+
let fileConfig = {};
|
|
933
|
+
const configPath = path4.resolve("vouch.config.json");
|
|
934
|
+
if (fs4.existsSync(configPath)) {
|
|
935
|
+
try {
|
|
936
|
+
const raw = fs4.readFileSync(configPath, "utf-8");
|
|
937
|
+
fileConfig = JSON.parse(raw);
|
|
938
|
+
} catch {
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
const envConfig = {};
|
|
942
|
+
if (process.env.VOUCH_PROVIDER)
|
|
943
|
+
envConfig.provider = process.env.VOUCH_PROVIDER;
|
|
944
|
+
if (process.env.VOUCH_MODEL) envConfig.model = process.env.VOUCH_MODEL;
|
|
945
|
+
if (process.env.VOUCH_API_KEY) envConfig.apiKey = process.env.VOUCH_API_KEY;
|
|
946
|
+
if (process.env.VOUCH_BASE_URL)
|
|
947
|
+
envConfig.baseUrl = process.env.VOUCH_BASE_URL;
|
|
948
|
+
if (process.env.VOUCH_HEADLESS)
|
|
949
|
+
envConfig.headless = process.env.VOUCH_HEADLESS === "true";
|
|
950
|
+
return {
|
|
951
|
+
...DEFAULT_CONFIG,
|
|
952
|
+
...fileConfig,
|
|
953
|
+
...envConfig,
|
|
954
|
+
...overrides
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
async function runTestFile(filePath, config, logger) {
|
|
958
|
+
const suite = parseVchFile(filePath);
|
|
959
|
+
logger.suiteStart(suite);
|
|
960
|
+
const engine = new VisionQAEngine(config);
|
|
961
|
+
const browser = new BrowserController(config);
|
|
962
|
+
const coordinator = new ActionCoordinator(engine, browser, config);
|
|
963
|
+
const result = {
|
|
964
|
+
suite,
|
|
965
|
+
results: [],
|
|
966
|
+
startTime: Date.now(),
|
|
967
|
+
endTime: 0,
|
|
968
|
+
totalPassed: 0,
|
|
969
|
+
totalFailed: 0,
|
|
970
|
+
totalSkipped: 0
|
|
971
|
+
};
|
|
972
|
+
try {
|
|
973
|
+
logger.info("Launching browser...");
|
|
974
|
+
await browser.launch();
|
|
975
|
+
logger.info("Browser ready.");
|
|
976
|
+
const actionSteps = suite.steps.filter((s) => s.type !== "comment");
|
|
977
|
+
for (let i = 0; i < suite.steps.length; i++) {
|
|
978
|
+
const step = suite.steps[i];
|
|
979
|
+
const isLastActionStep = step === actionSteps[actionSteps.length - 1];
|
|
980
|
+
logger.stepStart(step);
|
|
981
|
+
const stepResult = await coordinator.executeStep(step, isLastActionStep);
|
|
982
|
+
result.results.push(stepResult);
|
|
983
|
+
switch (stepResult.status) {
|
|
984
|
+
case "passed":
|
|
985
|
+
result.totalPassed++;
|
|
986
|
+
break;
|
|
987
|
+
case "failed":
|
|
988
|
+
result.totalFailed++;
|
|
989
|
+
break;
|
|
990
|
+
case "skipped":
|
|
991
|
+
result.totalSkipped++;
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
logger.stepEnd(stepResult);
|
|
995
|
+
}
|
|
996
|
+
} catch (err) {
|
|
997
|
+
logger.error(
|
|
998
|
+
`Fatal error: ${err instanceof Error ? err.message : String(err)}`
|
|
999
|
+
);
|
|
1000
|
+
} finally {
|
|
1001
|
+
await browser.close();
|
|
1002
|
+
logger.info("Browser closed.");
|
|
1003
|
+
const video = browser.getVideoPath();
|
|
1004
|
+
if (video) {
|
|
1005
|
+
logger.info(`Video saved: ${path4.resolve(video)}`);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
result.endTime = Date.now();
|
|
1009
|
+
if (config.report) {
|
|
1010
|
+
const reportFile = generateJSONReport(result, config.reportDir);
|
|
1011
|
+
logger.info(`Report generated: ${path4.resolve(reportFile)}`);
|
|
1012
|
+
}
|
|
1013
|
+
logger.suiteEnd(result);
|
|
1014
|
+
return result;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// src/cli/init.ts
|
|
1018
|
+
import * as fs5 from "fs";
|
|
1019
|
+
import * as path5 from "path";
|
|
1020
|
+
async function initProject(quiet = false) {
|
|
1021
|
+
let chalk2 = void 0;
|
|
1022
|
+
let figures2 = void 0;
|
|
1023
|
+
let p = null;
|
|
1024
|
+
try {
|
|
1025
|
+
chalk2 = await import("chalk");
|
|
1026
|
+
figures2 = await import("figures");
|
|
1027
|
+
if (!quiet) {
|
|
1028
|
+
p = await import("@clack/prompts");
|
|
1029
|
+
}
|
|
1030
|
+
} catch (e) {
|
|
1031
|
+
}
|
|
1032
|
+
const c = chalk2?.default ?? chalk2;
|
|
1033
|
+
const f = figures2?.default ?? figures2;
|
|
1034
|
+
const configContent = JSON.stringify(DEFAULT_CONFIG, null, 2);
|
|
1035
|
+
if (!fs5.existsSync("vouch.config.json")) {
|
|
1036
|
+
fs5.writeFileSync("vouch.config.json", configContent, "utf-8");
|
|
1037
|
+
if (p) p.log.success("Created vouch.config.json");
|
|
1038
|
+
else if (!quiet && c && f)
|
|
1039
|
+
console.log(c.green(` ${f.tick} Created vouch.config.json`));
|
|
1040
|
+
} else {
|
|
1041
|
+
if (!quiet && c && f)
|
|
1042
|
+
console.log(
|
|
1043
|
+
c.yellow(` ${f.warning} vouch.config.json already exists, skipping.`)
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
const exampleDir = "examples";
|
|
1047
|
+
if (!fs5.existsSync(exampleDir)) {
|
|
1048
|
+
fs5.mkdirSync(exampleDir, { recursive: true });
|
|
1049
|
+
}
|
|
1050
|
+
const exampleTest = `> name: Example Login Flow
|
|
1051
|
+
# This is an example Vouch test file
|
|
1052
|
+
|
|
1053
|
+
@navigate https://example.com/login
|
|
1054
|
+
|
|
1055
|
+
click on the email input field
|
|
1056
|
+
type test@example.com into the email field
|
|
1057
|
+
click on the password input field
|
|
1058
|
+
type SecurePassword123! into the password field
|
|
1059
|
+
|
|
1060
|
+
click the Sign In button
|
|
1061
|
+
|
|
1062
|
+
@assert Dashboard heading is visible
|
|
1063
|
+
`;
|
|
1064
|
+
const examplePath = path5.join(exampleDir, "demo.vch");
|
|
1065
|
+
if (!fs5.existsSync(examplePath)) {
|
|
1066
|
+
fs5.writeFileSync(examplePath, exampleTest, "utf-8");
|
|
1067
|
+
if (p) p.log.success(`Created ${examplePath}`);
|
|
1068
|
+
else if (!quiet && c && f)
|
|
1069
|
+
console.log(c.green(` ${f.tick} Created ${examplePath}`));
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// src/cli/interactive.ts
|
|
1074
|
+
import * as fs6 from "fs";
|
|
1075
|
+
import * as path6 from "path";
|
|
1076
|
+
|
|
1077
|
+
// src/cli/logger.ts
|
|
1078
|
+
var chalk;
|
|
1079
|
+
var figures;
|
|
1080
|
+
var ora;
|
|
1081
|
+
async function loadLoggerDeps() {
|
|
1082
|
+
chalk = await import("chalk");
|
|
1083
|
+
figures = await import("figures");
|
|
1084
|
+
ora = await import("ora");
|
|
1085
|
+
}
|
|
1086
|
+
function createLogger() {
|
|
1087
|
+
const c = chalk?.default ?? chalk;
|
|
1088
|
+
const f = figures?.default ?? figures;
|
|
1089
|
+
const o = ora?.default ?? ora;
|
|
1090
|
+
let currentSpinner = null;
|
|
1091
|
+
return {
|
|
1092
|
+
info(msg) {
|
|
1093
|
+
if (currentSpinner) {
|
|
1094
|
+
currentSpinner.info(c.dim(msg));
|
|
1095
|
+
currentSpinner.start();
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
if (c && f) console.log(c.dim(` ${f.pointerSmall} ${msg}`));
|
|
1099
|
+
else console.log(` > ${msg}`);
|
|
1100
|
+
},
|
|
1101
|
+
error(msg) {
|
|
1102
|
+
if (currentSpinner) {
|
|
1103
|
+
currentSpinner.fail(c.red(msg));
|
|
1104
|
+
currentSpinner = null;
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (c && f) console.log(c.red(` ${f.cross} ${msg} `));
|
|
1108
|
+
else console.error(` X ${msg}`);
|
|
1109
|
+
},
|
|
1110
|
+
suiteStart(suite) {
|
|
1111
|
+
console.log();
|
|
1112
|
+
if (c) {
|
|
1113
|
+
console.log(c.bold.green(` \u{1F578}\uFE0F ${suite.name}`));
|
|
1114
|
+
console.log(c.dim(` ${suite.filePath}`));
|
|
1115
|
+
console.log(
|
|
1116
|
+
c.dim(
|
|
1117
|
+
` ${suite.steps.filter((s) => s.type !== "comment").length} steps`
|
|
1118
|
+
)
|
|
1119
|
+
);
|
|
1120
|
+
} else {
|
|
1121
|
+
console.log(` \u{1F578}\uFE0F ${suite.name}
|
|
1122
|
+
${suite.filePath}`);
|
|
1123
|
+
}
|
|
1124
|
+
console.log();
|
|
1125
|
+
},
|
|
1126
|
+
suiteEnd(result) {
|
|
1127
|
+
const duration = ((result.endTime - result.startTime) / 1e3).toFixed(2);
|
|
1128
|
+
console.log();
|
|
1129
|
+
if (c && f) {
|
|
1130
|
+
console.log(c.dim(" \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"));
|
|
1131
|
+
if (result.totalFailed === 0) {
|
|
1132
|
+
console.log(
|
|
1133
|
+
c.bold.green(` ${f.tick} All ${result.totalPassed} steps passed`) + c.dim(` (${duration}s)`)
|
|
1134
|
+
);
|
|
1135
|
+
} else {
|
|
1136
|
+
console.log(
|
|
1137
|
+
c.bold.green(` ${f.cross} ${result.totalFailed} failed`) + c.dim(` | `) + c.green(`${result.totalPassed} passed`) + c.dim(` (${duration}s)`)
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
} else {
|
|
1141
|
+
console.log(
|
|
1142
|
+
` ${result.totalFailed === 0 ? "Passed" : "Failed"}: ${result.totalPassed} passed, ${result.totalFailed} failed (${duration}s)`
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
console.log();
|
|
1146
|
+
},
|
|
1147
|
+
stepStart(step) {
|
|
1148
|
+
if (step.type === "comment") return;
|
|
1149
|
+
if (c && o) {
|
|
1150
|
+
const prefix = step.type === "navigate" ? "\u{1F310}" : step.type === "assert" ? "\u{1F50D}" : step.type === "wait" ? "\u23F3" : "\u{1F3AF}";
|
|
1151
|
+
currentSpinner = o({
|
|
1152
|
+
text: c.dim(`${prefix} L${step.lineNumber}: `) + c.green(step.instruction),
|
|
1153
|
+
color: "green",
|
|
1154
|
+
spinner: "dots"
|
|
1155
|
+
}).start();
|
|
1156
|
+
} else {
|
|
1157
|
+
process.stdout.write(` L${step.lineNumber}: ${step.instruction} `);
|
|
1158
|
+
}
|
|
1159
|
+
},
|
|
1160
|
+
stepEnd(result) {
|
|
1161
|
+
if (result.step.type === "comment") return;
|
|
1162
|
+
const durationStr = `${(result.duration / 1e3).toFixed(1)}s`;
|
|
1163
|
+
if (currentSpinner) {
|
|
1164
|
+
if (result.status === "passed") {
|
|
1165
|
+
currentSpinner.succeed(
|
|
1166
|
+
currentSpinner.text + c.dim(` ${durationStr}`)
|
|
1167
|
+
);
|
|
1168
|
+
} else if (result.status === "failed") {
|
|
1169
|
+
currentSpinner.fail(currentSpinner.text + c.dim(` ${durationStr}`));
|
|
1170
|
+
if (result.error)
|
|
1171
|
+
console.log(c.dim(` \u2514\u2500 `) + c.red(result.error.slice(0, 120)));
|
|
1172
|
+
} else {
|
|
1173
|
+
currentSpinner.stopAndPersist({
|
|
1174
|
+
symbol: c.dim(f.arrowRight),
|
|
1175
|
+
text: currentSpinner.text + c.dim(" skipped")
|
|
1176
|
+
});
|
|
1177
|
+
}
|
|
1178
|
+
currentSpinner = null;
|
|
1179
|
+
} else {
|
|
1180
|
+
if (result.status === "passed") {
|
|
1181
|
+
if (c && f) console.log(c.green(f.tick) + c.dim(` ${durationStr}`));
|
|
1182
|
+
else console.log(` OK ${durationStr}`);
|
|
1183
|
+
} else if (result.status === "failed") {
|
|
1184
|
+
if (c && f) {
|
|
1185
|
+
console.log(c.red(f.cross) + c.dim(` ${durationStr}`));
|
|
1186
|
+
if (result.error)
|
|
1187
|
+
console.log(c.green(` \u2514\u2500 ${result.error.slice(0, 120)}`));
|
|
1188
|
+
} else {
|
|
1189
|
+
console.log(` FAIL ${durationStr}`);
|
|
1190
|
+
if (result.error)
|
|
1191
|
+
console.log(` \u2514\u2500 ${result.error.slice(0, 120)}`);
|
|
1192
|
+
}
|
|
1193
|
+
} else {
|
|
1194
|
+
if (c && f) console.log(c.dim(f.arrowRight + " skipped"));
|
|
1195
|
+
else console.log(" skipped");
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// src/cli/interactive.ts
|
|
1203
|
+
function findVchFiles(dir, fileList = []) {
|
|
1204
|
+
try {
|
|
1205
|
+
const files = fs6.readdirSync(dir);
|
|
1206
|
+
for (const file of files) {
|
|
1207
|
+
if (file === "node_modules" || file === ".git" || file === "dist" || file === ".vouch" || file.startsWith("."))
|
|
1208
|
+
continue;
|
|
1209
|
+
const fullPath = path6.join(dir, file);
|
|
1210
|
+
const stat = fs6.statSync(fullPath);
|
|
1211
|
+
if (stat.isDirectory()) {
|
|
1212
|
+
findVchFiles(fullPath, fileList);
|
|
1213
|
+
} else if (file.endsWith(".vch")) {
|
|
1214
|
+
fileList.push(fullPath);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
} catch (e) {
|
|
1218
|
+
}
|
|
1219
|
+
return fileList;
|
|
1220
|
+
}
|
|
1221
|
+
async function runInteractiveMenu() {
|
|
1222
|
+
const chalk2 = await import("chalk");
|
|
1223
|
+
const p = await import("@clack/prompts");
|
|
1224
|
+
const c = chalk2.default ?? chalk2;
|
|
1225
|
+
console.clear();
|
|
1226
|
+
console.log(
|
|
1227
|
+
c.bold.white(`
|
|
1228
|
+
\u2584\u2584 \u2584\u2584 \u2584\u2584\u2584\u2584\u2584\u2584\u2584 \u2584\u2584 \u2584\u2584 \u2584\u2584\u2584\u2584\u2584\u2584\u2584 \u2584\u2584 \u2584\u2584
|
|
1229
|
+
\u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588
|
|
1230
|
+
\u2588 \u2588 \u2588 \u2588 \u2584 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588\u2584\u2588 \u2588
|
|
1231
|
+
\u2588 \u2588\u2584\u2588 \u2588 \u2588 \u2588 \u2588 \u2588\u2584\u2588 \u2588 \u2584\u2584\u2588 \u2588
|
|
1232
|
+
\u2588 \u2588 \u2588\u2584\u2588 \u2588 \u2588 \u2588 \u2588 \u2584 \u2588
|
|
1233
|
+
\u2580 \u2584\u2584\u2588 \u2588 \u2588 \u2588\u2584\u2584\u2588 \u2588 \u2588 \u2588
|
|
1234
|
+
\u2580\u2584\u2584\u2584\u2580 \u2580\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2580\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2580\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2580\u2584\u2584\u2580 \u2580\u2584\u2584\u2580
|
|
1235
|
+
`)
|
|
1236
|
+
);
|
|
1237
|
+
console.log(c.dim(" The future of visual web automation\n"));
|
|
1238
|
+
p.intro(c.bgWhite(c.black(" Welcome to Vouch ")));
|
|
1239
|
+
if (!fs6.existsSync("vouch.config.json")) {
|
|
1240
|
+
await initProject(true);
|
|
1241
|
+
}
|
|
1242
|
+
const testFiles = findVchFiles(process.cwd());
|
|
1243
|
+
const relativeFiles = testFiles.map((f) => path6.relative(process.cwd(), f));
|
|
1244
|
+
const action = await p.select({
|
|
1245
|
+
message: "What would you like to do?",
|
|
1246
|
+
options: [
|
|
1247
|
+
{
|
|
1248
|
+
label: "\u25B6 Run specific tests",
|
|
1249
|
+
value: "run_selected",
|
|
1250
|
+
hint: "Choose files to run"
|
|
1251
|
+
},
|
|
1252
|
+
{
|
|
1253
|
+
label: "\u25B6 Run all tests",
|
|
1254
|
+
value: "run_all",
|
|
1255
|
+
hint: "Execute all .vch files"
|
|
1256
|
+
},
|
|
1257
|
+
{
|
|
1258
|
+
label: "\u{1F680} Initialize examples",
|
|
1259
|
+
value: "init",
|
|
1260
|
+
hint: "Create example tests"
|
|
1261
|
+
},
|
|
1262
|
+
{ label: "\u274C Exit", value: "exit" }
|
|
1263
|
+
]
|
|
1264
|
+
});
|
|
1265
|
+
if (action === "exit" || p.isCancel(action)) {
|
|
1266
|
+
p.outro("Goodbye!");
|
|
1267
|
+
process.exit(0);
|
|
1268
|
+
}
|
|
1269
|
+
if (action === "init") {
|
|
1270
|
+
await initProject(false);
|
|
1271
|
+
p.outro("Examples initialized! Run 'vouch' again to execute tests.");
|
|
1272
|
+
process.exit(0);
|
|
1273
|
+
}
|
|
1274
|
+
let selectedFiles = [];
|
|
1275
|
+
if (action === "run_all") {
|
|
1276
|
+
if (relativeFiles.length === 0) {
|
|
1277
|
+
p.log.warn("No .vch files found in the current directory.");
|
|
1278
|
+
process.exit(0);
|
|
1279
|
+
}
|
|
1280
|
+
selectedFiles = relativeFiles;
|
|
1281
|
+
} else if (action === "run_selected") {
|
|
1282
|
+
if (relativeFiles.length === 0) {
|
|
1283
|
+
p.log.warn("No .vch files found in the current directory.");
|
|
1284
|
+
process.exit(0);
|
|
1285
|
+
}
|
|
1286
|
+
const files = await p.multiselect({
|
|
1287
|
+
message: "Select test files (type to search)",
|
|
1288
|
+
options: relativeFiles.map((f) => ({
|
|
1289
|
+
label: path6.basename(f),
|
|
1290
|
+
// Just show file name
|
|
1291
|
+
value: f,
|
|
1292
|
+
hint: path6.dirname(f) === "." ? void 0 : path6.dirname(f)
|
|
1293
|
+
})),
|
|
1294
|
+
required: true
|
|
1295
|
+
});
|
|
1296
|
+
if (p.isCancel(files)) process.exit(0);
|
|
1297
|
+
selectedFiles = files;
|
|
1298
|
+
}
|
|
1299
|
+
const config = loadConfig();
|
|
1300
|
+
p.outro("Starting tests...");
|
|
1301
|
+
const logger = createLogger();
|
|
1302
|
+
let totalFailed = 0;
|
|
1303
|
+
for (const file of selectedFiles) {
|
|
1304
|
+
const result = await runTestFile(file, config, logger);
|
|
1305
|
+
totalFailed += result.totalFailed;
|
|
1306
|
+
}
|
|
1307
|
+
process.exit(totalFailed > 0 ? 1 : 0);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// src/cli/commands.ts
|
|
1311
|
+
async function ensureConfigExists() {
|
|
1312
|
+
if (!fs7.existsSync("vouch.config.json")) {
|
|
1313
|
+
await initProject(true);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
function createCLI() {
|
|
1317
|
+
const program = new Command();
|
|
1318
|
+
program.name("vouch").description("\u{1F578}\uFE0F Vouch \u2014 Zero-selector, vision-driven web automation").version("1.0.0");
|
|
1319
|
+
program.action(async () => {
|
|
1320
|
+
await ensureConfigExists();
|
|
1321
|
+
await runInteractiveMenu();
|
|
1322
|
+
});
|
|
1323
|
+
program.command("run").description("Execute a .vch file").argument("<file>", "Path to the .vch file").option(
|
|
1324
|
+
"-p, --provider <provider>",
|
|
1325
|
+
"AI provider (openai, anthropic, google, ollama)"
|
|
1326
|
+
).option("-m, --model <model>", "AI model identifier").option("-k, --api-key <key>", "API key for the AI provider").option("--base-url <url>", "Base URL override").option("--headless", "Run browser in headless mode").option("--no-headless", "Run browser in headed mode").option("--retries <n>", "Max retries per step", parseInt).option("--viewport <WxH>", "Viewport size (e.g., 1280x800)").option("--no-report", "Skip JSON report generation").option("--report-dir <dir>", "JSON report output directory").action(async (file, options) => {
|
|
1327
|
+
await ensureConfigExists();
|
|
1328
|
+
await loadLoggerDeps();
|
|
1329
|
+
const chalk2 = await import("chalk");
|
|
1330
|
+
const boxen = await import("boxen");
|
|
1331
|
+
const c = chalk2.default ?? chalk2;
|
|
1332
|
+
const b = boxen.default ?? boxen;
|
|
1333
|
+
console.log(
|
|
1334
|
+
b(c.bold.white("\u{1F578}\uFE0F VOUCH") + c.dim(" \u2014 Vision-Driven Automation"), {
|
|
1335
|
+
padding: { top: 0, bottom: 0, left: 2, right: 2 },
|
|
1336
|
+
borderStyle: "round",
|
|
1337
|
+
borderColor: "white",
|
|
1338
|
+
dimBorder: true
|
|
1339
|
+
})
|
|
1340
|
+
);
|
|
1341
|
+
const overrides = {};
|
|
1342
|
+
if (options.provider)
|
|
1343
|
+
overrides.provider = options.provider;
|
|
1344
|
+
if (options.model) overrides.model = options.model;
|
|
1345
|
+
if (options.apiKey) overrides.apiKey = options.apiKey;
|
|
1346
|
+
if (options.baseUrl) overrides.baseUrl = options.baseUrl;
|
|
1347
|
+
if (typeof options.headless === "boolean")
|
|
1348
|
+
overrides.headless = options.headless;
|
|
1349
|
+
if (options.retries) overrides.maxRetries = options.retries;
|
|
1350
|
+
if (options.reportDir) overrides.reportDir = options.reportDir;
|
|
1351
|
+
if (options.report === false) overrides.report = false;
|
|
1352
|
+
if (options.viewport) {
|
|
1353
|
+
const [w, h] = options.viewport.split("x").map(Number);
|
|
1354
|
+
if (w && h) {
|
|
1355
|
+
overrides.viewportWidth = w;
|
|
1356
|
+
overrides.viewportHeight = h;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
const config = loadConfig(overrides);
|
|
1360
|
+
const logger = createLogger();
|
|
1361
|
+
logger.info(
|
|
1362
|
+
`Provider: ${c.cyan(config.provider)} | Model: ${c.cyan(config.model)}`
|
|
1363
|
+
);
|
|
1364
|
+
logger.info(
|
|
1365
|
+
`Viewport: ${config.viewportWidth}x${config.viewportHeight} | Headless: ${config.headless}`
|
|
1366
|
+
);
|
|
1367
|
+
logger.info(
|
|
1368
|
+
`Max retries: ${config.maxRetries} | Action delay: ${config.actionDelay}ms`
|
|
1369
|
+
);
|
|
1370
|
+
const result = await runTestFile(file, config, logger);
|
|
1371
|
+
process.exit(result.totalFailed > 0 ? 1 : 0);
|
|
1372
|
+
});
|
|
1373
|
+
program.command("init").description("Initialize a new Vouch project with example files").action(async () => {
|
|
1374
|
+
await loadLoggerDeps();
|
|
1375
|
+
await initProject(false);
|
|
1376
|
+
});
|
|
1377
|
+
return program;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// src/index.ts
|
|
1381
|
+
async function main() {
|
|
1382
|
+
const program = createCLI();
|
|
1383
|
+
await program.parseAsync(process.argv);
|
|
1384
|
+
}
|
|
1385
|
+
main().catch((err) => {
|
|
1386
|
+
console.error("Fatal:", err);
|
|
1387
|
+
process.exit(1);
|
|
1388
|
+
});
|
|
1389
|
+
//# sourceMappingURL=index.js.map
|