bangonit 0.3.0 → 0.3.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/README.md +69 -37
- package/app/desktopapp/dist/main/index.js +10 -161
- package/app/desktopapp/dist/main/ipc.js +0 -20
- package/app/desktopapp/dist/main/preload.js +0 -2
- package/app/desktopapp/dist/main/tabs.js +0 -10
- package/app/desktopapp/dist/shared/args.js +21 -0
- package/app/desktopapp/package.json +1 -1
- package/app/replay/dist/replay.css +1 -1
- package/app/replay/dist/replay.js +20 -20
- package/app/webapp/.next/BUILD_ID +1 -1
- package/app/webapp/.next/app-build-manifest.json +2 -2
- package/app/webapp/.next/app-path-routes-manifest.json +1 -1
- package/app/webapp/.next/build-manifest.json +2 -2
- package/app/webapp/.next/next-minimal-server.js.nft.json +1 -1
- package/app/webapp/.next/next-server.js.nft.json +1 -1
- package/app/webapp/.next/prerender-manifest.json +1 -1
- package/app/webapp/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/app/webapp/.next/server/app/_not-found.html +1 -1
- package/app/webapp/.next/server/app/_not-found.rsc +1 -1
- package/app/webapp/.next/server/app/app/page.js +3 -7
- package/app/webapp/.next/server/app/app/page_client-reference-manifest.js +1 -1
- package/app/webapp/.next/server/app/app.html +1 -1
- package/app/webapp/.next/server/app/app.rsc +2 -2
- package/app/webapp/.next/server/app/index.html +1 -1
- package/app/webapp/.next/server/app/index.rsc +1 -1
- package/app/webapp/.next/server/app/page_client-reference-manifest.js +1 -1
- package/app/webapp/.next/server/app-paths-manifest.json +2 -2
- package/app/webapp/.next/server/chunks/708.js +1 -1
- package/app/webapp/.next/server/functions-config-manifest.json +1 -1
- package/app/webapp/.next/server/pages/404.html +1 -1
- package/app/webapp/.next/server/pages/500.html +1 -1
- package/app/webapp/.next/server/server-reference-manifest.json +1 -1
- package/app/webapp/.next/static/chunks/app/app/page-0e096497dcb81dae.js +1 -0
- package/app/webapp/.next/static/css/{869d3ff23c36c4b5.css → 38219627f55424f2.css} +1 -1
- package/app/webapp/.next/trace +2 -2
- package/app/webapp/package.json +7 -2
- package/app/webapp/src/shared/api/chat.ts +2 -11
- package/app/webapp/src/shared/components/AppShell.tsx +21 -1
- package/app/webapp/src/shared/components/SessionView.tsx +37 -65
- package/app/webapp/src/shared/lib/browser/mouse.ts +1 -1
- package/app/webapp/src/shared/lib/browser/recorder.ts +3 -3
- package/app/webapp/src/shared/types/global.d.ts +0 -3
- package/bin/app/desktopapp/src/shared/args.js +21 -0
- package/bin/bangonit.js +259 -96
- package/bin/src/cli/bangonit.js +767 -0
- package/package.json +6 -4
- package/app/webapp/.next/static/chunks/app/app/page-d38c1e48d37def82.js +0 -1
- /package/app/webapp/.next/static/{TaLpPsk5rC30wNNcyfUN3 → Qq0OvlQijtcR84Dg9Dgp0}/_buildManifest.js +0 -0
- /package/app/webapp/.next/static/{TaLpPsk5rC30wNNcyfUN3 → Qq0OvlQijtcR84Dg9Dgp0}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// Bang On It! CLI — starts the webapp and Electron app, forwards all args.
|
|
4
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
5
|
+
if (k2 === undefined) k2 = k;
|
|
6
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
7
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
8
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
9
|
+
}
|
|
10
|
+
Object.defineProperty(o, k2, desc);
|
|
11
|
+
}) : (function(o, m, k, k2) {
|
|
12
|
+
if (k2 === undefined) k2 = k;
|
|
13
|
+
o[k2] = m[k];
|
|
14
|
+
}));
|
|
15
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
16
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
17
|
+
}) : function(o, v) {
|
|
18
|
+
o["default"] = v;
|
|
19
|
+
});
|
|
20
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
21
|
+
var ownKeys = function(o) {
|
|
22
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
23
|
+
var ar = [];
|
|
24
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
25
|
+
return ar;
|
|
26
|
+
};
|
|
27
|
+
return ownKeys(o);
|
|
28
|
+
};
|
|
29
|
+
return function (mod) {
|
|
30
|
+
if (mod && mod.__esModule) return mod;
|
|
31
|
+
var result = {};
|
|
32
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
33
|
+
__setModuleDefault(result, mod);
|
|
34
|
+
return result;
|
|
35
|
+
};
|
|
36
|
+
})();
|
|
37
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
38
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
39
|
+
};
|
|
40
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
+
const child_process_1 = require("child_process");
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const fs = __importStar(require("fs"));
|
|
44
|
+
const net = __importStar(require("net"));
|
|
45
|
+
const readline = __importStar(require("readline"));
|
|
46
|
+
const TOML = __importStar(require("@iarna/toml"));
|
|
47
|
+
const Minio = __importStar(require("minio"));
|
|
48
|
+
const yargs_1 = __importDefault(require("yargs"));
|
|
49
|
+
const helpers_1 = require("yargs/helpers");
|
|
50
|
+
const is_ci_1 = __importDefault(require("is-ci"));
|
|
51
|
+
const args_1 = require("../../app/desktopapp/src/shared/args");
|
|
52
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
53
|
+
const WEBAPP_DIR = path.join(ROOT, "app", "webapp");
|
|
54
|
+
const DESKTOP_DIR = path.join(ROOT, "app", "desktopapp");
|
|
55
|
+
const LOGS_DIR = path.join(process.cwd(), "logs");
|
|
56
|
+
// Colors
|
|
57
|
+
const c = {
|
|
58
|
+
reset: "\x1b[0m",
|
|
59
|
+
bold: "\x1b[1m",
|
|
60
|
+
dim: "\x1b[2m",
|
|
61
|
+
red: "\x1b[31m",
|
|
62
|
+
green: "\x1b[32m",
|
|
63
|
+
yellow: "\x1b[33m",
|
|
64
|
+
blue: "\x1b[34m",
|
|
65
|
+
magenta: "\x1b[35m",
|
|
66
|
+
cyan: "\x1b[36m",
|
|
67
|
+
};
|
|
68
|
+
function die(msg) {
|
|
69
|
+
console.error(`${c.red}${msg}${c.reset}`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
function getFreePort() {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const server = net.createServer();
|
|
75
|
+
server.once("error", reject);
|
|
76
|
+
server.listen(0, () => {
|
|
77
|
+
const port = server.address().port;
|
|
78
|
+
server.close(() => resolve(port));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function isPortFree(port) {
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
const server = net.createServer();
|
|
85
|
+
server.once("error", () => resolve(false));
|
|
86
|
+
server.once("listening", () => {
|
|
87
|
+
server.close();
|
|
88
|
+
resolve(true);
|
|
89
|
+
});
|
|
90
|
+
server.listen(port);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
async function waitForPort(port, timeoutMs = 30000) {
|
|
94
|
+
const start = Date.now();
|
|
95
|
+
while (Date.now() - start < timeoutMs) {
|
|
96
|
+
const free = await isPortFree(port);
|
|
97
|
+
if (!free)
|
|
98
|
+
return;
|
|
99
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
100
|
+
}
|
|
101
|
+
die(`Timed out waiting for port ${port}`);
|
|
102
|
+
}
|
|
103
|
+
function loadEnv() {
|
|
104
|
+
if (!process.env.ANTHROPIC_API_KEY) {
|
|
105
|
+
const envPath = path.join(process.cwd(), ".env");
|
|
106
|
+
if (fs.existsSync(envPath)) {
|
|
107
|
+
for (const line of fs.readFileSync(envPath, "utf-8").split("\n")) {
|
|
108
|
+
const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
|
|
109
|
+
if (match && !process.env[match[1]]) {
|
|
110
|
+
process.env[match[1]] = match[2].replace(/^["']|["']$/g, "");
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Interpolate ${ENV_VAR} references in string values throughout an object
|
|
117
|
+
function interpolateEnv(obj) {
|
|
118
|
+
if (typeof obj === "string") {
|
|
119
|
+
return obj.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name) => {
|
|
120
|
+
return process.env[name] || "";
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (Array.isArray(obj))
|
|
124
|
+
return obj.map(interpolateEnv);
|
|
125
|
+
if (obj && typeof obj === "object") {
|
|
126
|
+
const result = {};
|
|
127
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
128
|
+
result[k] = interpolateEnv(v);
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
return obj;
|
|
133
|
+
}
|
|
134
|
+
// Walk up from cwd to find .bangonit/ directory, stopping at .git or filesystem root
|
|
135
|
+
function findProjectDir() {
|
|
136
|
+
let dir = process.cwd();
|
|
137
|
+
while (true) {
|
|
138
|
+
if (fs.existsSync(path.join(dir, ".bangonit")))
|
|
139
|
+
return dir;
|
|
140
|
+
if (fs.existsSync(path.join(dir, ".git")))
|
|
141
|
+
return null;
|
|
142
|
+
const parent = path.dirname(dir);
|
|
143
|
+
if (parent === dir)
|
|
144
|
+
return null; // filesystem root
|
|
145
|
+
dir = parent;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function loadConfig(configPath) {
|
|
149
|
+
if (configPath) {
|
|
150
|
+
if (!fs.existsSync(configPath))
|
|
151
|
+
die(`Config file not found: ${configPath}`);
|
|
152
|
+
try {
|
|
153
|
+
const raw = TOML.parse(fs.readFileSync(configPath, "utf-8"));
|
|
154
|
+
return interpolateEnv(raw);
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
die(`Error reading config ${configPath}: ${err.message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
const projectDir = findProjectDir();
|
|
161
|
+
if (!projectDir)
|
|
162
|
+
return {};
|
|
163
|
+
const filePath = path.join(projectDir, ".bangonit", "config.toml");
|
|
164
|
+
if (!fs.existsSync(filePath))
|
|
165
|
+
return {};
|
|
166
|
+
try {
|
|
167
|
+
const raw = TOML.parse(fs.readFileSync(filePath, "utf-8"));
|
|
168
|
+
return interpolateEnv(raw);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
die(`Error reading config ${filePath}: ${err.message}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// --- Test plan discovery ---
|
|
175
|
+
function findTestPlans(dir, filter) {
|
|
176
|
+
const results = [];
|
|
177
|
+
if (!fs.existsSync(dir))
|
|
178
|
+
return results;
|
|
179
|
+
function walk(d) {
|
|
180
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
181
|
+
const full = path.join(d, entry.name);
|
|
182
|
+
if (entry.isDirectory()) {
|
|
183
|
+
walk(full);
|
|
184
|
+
}
|
|
185
|
+
else if (entry.name.endsWith(".md")) {
|
|
186
|
+
results.push(full);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
walk(dir);
|
|
191
|
+
if (filter) {
|
|
192
|
+
const lower = filter.toLowerCase();
|
|
193
|
+
return results.filter((f) => path.basename(f).toLowerCase().includes(lower));
|
|
194
|
+
}
|
|
195
|
+
return results;
|
|
196
|
+
}
|
|
197
|
+
function createS3Client(opts) {
|
|
198
|
+
const accessKey = opts.accessKey || process.env.AWS_ACCESS_KEY_ID || "";
|
|
199
|
+
const secretKey = opts.secretKey || process.env.AWS_SECRET_ACCESS_KEY || "";
|
|
200
|
+
const region = opts.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1";
|
|
201
|
+
if (opts.endpoint) {
|
|
202
|
+
const useSSL = !opts.endpoint.startsWith("http://");
|
|
203
|
+
const endPoint = opts.endpoint.replace(/^https?:\/\//, "");
|
|
204
|
+
return new Minio.Client({ endPoint, useSSL, accessKey, secretKey, region });
|
|
205
|
+
}
|
|
206
|
+
return new Minio.Client({
|
|
207
|
+
endPoint: "s3.amazonaws.com",
|
|
208
|
+
useSSL: true,
|
|
209
|
+
accessKey,
|
|
210
|
+
secretKey,
|
|
211
|
+
region,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
async function uploadDir(client, localDir, bucket, prefix) {
|
|
215
|
+
const entries = fs.readdirSync(localDir, { withFileTypes: true });
|
|
216
|
+
for (const entry of entries) {
|
|
217
|
+
const fullPath = path.join(localDir, entry.name);
|
|
218
|
+
const objectName = `${prefix}/${entry.name}`;
|
|
219
|
+
if (entry.isDirectory()) {
|
|
220
|
+
await uploadDir(client, fullPath, bucket, objectName);
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
await client.fPutObject(bucket, objectName, fullPath, {});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
async function uploadToS3(localDir, opts) {
|
|
228
|
+
const client = createS3Client(opts);
|
|
229
|
+
await uploadDir(client, localDir, opts.bucket, opts.prefix);
|
|
230
|
+
const region = opts.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || "us-east-1";
|
|
231
|
+
if (opts.endpoint) {
|
|
232
|
+
const proto = opts.endpoint.startsWith("http://") ? "http" : "https";
|
|
233
|
+
const host = opts.endpoint.replace(/^https?:\/\//, "");
|
|
234
|
+
return `${proto}://${opts.bucket}.${host}/${opts.prefix}/index.html`;
|
|
235
|
+
}
|
|
236
|
+
if (region === "us-east-1") {
|
|
237
|
+
return `https://${opts.bucket}.s3.amazonaws.com/${opts.prefix}/index.html`;
|
|
238
|
+
}
|
|
239
|
+
return `https://${opts.bucket}.s3.${region}.amazonaws.com/${opts.prefix}/index.html`;
|
|
240
|
+
}
|
|
241
|
+
function createPrompter() {
|
|
242
|
+
let closed = false;
|
|
243
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
244
|
+
rl.on("close", () => { closed = true; });
|
|
245
|
+
return {
|
|
246
|
+
ask(question, defaultVal) {
|
|
247
|
+
if (closed)
|
|
248
|
+
return Promise.resolve(defaultVal || "");
|
|
249
|
+
return new Promise((resolve) => {
|
|
250
|
+
const defStr = defaultVal ? `${c.dim} [${defaultVal}]${c.reset}` : "";
|
|
251
|
+
rl.question(` ${c.cyan}?${c.reset} ${question}${defStr} `, (answer) => {
|
|
252
|
+
resolve(answer.trim() || defaultVal || "");
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
askChoice(question, choices, defaultVal) {
|
|
257
|
+
if (closed)
|
|
258
|
+
return Promise.resolve(defaultVal);
|
|
259
|
+
const choiceStr = choices.map((ch) => ch === defaultVal ? `${c.bold}${ch}${c.reset}${c.dim}` : ch).join("/");
|
|
260
|
+
return new Promise((resolve) => {
|
|
261
|
+
rl.question(` ${c.cyan}?${c.reset} ${question} ${c.dim}(${choiceStr})${c.reset} `, (answer) => {
|
|
262
|
+
const val = answer.trim().toLowerCase() || defaultVal;
|
|
263
|
+
resolve(choices.includes(val) ? val : defaultVal);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
},
|
|
267
|
+
close() { rl.close(); },
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
// --- init command ---
|
|
271
|
+
// Common timezone offsets from UTC (standard time)
|
|
272
|
+
const TIMEZONE_OFFSETS = {
|
|
273
|
+
"us/eastern": -5, "us/central": -6, "us/mountain": -7, "us/pacific": -8,
|
|
274
|
+
"europe/london": 0, "europe/berlin": 1, "europe/paris": 1,
|
|
275
|
+
"asia/tokyo": 9, "asia/shanghai": 8, "asia/kolkata": 5,
|
|
276
|
+
"australia/sydney": 11,
|
|
277
|
+
};
|
|
278
|
+
function localHourToUtc(localHour, utcOffset) {
|
|
279
|
+
return ((localHour - utcOffset) % 24 + 24) % 24;
|
|
280
|
+
}
|
|
281
|
+
async function initProject() {
|
|
282
|
+
const p = createPrompter();
|
|
283
|
+
console.log(`\n ${c.bold}${c.magenta}Bang On It! init${c.reset}\n`);
|
|
284
|
+
const testplans = await p.ask("Test plans directory", "testplans");
|
|
285
|
+
const apiKey = await p.ask("Anthropic API key (empty to use env var)", "");
|
|
286
|
+
const recordingsDir = await p.ask("Recordings directory", "recordings");
|
|
287
|
+
const s3Bucket = await p.ask("S3 bucket for recordings (empty to skip)", "");
|
|
288
|
+
let s3Endpoint = "";
|
|
289
|
+
let s3Region = "";
|
|
290
|
+
let s3Prefix = "";
|
|
291
|
+
if (s3Bucket) {
|
|
292
|
+
s3Endpoint = await p.ask("S3 endpoint (empty for AWS, or e.g. nyc3.digitaloceanspaces.com)", "");
|
|
293
|
+
s3Region = await p.ask("S3 region", "us-east-1");
|
|
294
|
+
s3Prefix = await p.ask("S3 prefix", "bangonit");
|
|
295
|
+
}
|
|
296
|
+
// --- Config file ---
|
|
297
|
+
let toml = `testplans = "${testplans}"\n`;
|
|
298
|
+
toml += `recordings_dir = "${recordingsDir}"\n`;
|
|
299
|
+
if (apiKey) {
|
|
300
|
+
toml += `anthropic_api_key = "${apiKey}"\n`;
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
toml += `# anthropic_api_key = "\${ANTHROPIC_API_KEY}"\n`;
|
|
304
|
+
}
|
|
305
|
+
if (s3Bucket) {
|
|
306
|
+
toml += `\n[s3]\nbucket = "${s3Bucket}"\n`;
|
|
307
|
+
if (s3Endpoint)
|
|
308
|
+
toml += `endpoint = "${s3Endpoint}"\n`;
|
|
309
|
+
if (s3Region && s3Region !== "us-east-1")
|
|
310
|
+
toml += `region = "${s3Region}"\n`;
|
|
311
|
+
if (s3Prefix && s3Prefix !== "bangonit")
|
|
312
|
+
toml += `prefix = "${s3Prefix}"\n`;
|
|
313
|
+
toml += `# access_key = "\${AWS_ACCESS_KEY_ID}"\n`;
|
|
314
|
+
toml += `# secret_key = "\${AWS_SECRET_ACCESS_KEY}"\n`;
|
|
315
|
+
}
|
|
316
|
+
const bangDir = path.join(process.cwd(), ".bangonit");
|
|
317
|
+
fs.mkdirSync(bangDir, { recursive: true });
|
|
318
|
+
const configOutPath = path.join(bangDir, "config.toml");
|
|
319
|
+
fs.writeFileSync(configOutPath, toml);
|
|
320
|
+
console.log(`\n ${c.green}Created${c.reset} .bangonit/config.toml`);
|
|
321
|
+
// --- Test plan directories ---
|
|
322
|
+
const testplanBase = path.join(process.cwd(), testplans);
|
|
323
|
+
const dirs = ["smoke", "acceptance", "regression"];
|
|
324
|
+
for (const dir of dirs) {
|
|
325
|
+
const dirPath = path.join(testplanBase, dir);
|
|
326
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
327
|
+
const gitkeep = path.join(dirPath, ".gitkeep");
|
|
328
|
+
if (!fs.existsSync(gitkeep))
|
|
329
|
+
fs.writeFileSync(gitkeep, "");
|
|
330
|
+
console.log(` ${c.green}Created${c.reset} ${testplans}/${dir}/`);
|
|
331
|
+
}
|
|
332
|
+
// Write example smoke test
|
|
333
|
+
const smokeExample = path.join(testplanBase, "smoke", "homepage.md");
|
|
334
|
+
if (!fs.existsSync(smokeExample)) {
|
|
335
|
+
fs.writeFileSync(smokeExample, `---
|
|
336
|
+
name: Homepage loads
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
## Steps
|
|
340
|
+
1. Navigate to http://localhost:3000
|
|
341
|
+
2. Verify the page loads with a heading
|
|
342
|
+
3. Verify there are no console errors
|
|
343
|
+
`);
|
|
344
|
+
console.log(` ${c.green}Created${c.reset} ${testplans}/smoke/homepage.md`);
|
|
345
|
+
}
|
|
346
|
+
// --- Claude Code skills ---
|
|
347
|
+
const claudeSkillsDir = path.join(process.cwd(), ".claude", "skills");
|
|
348
|
+
const testSkillDir = path.join(claudeSkillsDir, "test");
|
|
349
|
+
fs.mkdirSync(testSkillDir, { recursive: true });
|
|
350
|
+
fs.writeFileSync(path.join(testSkillDir, "SKILL.md"), `---
|
|
351
|
+
name: test
|
|
352
|
+
description: Run Bang On It! E2E tests locally. Pass test plan files or a filter as $ARGUMENTS (e.g. "testplans/smoke/" or "-t login"). With no arguments, runs all test plans.
|
|
353
|
+
tools: Bash, Read
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
# Run E2E Tests
|
|
357
|
+
|
|
358
|
+
Run Bang On It! end-to-end tests locally.
|
|
359
|
+
|
|
360
|
+
**Arguments:** $ARGUMENTS
|
|
361
|
+
|
|
362
|
+
## Instructions
|
|
363
|
+
|
|
364
|
+
1. If $ARGUMENTS is empty, run all test plans:
|
|
365
|
+
\`\`\`bash
|
|
366
|
+
boi run --record
|
|
367
|
+
\`\`\`
|
|
368
|
+
|
|
369
|
+
2. If $ARGUMENTS contains file paths or directories, run those:
|
|
370
|
+
\`\`\`bash
|
|
371
|
+
boi run $ARGUMENTS --record
|
|
372
|
+
\`\`\`
|
|
373
|
+
|
|
374
|
+
3. If $ARGUMENTS contains a filter (e.g. "login", "checkout"), run with filter:
|
|
375
|
+
\`\`\`bash
|
|
376
|
+
boi run -t $ARGUMENTS --record
|
|
377
|
+
\`\`\`
|
|
378
|
+
|
|
379
|
+
4. Wait for tests to complete. Report results and recording paths.
|
|
380
|
+
|
|
381
|
+
5. If tests fail, read the test plan file and the output to diagnose the failure. Suggest whether the test plan needs updating or there's a real bug.
|
|
382
|
+
`);
|
|
383
|
+
console.log(` ${c.green}Created${c.reset} .claude/skills/test/SKILL.md`);
|
|
384
|
+
const createTestSkillDir = path.join(claudeSkillsDir, "create-test");
|
|
385
|
+
fs.mkdirSync(createTestSkillDir, { recursive: true });
|
|
386
|
+
fs.writeFileSync(path.join(createTestSkillDir, "SKILL.md"), `---
|
|
387
|
+
name: create-test
|
|
388
|
+
description: Create new Bang On It! test plan(s). Pass a description of what to test as $ARGUMENTS, or omit to auto-generate from git changes.
|
|
389
|
+
tools: Bash, Read, Write, Glob, Grep
|
|
390
|
+
---
|
|
391
|
+
|
|
392
|
+
# Create Test Plan
|
|
393
|
+
|
|
394
|
+
Create new Bang On It! test plan files.
|
|
395
|
+
|
|
396
|
+
**What to test:** $ARGUMENTS
|
|
397
|
+
|
|
398
|
+
## Instructions
|
|
399
|
+
|
|
400
|
+
### Step 0: Determine what to test
|
|
401
|
+
|
|
402
|
+
- If $ARGUMENTS is provided, use it as the description of what to test.
|
|
403
|
+
- If $ARGUMENTS is empty, auto-discover from git changes:
|
|
404
|
+
1. Run \`git log master..HEAD --oneline\` and \`git diff master...HEAD --stat\` to see what changed on this branch.
|
|
405
|
+
2. If no branch divergence, run \`git diff HEAD --stat\` and \`git diff HEAD\` for uncommitted changes.
|
|
406
|
+
3. If still nothing, run \`git log -1 --format="%H %s"\` and \`git show HEAD --stat\` for the latest commit.
|
|
407
|
+
4. Analyze the changes and create test plan(s) covering them. Bug fixes get regression tests, new features get acceptance tests.
|
|
408
|
+
5. Skip changes that are already covered by existing test plans, pure refactors, docs, CI, or dependency updates.
|
|
409
|
+
|
|
410
|
+
### Step 1: Determine which directory the test belongs in
|
|
411
|
+
- \`${testplans}/smoke/\` — Quick sanity checks (app loads, critical path works). Keep smoke tests minimal — they run on every commit so they must be fast. Only add here if it tests truly fundamental functionality. Prefer acceptance/ for most tests.
|
|
412
|
+
- \`${testplans}/acceptance/\` — Core user journeys and happy paths. This is the default for most new tests.
|
|
413
|
+
- \`${testplans}/regression/\` — Bug fixes and edge cases. Use when the description references a bug or issue.
|
|
414
|
+
|
|
415
|
+
2. Read existing test plans in that directory to understand conventions:
|
|
416
|
+
\`\`\`bash
|
|
417
|
+
ls ${testplans}/smoke/ ${testplans}/acceptance/ ${testplans}/regression/
|
|
418
|
+
\`\`\`
|
|
419
|
+
|
|
420
|
+
3. Read the codebase to understand what UI elements and flows are involved. Look at routes, components, and pages relevant to the test.
|
|
421
|
+
|
|
422
|
+
4. Create the test plan file:
|
|
423
|
+
- Filename: kebab-case, e.g. \`password-reset.md\`
|
|
424
|
+
- Use this format:
|
|
425
|
+
|
|
426
|
+
\`\`\`markdown
|
|
427
|
+
---
|
|
428
|
+
name: Descriptive test name
|
|
429
|
+
retries: 1
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## Steps
|
|
433
|
+
1. Navigate to the relevant page
|
|
434
|
+
2. Perform the action being tested
|
|
435
|
+
3. Verify the expected outcome
|
|
436
|
+
\`\`\`
|
|
437
|
+
|
|
438
|
+
5. Keep steps concise and actionable. Write from the user's perspective — describe what to click, type, and verify. Don't reference CSS selectors or implementation details.
|
|
439
|
+
|
|
440
|
+
6. Output the path to the created file.
|
|
441
|
+
`);
|
|
442
|
+
console.log(` ${c.green}Created${c.reset} .claude/skills/create-test/SKILL.md`);
|
|
443
|
+
// --- CI setup ---
|
|
444
|
+
console.log("");
|
|
445
|
+
const setupCi = await p.askChoice("Set up GitHub Actions CI?", ["y", "n"], "y");
|
|
446
|
+
if (setupCi === "y") {
|
|
447
|
+
console.log("");
|
|
448
|
+
const baseImage = await p.ask("GitHub Actions runner", "ubuntu-latest");
|
|
449
|
+
const nodeVersion = await p.ask("Node.js version", "20");
|
|
450
|
+
const setupCommand = await p.ask("Setup command", "npm install && npm run build");
|
|
451
|
+
const startCommand = await p.ask("Command to start your web server", "npm start &");
|
|
452
|
+
const waitUrl = await p.ask("URL to wait for before testing", "http://localhost:3000");
|
|
453
|
+
const timeout = await p.ask("Test timeout in seconds", "300");
|
|
454
|
+
const tzNames = Object.keys(TIMEZONE_OFFSETS).join(", ");
|
|
455
|
+
const tz = await p.ask(`Timezone for full run (${tzNames})`, "us/eastern");
|
|
456
|
+
const utcOffset = TIMEZONE_OFFSETS[tz.toLowerCase()] ?? -5;
|
|
457
|
+
const fullUtcHour = localHourToUtc(18, utcOffset);
|
|
458
|
+
const timeoutMinutes = Math.ceil(parseInt(timeout) / 60) + 5;
|
|
459
|
+
const stepsYaml = `
|
|
460
|
+
steps:
|
|
461
|
+
- uses: actions/checkout@v4
|
|
462
|
+
|
|
463
|
+
- uses: actions/setup-node@v4
|
|
464
|
+
with:
|
|
465
|
+
node-version: '${nodeVersion}'
|
|
466
|
+
|
|
467
|
+
- name: Install system dependencies
|
|
468
|
+
run: |
|
|
469
|
+
sudo apt-get update
|
|
470
|
+
sudo apt-get install -y xvfb libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2
|
|
471
|
+
|
|
472
|
+
- name: Setup project
|
|
473
|
+
run: ${setupCommand}
|
|
474
|
+
|
|
475
|
+
- name: Start server
|
|
476
|
+
run: ${startCommand}
|
|
477
|
+
|
|
478
|
+
- name: Wait for server
|
|
479
|
+
run: npx wait-on ${waitUrl} --timeout 30000
|
|
480
|
+
|
|
481
|
+
- name: Install bangonit
|
|
482
|
+
run: npm install -g bangonit`;
|
|
483
|
+
const smokeWorkflow = `name: "Bang On It! Smoke Tests"
|
|
484
|
+
|
|
485
|
+
on:
|
|
486
|
+
push:
|
|
487
|
+
branches: [main, master]
|
|
488
|
+
pull_request:
|
|
489
|
+
|
|
490
|
+
jobs:
|
|
491
|
+
smoke:
|
|
492
|
+
runs-on: ${baseImage}
|
|
493
|
+
timeout-minutes: ${timeoutMinutes}
|
|
494
|
+
env:
|
|
495
|
+
ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
|
|
496
|
+
${stepsYaml}
|
|
497
|
+
|
|
498
|
+
- name: Run smoke tests
|
|
499
|
+
run: |
|
|
500
|
+
xvfb-run --auto-servernum boi run ${testplans}/smoke/ \\
|
|
501
|
+
--timeout ${timeout} \\
|
|
502
|
+
--output bangonit-output.json --record
|
|
503
|
+
|
|
504
|
+
- name: Upload test results
|
|
505
|
+
if: always()
|
|
506
|
+
uses: actions/upload-artifact@v4
|
|
507
|
+
with:
|
|
508
|
+
name: bangonit-smoke-results
|
|
509
|
+
path: |
|
|
510
|
+
bangonit-output.json
|
|
511
|
+
recordings/
|
|
512
|
+
if-no-files-found: ignore
|
|
513
|
+
`;
|
|
514
|
+
const fullWorkflow = `name: "Bang On It! Full Tests"
|
|
515
|
+
|
|
516
|
+
on:
|
|
517
|
+
schedule:
|
|
518
|
+
- cron: '0 ${fullUtcHour} * * *'
|
|
519
|
+
workflow_dispatch:
|
|
520
|
+
|
|
521
|
+
jobs:
|
|
522
|
+
full:
|
|
523
|
+
runs-on: ${baseImage}
|
|
524
|
+
timeout-minutes: ${timeoutMinutes * 3}
|
|
525
|
+
env:
|
|
526
|
+
ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
|
|
527
|
+
${stepsYaml}
|
|
528
|
+
|
|
529
|
+
- name: Run all tests
|
|
530
|
+
run: |
|
|
531
|
+
xvfb-run --auto-servernum boi run \\
|
|
532
|
+
--timeout ${timeout} \\
|
|
533
|
+
--output bangonit-output.json --record
|
|
534
|
+
|
|
535
|
+
- name: Upload test results
|
|
536
|
+
if: always()
|
|
537
|
+
uses: actions/upload-artifact@v4
|
|
538
|
+
with:
|
|
539
|
+
name: bangonit-full-results
|
|
540
|
+
path: |
|
|
541
|
+
bangonit-output.json
|
|
542
|
+
recordings/
|
|
543
|
+
if-no-files-found: ignore
|
|
544
|
+
`;
|
|
545
|
+
const outDir = path.join(process.cwd(), ".github", "workflows");
|
|
546
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
547
|
+
const smokePath = path.join(outDir, "bangonit-smoke.yml");
|
|
548
|
+
fs.writeFileSync(smokePath, smokeWorkflow);
|
|
549
|
+
console.log(`\n ${c.green}Created${c.reset} ${path.relative(process.cwd(), smokePath)}`);
|
|
550
|
+
const fullPath = path.join(outDir, "bangonit-full.yml");
|
|
551
|
+
fs.writeFileSync(fullPath, fullWorkflow);
|
|
552
|
+
console.log(` ${c.green}Created${c.reset} ${path.relative(process.cwd(), fullPath)}`);
|
|
553
|
+
console.log(`\n ${c.yellow}Required GitHub secret:${c.reset}`);
|
|
554
|
+
console.log(` ${c.dim} ANTHROPIC_API_KEY${c.reset} — your Anthropic API key`);
|
|
555
|
+
console.log(`\n ${c.dim}Smoke tests run on every push/PR.${c.reset}`);
|
|
556
|
+
console.log(` ${c.dim}Full tests run all test plans daily at 6pm ${tz} (${fullUtcHour}:00 UTC).${c.reset}`);
|
|
557
|
+
}
|
|
558
|
+
p.close();
|
|
559
|
+
console.log("");
|
|
560
|
+
}
|
|
561
|
+
async function run(argv, config) {
|
|
562
|
+
loadEnv();
|
|
563
|
+
// Config can provide the API key (supports ${ENV_VAR} interpolation)
|
|
564
|
+
if (config.anthropic_api_key && !process.env.ANTHROPIC_API_KEY) {
|
|
565
|
+
process.env.ANTHROPIC_API_KEY = config.anthropic_api_key;
|
|
566
|
+
}
|
|
567
|
+
if (!process.env.ANTHROPIC_API_KEY) {
|
|
568
|
+
die("Error: ANTHROPIC_API_KEY is not set.\nSet it in your environment, .env file, or .bangonit/config.toml.");
|
|
569
|
+
}
|
|
570
|
+
const recordingsDir = config.recordings_dir
|
|
571
|
+
? path.resolve(process.cwd(), config.recordings_dir)
|
|
572
|
+
: path.join(process.cwd(), "recordings");
|
|
573
|
+
// Validate test plan files exist before launching anything
|
|
574
|
+
for (const file of argv.files) {
|
|
575
|
+
const absPath = path.isAbsolute(file) ? file : path.join(process.cwd(), file);
|
|
576
|
+
if (!fs.existsSync(absPath)) {
|
|
577
|
+
die(`Test plan file not found: ${file}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
// Discover test plans if no files/plan specified
|
|
581
|
+
const files = [...argv.files];
|
|
582
|
+
if (files.length === 0 && !argv.plan && config.testplans) {
|
|
583
|
+
const testDirPath = path.resolve(process.cwd(), config.testplans);
|
|
584
|
+
const plans = findTestPlans(testDirPath, argv.filter);
|
|
585
|
+
if (plans.length > 0) {
|
|
586
|
+
files.push(...plans);
|
|
587
|
+
}
|
|
588
|
+
else if (argv.filter) {
|
|
589
|
+
die(`No test plans matching "${argv.filter}" found in ${config.testplans}/`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
else if (files.length === 0 && !argv.plan && argv.filter) {
|
|
593
|
+
die(`--filter requires a testplans directory configured in .bangonit/config.toml`);
|
|
594
|
+
}
|
|
595
|
+
// Hint when launching interactive UI with no config
|
|
596
|
+
if (files.length === 0 && !argv.plan) {
|
|
597
|
+
if (!config.testplans) {
|
|
598
|
+
console.log(`${c.dim}No test plans specified. Launching interactive UI.${c.reset}`);
|
|
599
|
+
console.log(`${c.dim}Tip: Run ${c.reset}boi init${c.dim} to set up a config file, or pass test plan files directly.${c.reset}\n`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
// Build structured args for Electron (passed via BANGONIT_ARGS env var)
|
|
603
|
+
const electronArgs = args_1.electronArgsSchema.parse({
|
|
604
|
+
testPlanFiles: files,
|
|
605
|
+
headless: argv.headless,
|
|
606
|
+
exit: argv.exit,
|
|
607
|
+
json: argv.json,
|
|
608
|
+
console: argv.console,
|
|
609
|
+
record: argv.record,
|
|
610
|
+
retries: argv.retries ?? 0,
|
|
611
|
+
output: argv.output || null,
|
|
612
|
+
plan: argv.plan || null,
|
|
613
|
+
prompt: argv.prompt || null,
|
|
614
|
+
concurrency: argv.concurrency ?? 1,
|
|
615
|
+
timeout: argv.timeout ?? 0,
|
|
616
|
+
cwd: process.cwd(),
|
|
617
|
+
recordingsDir: argv.record ? recordingsDir : null,
|
|
618
|
+
});
|
|
619
|
+
const PORT = await getFreePort();
|
|
620
|
+
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
|
621
|
+
const nextDir = path.join(WEBAPP_DIR, ".next");
|
|
622
|
+
const isBuilt = fs.existsSync(nextDir);
|
|
623
|
+
let webappProc;
|
|
624
|
+
if (isBuilt) {
|
|
625
|
+
webappProc = (0, child_process_1.spawn)("npx", ["next", "start", "-p", String(PORT)], {
|
|
626
|
+
cwd: WEBAPP_DIR,
|
|
627
|
+
env: { ...process.env, NODE_ENV: "production" },
|
|
628
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
webappProc = (0, child_process_1.spawn)("npx", ["next", "dev", "-p", String(PORT)], {
|
|
633
|
+
cwd: WEBAPP_DIR,
|
|
634
|
+
env: { ...process.env },
|
|
635
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
const webappLog = fs.createWriteStream(path.join(LOGS_DIR, "webapp.log"));
|
|
639
|
+
webappProc.stdout.pipe(webappLog);
|
|
640
|
+
webappProc.stderr.pipe(webappLog);
|
|
641
|
+
let webappCrashed = false;
|
|
642
|
+
webappProc.on("exit", (code) => {
|
|
643
|
+
if (!webappCrashed && code !== null && code !== 0) {
|
|
644
|
+
webappCrashed = true;
|
|
645
|
+
die(`Webapp server crashed (exit code ${code}). Check logs/webapp.log for details.`);
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
const cleanup = () => {
|
|
649
|
+
webappCrashed = true; // suppress crash message during normal shutdown
|
|
650
|
+
try {
|
|
651
|
+
webappProc.kill();
|
|
652
|
+
}
|
|
653
|
+
catch { }
|
|
654
|
+
};
|
|
655
|
+
process.on("exit", cleanup);
|
|
656
|
+
process.on("SIGINT", () => { cleanup(); process.exit(1); });
|
|
657
|
+
process.on("SIGTERM", () => { cleanup(); process.exit(1); });
|
|
658
|
+
await waitForPort(PORT);
|
|
659
|
+
const electronMain = path.join(DESKTOP_DIR, "dist", "main", "index.js");
|
|
660
|
+
if (!fs.existsSync(electronMain)) {
|
|
661
|
+
try {
|
|
662
|
+
(0, child_process_1.execSync)("npx tsc", { cwd: DESKTOP_DIR, stdio: "inherit" });
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
cleanup();
|
|
666
|
+
die("Failed to compile Electron app");
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
let electronPath;
|
|
670
|
+
try {
|
|
671
|
+
electronPath = require(path.join(DESKTOP_DIR, "node_modules", "electron"));
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
try {
|
|
675
|
+
electronPath = require("electron");
|
|
676
|
+
}
|
|
677
|
+
catch {
|
|
678
|
+
cleanup();
|
|
679
|
+
die("Error: electron not found. Run `npm install` in app/desktopapp.");
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
const electronProc = (0, child_process_1.spawn)(electronPath, ["."], {
|
|
683
|
+
cwd: DESKTOP_DIR,
|
|
684
|
+
env: {
|
|
685
|
+
...process.env,
|
|
686
|
+
[args_1.ELECTRON_ARGS_ENV]: JSON.stringify(electronArgs),
|
|
687
|
+
WEBAPP_URL: `http://localhost:${PORT}/app`,
|
|
688
|
+
NODE_ENV: process.env.NODE_ENV || "production",
|
|
689
|
+
},
|
|
690
|
+
stdio: ["inherit", "inherit", "ignore"],
|
|
691
|
+
});
|
|
692
|
+
electronProc.on("exit", async (code) => {
|
|
693
|
+
// Upload recordings to S3 if configured
|
|
694
|
+
if (config.s3?.bucket && argv.record) {
|
|
695
|
+
if (fs.existsSync(recordingsDir)) {
|
|
696
|
+
const dirs = fs.readdirSync(recordingsDir).filter((d) => fs.existsSync(path.join(recordingsDir, d, "index.html")));
|
|
697
|
+
const s3Prefix = config.s3.prefix || "bangonit";
|
|
698
|
+
for (const dir of dirs) {
|
|
699
|
+
const localPath = path.join(recordingsDir, dir);
|
|
700
|
+
try {
|
|
701
|
+
const url = await uploadToS3(localPath, {
|
|
702
|
+
bucket: config.s3.bucket,
|
|
703
|
+
prefix: `${s3Prefix}/${dir}`,
|
|
704
|
+
region: config.s3.region,
|
|
705
|
+
endpoint: config.s3.endpoint,
|
|
706
|
+
accessKey: config.s3.access_key,
|
|
707
|
+
secretKey: config.s3.secret_key,
|
|
708
|
+
});
|
|
709
|
+
console.log(`\n${c.green}Recording:${c.reset} ${c.cyan}${url}${c.reset}`);
|
|
710
|
+
}
|
|
711
|
+
catch (err) {
|
|
712
|
+
console.error(`\n${c.red}Failed to upload recording:${c.reset} ${err.message}`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
cleanup();
|
|
718
|
+
process.exit(code ?? 1);
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
// --- main ---
|
|
722
|
+
const ciDefaults = is_ci_1.default ? { headless: true, exit: true } : {};
|
|
723
|
+
(0, yargs_1.default)((0, helpers_1.hideBin)(process.argv))
|
|
724
|
+
.scriptName("boi")
|
|
725
|
+
.usage("Usage: $0 <command> [options]")
|
|
726
|
+
.command("init", "Set up Bang On It! (config, test plans, and optionally CI)", {}, () => { initProject(); })
|
|
727
|
+
.command(["run [files..]", "$0"], "Run test plans (or launch interactive UI)", (y) => y
|
|
728
|
+
.positional("files", { type: "string", array: true, default: [], describe: "Test plan files" })
|
|
729
|
+
.option("filter", { alias: "t", type: "string", describe: "Filter test plans by name substring" })
|
|
730
|
+
.option("config", { type: "string", describe: "Path to config file (default: .bangonit/config.toml)" })
|
|
731
|
+
.option("plan", { type: "string", describe: "Inline test plan (instead of file)" })
|
|
732
|
+
.option("prompt", { type: "string", describe: "Additional instructions appended to test plan" })
|
|
733
|
+
.option("record", { type: "boolean", default: false, describe: "Record session replay" })
|
|
734
|
+
.option("retries", { type: "number", describe: "Retry failed tests N times (overrides test plan frontmatter)" })
|
|
735
|
+
.option("headless", { type: "boolean", default: ciDefaults.headless ?? false, describe: "Run without showing the browser window" })
|
|
736
|
+
.option("exit", { type: "boolean", default: ciDefaults.exit ?? false, describe: "Exit immediately after tests complete" })
|
|
737
|
+
.option("json", { type: "boolean", default: false, describe: "Stream NDJSON events to stdout" })
|
|
738
|
+
.option("console", { type: "boolean", default: false, describe: "Forward browser console logs to stdout" })
|
|
739
|
+
.option("output", { type: "string", describe: "Write JSON results to file" })
|
|
740
|
+
.option("concurrency", { type: "number", describe: "Number of parallel agents (default: 1)" })
|
|
741
|
+
.option("timeout", { type: "number", describe: "Test timeout in seconds (0 = none)" }), (argv) => {
|
|
742
|
+
const config = loadConfig(argv.config);
|
|
743
|
+
run({
|
|
744
|
+
files: argv.files,
|
|
745
|
+
filter: argv.filter,
|
|
746
|
+
plan: argv.plan,
|
|
747
|
+
prompt: argv.prompt,
|
|
748
|
+
record: argv.record,
|
|
749
|
+
retries: argv.retries,
|
|
750
|
+
headless: argv.headless,
|
|
751
|
+
exit: argv.exit,
|
|
752
|
+
json: argv.json,
|
|
753
|
+
console: argv.console,
|
|
754
|
+
output: argv.output,
|
|
755
|
+
concurrency: argv.concurrency,
|
|
756
|
+
timeout: argv.timeout,
|
|
757
|
+
}, config);
|
|
758
|
+
})
|
|
759
|
+
.example("$0 run test.md", "Run a test plan file")
|
|
760
|
+
.example("$0 run --plan 'test login flow'", "Run an inline test plan")
|
|
761
|
+
.example("$0 run -t checkout", "Run test plans matching 'checkout'")
|
|
762
|
+
.example("$0 run", "Launch interactive UI")
|
|
763
|
+
.example("$0 init", "Set up config, test plans, and CI")
|
|
764
|
+
.strict()
|
|
765
|
+
.help()
|
|
766
|
+
.alias("h", "help")
|
|
767
|
+
.parse();
|