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.
Files changed (49) hide show
  1. package/README.md +69 -37
  2. package/app/desktopapp/dist/main/index.js +10 -161
  3. package/app/desktopapp/dist/main/ipc.js +0 -20
  4. package/app/desktopapp/dist/main/preload.js +0 -2
  5. package/app/desktopapp/dist/main/tabs.js +0 -10
  6. package/app/desktopapp/dist/shared/args.js +21 -0
  7. package/app/desktopapp/package.json +1 -1
  8. package/app/replay/dist/replay.css +1 -1
  9. package/app/replay/dist/replay.js +20 -20
  10. package/app/webapp/.next/BUILD_ID +1 -1
  11. package/app/webapp/.next/app-build-manifest.json +2 -2
  12. package/app/webapp/.next/app-path-routes-manifest.json +1 -1
  13. package/app/webapp/.next/build-manifest.json +2 -2
  14. package/app/webapp/.next/next-minimal-server.js.nft.json +1 -1
  15. package/app/webapp/.next/next-server.js.nft.json +1 -1
  16. package/app/webapp/.next/prerender-manifest.json +1 -1
  17. package/app/webapp/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/app/webapp/.next/server/app/_not-found.html +1 -1
  19. package/app/webapp/.next/server/app/_not-found.rsc +1 -1
  20. package/app/webapp/.next/server/app/app/page.js +3 -7
  21. package/app/webapp/.next/server/app/app/page_client-reference-manifest.js +1 -1
  22. package/app/webapp/.next/server/app/app.html +1 -1
  23. package/app/webapp/.next/server/app/app.rsc +2 -2
  24. package/app/webapp/.next/server/app/index.html +1 -1
  25. package/app/webapp/.next/server/app/index.rsc +1 -1
  26. package/app/webapp/.next/server/app/page_client-reference-manifest.js +1 -1
  27. package/app/webapp/.next/server/app-paths-manifest.json +2 -2
  28. package/app/webapp/.next/server/chunks/708.js +1 -1
  29. package/app/webapp/.next/server/functions-config-manifest.json +1 -1
  30. package/app/webapp/.next/server/pages/404.html +1 -1
  31. package/app/webapp/.next/server/pages/500.html +1 -1
  32. package/app/webapp/.next/server/server-reference-manifest.json +1 -1
  33. package/app/webapp/.next/static/chunks/app/app/page-0e096497dcb81dae.js +1 -0
  34. package/app/webapp/.next/static/css/{869d3ff23c36c4b5.css → 38219627f55424f2.css} +1 -1
  35. package/app/webapp/.next/trace +2 -2
  36. package/app/webapp/package.json +7 -2
  37. package/app/webapp/src/shared/api/chat.ts +2 -11
  38. package/app/webapp/src/shared/components/AppShell.tsx +21 -1
  39. package/app/webapp/src/shared/components/SessionView.tsx +37 -65
  40. package/app/webapp/src/shared/lib/browser/mouse.ts +1 -1
  41. package/app/webapp/src/shared/lib/browser/recorder.ts +3 -3
  42. package/app/webapp/src/shared/types/global.d.ts +0 -3
  43. package/bin/app/desktopapp/src/shared/args.js +21 -0
  44. package/bin/bangonit.js +259 -96
  45. package/bin/src/cli/bangonit.js +767 -0
  46. package/package.json +6 -4
  47. package/app/webapp/.next/static/chunks/app/app/page-d38c1e48d37def82.js +0 -1
  48. /package/app/webapp/.next/static/{TaLpPsk5rC30wNNcyfUN3 → Qq0OvlQijtcR84Dg9Dgp0}/_buildManifest.js +0 -0
  49. /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();