prowl-tools 0.1.3

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/dist/index.js ADDED
@@ -0,0 +1,1366 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ DEFAULT_FLAKY_THRESHOLD,
4
+ analyzePage,
5
+ generateHunt,
6
+ printCiSummary,
7
+ rankFlaky,
8
+ readHuntHistory,
9
+ runHunt,
10
+ runSuite,
11
+ updateBacklogFromSuite
12
+ } from "./chunk-T7YLXF6X.js";
13
+ import {
14
+ CONFIG_DIR,
15
+ listHunts,
16
+ loadConfig,
17
+ loadHuntMeta,
18
+ loadHuntTags,
19
+ resolveViewport
20
+ } from "./chunk-NXXGJOBG.js";
21
+
22
+ // src/cli/program.ts
23
+ import { Command as Command13 } from "commander";
24
+
25
+ // package.json
26
+ var package_default = {
27
+ name: "prowl-tools",
28
+ version: "0.1.3",
29
+ description: "CLI-first QA testing tool for deterministic Playwright flows.",
30
+ type: "module",
31
+ license: "Apache-2.0",
32
+ author: "Michael Tookes",
33
+ repository: {
34
+ type: "git",
35
+ url: "https://github.com/prowl-tools/prowl.git"
36
+ },
37
+ homepage: "https://prowl.tools",
38
+ bugs: {
39
+ url: "https://github.com/prowl-tools/prowl/issues"
40
+ },
41
+ keywords: [
42
+ "testing",
43
+ "qa",
44
+ "playwright",
45
+ "yaml",
46
+ "e2e",
47
+ "browser-testing",
48
+ "automation",
49
+ "web-testing",
50
+ "cli"
51
+ ],
52
+ main: "dist/lib.cjs",
53
+ module: "dist/lib.js",
54
+ types: "dist/lib.d.ts",
55
+ exports: {
56
+ ".": {
57
+ import: {
58
+ types: "./dist/lib.d.ts",
59
+ default: "./dist/lib.js"
60
+ },
61
+ require: {
62
+ types: "./dist/lib.d.cts",
63
+ default: "./dist/lib.cjs"
64
+ }
65
+ }
66
+ },
67
+ bin: {
68
+ prowl: "dist/index.js"
69
+ },
70
+ files: [
71
+ "dist",
72
+ "examples",
73
+ "LICENSE",
74
+ "README.md",
75
+ "NOTICE"
76
+ ],
77
+ engines: {
78
+ node: ">=20.0.0"
79
+ },
80
+ scripts: {
81
+ build: "tsup",
82
+ lint: "eslint .",
83
+ test: "vitest run",
84
+ "test:watch": "vitest"
85
+ },
86
+ dependencies: {
87
+ "@modelcontextprotocol/sdk": "^1.29.0",
88
+ chalk: "^5.3.0",
89
+ commander: "^12.1.0",
90
+ dotenv: "^16.6.1",
91
+ ora: "^8.1.1",
92
+ pixelmatch: "^7.1.0",
93
+ playwright: "^1.50.1",
94
+ pngjs: "^7.0.0",
95
+ yaml: "^2.6.1",
96
+ zod: "^3.23.8"
97
+ },
98
+ devDependencies: {
99
+ "@types/node": "^22.13.1",
100
+ "@types/pngjs": "^6.0.5",
101
+ "@typescript-eslint/eslint-plugin": "^7.18.0",
102
+ "@typescript-eslint/parser": "^7.18.0",
103
+ eslint: "^8.57.1",
104
+ tsup: "^8.3.5",
105
+ typescript: "^5.7.3",
106
+ vitest: "^2.1.8"
107
+ }
108
+ };
109
+
110
+ // src/cli/commands/run.ts
111
+ import { Command } from "commander";
112
+ import chalk3 from "chalk";
113
+
114
+ // src/cli/output.ts
115
+ import chalk from "chalk";
116
+ function describeStep(step) {
117
+ if ("navigate" in step) return `navigate "${step.navigate}"`;
118
+ if ("click" in step) {
119
+ if (typeof step.click === "string") return `click "${step.click}"`;
120
+ return `click "${step.click.selector}"`;
121
+ }
122
+ if ("fill" in step) {
123
+ if ("selector" in step.fill && "value" in step.fill) {
124
+ return `fill "${step.fill.selector}"`;
125
+ }
126
+ const label = Object.keys(step.fill)[0];
127
+ return `fill "${label}"`;
128
+ }
129
+ if ("type" in step) return `type "${truncate(step.type, 20)}"`;
130
+ if ("selectOption" in step) return `selectOption "${step.selectOption.selector}"`;
131
+ if ("select" in step) {
132
+ const label = Object.keys(step.select)[0];
133
+ return `select "${label}"`;
134
+ }
135
+ if ("onDialog" in step) return `onDialog ${step.onDialog.action}`;
136
+ if ("setInputFiles" in step) return `setInputFiles "${step.setInputFiles.selector}"`;
137
+ if ("runHunt" in step) {
138
+ const name = typeof step.runHunt === "string" ? step.runHunt : step.runHunt.name;
139
+ return `runHunt "${name}"`;
140
+ }
141
+ if ("press" in step) return `press "${step.press.key}"`;
142
+ if ("assert" in step) {
143
+ const a = step.assert;
144
+ if (a.visible) return `assert visible "${a.visible}"`;
145
+ if (a.notVisible) return `assert notVisible "${a.notVisible}"`;
146
+ if (a.urlIncludes) return `assert urlIncludes "${a.urlIncludes}"`;
147
+ if (a.urlEquals) return `assert urlEquals "${a.urlEquals}"`;
148
+ return "assert";
149
+ }
150
+ if ("wait" in step) {
151
+ if (typeof step.wait === "string") return `wait "${step.wait}"`;
152
+ return `wait "${step.wait.for}"`;
153
+ }
154
+ if ("waitForSelector" in step) return `waitForSelector "${step.waitForSelector.selector}"`;
155
+ if ("waitForUrl" in step) return `waitForUrl "${step.waitForUrl.value}"`;
156
+ if ("waitForNetworkIdle" in step) return "waitForNetworkIdle";
157
+ if ("hover" in step) return `hover "${step.hover.selector}"`;
158
+ if ("scroll" in step) return `scroll ${step.scroll.direction} ${step.scroll.amount ?? 500}px`;
159
+ if ("scrollTo" in step) return `scrollTo "${step.scrollTo.selector}"`;
160
+ if ("screenshot" in step) return `screenshot "${step.screenshot.name ?? "auto"}"`;
161
+ if ("if" in step) {
162
+ if (step.if.visible !== void 0) return `if visible "${step.if.visible}"`;
163
+ if (step.if.notVisible !== void 0) return `if notVisible "${step.if.notVisible}"`;
164
+ return "if condition unspecified";
165
+ }
166
+ if ("repeat" in step) {
167
+ if (step.repeat.times !== void 0) return `repeat ${step.repeat.times} times`;
168
+ if (step.repeat.while?.visible !== void 0) {
169
+ return `repeat while visible "${step.repeat.while.visible}"`;
170
+ }
171
+ if (step.repeat.while?.notVisible !== void 0) {
172
+ return `repeat while not visible "${step.repeat.while.notVisible}"`;
173
+ }
174
+ return "repeat while condition unspecified";
175
+ }
176
+ if ("mockRoute" in step) return `mockRoute "${step.mockRoute.url}"`;
177
+ if ("unmockRoute" in step) return `unmockRoute "${step.unmockRoute.url}"`;
178
+ if ("evalScript" in step) {
179
+ if (typeof step.evalScript === "string") return `evalScript "${truncate(step.evalScript, 40)}"`;
180
+ const asLabel = step.evalScript.as ? ` as ${step.evalScript.as}` : "";
181
+ return `evalScript "${truncate(step.evalScript.expression, 40)}"${asLabel}`;
182
+ }
183
+ if ("runScript" in step) return `runScript "${step.runScript.file}"`;
184
+ if ("assertScreenshot" in step) {
185
+ const th = step.assertScreenshot.threshold !== void 0 ? ` (threshold: ${step.assertScreenshot.threshold})` : "";
186
+ return `assertScreenshot "${step.assertScreenshot.name}"${th}`;
187
+ }
188
+ return "unknown step";
189
+ }
190
+ function truncate(text, max) {
191
+ if (text.length <= max) return text;
192
+ return text.slice(0, max - 1) + "\u2026";
193
+ }
194
+ function printHuntHeader(huntName) {
195
+ console.log(`
196
+ ${chalk.cyan("\u25CF")} ${chalk.bold("Running hunt:")} ${huntName}`);
197
+ }
198
+ function printStepResult(result, step, _index) {
199
+ const label = step ? describeStep(step) : result.type;
200
+ const duration = chalk.gray(`(${result.durationMs}ms)`);
201
+ if (result.status === "pass") {
202
+ console.log(` ${chalk.green("\u2713")} ${label} ${duration}`);
203
+ } else {
204
+ const error = result.error ? chalk.gray(` \u2014 ${result.error}`) : "";
205
+ console.log(` ${chalk.red("\u2717")} ${label} ${duration}${error}`);
206
+ }
207
+ }
208
+ function printHuntSummary(result, runDir) {
209
+ const passed = result.steps.filter((s) => s.status === "pass").length;
210
+ const total = result.steps.length;
211
+ const status = result.status === "pass" ? chalk.green.bold("PASS") : chalk.red.bold("FAIL");
212
+ const stepCount = chalk.gray(`${passed}/${total} steps`);
213
+ const duration = chalk.gray(`(${result.durationMs}ms)`);
214
+ console.log(`
215
+ ${status} ${chalk.bold(result.hunt)} ${duration} ${stepCount}`);
216
+ console.log(` ${chalk.gray("Artifacts:")} ${runDir}
217
+ `);
218
+ }
219
+
220
+ // src/cli/mascot.ts
221
+ import chalk2 from "chalk";
222
+ var FIGLET_LOGO = [
223
+ " ____ ____ ___ _ _ _ ___ _",
224
+ "| _ \\| _ \\ / _ \\| | | | | / _ \\ / \\",
225
+ "| |_) | |_) | | | | | | | | | | | |/ _ \\",
226
+ "| __/| _ <| |_| | |/\\| | |__| |_| / ___ \\",
227
+ "|_| |_| \\_\\\\___/|_/ \\_\\____\\\\__\\_\\_/ \\_\\"
228
+ ];
229
+ function welcomeBanner() {
230
+ const logo = FIGLET_LOGO.map((line) => chalk2.cyan(line)).join("\n");
231
+ return `
232
+ ${logo}
233
+
234
+ ${chalk2.gray("QA testing for the web")}
235
+ `;
236
+ }
237
+ function resultMascot(state, huntName) {
238
+ const isPassing = state === "pass";
239
+ const icon = isPassing ? "\u2713" : "\u2717";
240
+ const label = isPassing ? "PASS" : "FAIL";
241
+ const color = isPassing ? chalk2.green : chalk2.red;
242
+ const content = ` ${icon} ${label} ${huntName} `;
243
+ const innerWidth = content.length;
244
+ const top = ` \u250C${"\u2500".repeat(innerWidth)}\u2510`;
245
+ const mid = ` \u2502${content}\u2502`;
246
+ const bot = ` \u2514${"\u2500".repeat(innerWidth)}\u2518`;
247
+ return color(`${top}
248
+ ${mid}
249
+ ${bot}`);
250
+ }
251
+
252
+ // src/cli/commands/run.ts
253
+ function buildRunCommand() {
254
+ const command = new Command("run").argument("<hunt-name>", "Hunt name or path (e.g. homepage or admin/users-crud)").option("--url <target>", "Override target URL").option("--headed", "Show browser window").option("--slow-mo <ms>", "Slow down Playwright actions", (value) => Number(value)).option("--trace", "Capture Playwright trace").option("--browser <engine>", "Browser engine: chromium, firefox, or webkit").option("--channel <name>", "Browser channel: chrome, msedge, chrome-beta, etc.").option("--viewport <size>", "Viewport size: WxH (e.g. 1920x1080) or preset (mobile, tablet, desktop)").option("--include-tags <tags>", "Only run hunts matching these tags (comma-separated)").option("--exclude-tags <tags>", "Skip hunts matching these tags (comma-separated)").option("--junit", "Generate JUnit XML report").option("--config <path>", "Custom config path").option("--json", "Output results as JSON").action(async (huntName, options) => {
255
+ try {
256
+ if (options.includeTags || options.excludeTags) {
257
+ const { configDir } = loadConfig(options.config);
258
+ const tags = loadHuntTags(huntName, configDir);
259
+ const includeTags = options.includeTags ? options.includeTags.split(",").map((t) => t.trim()) : void 0;
260
+ const excludeTags = options.excludeTags ? options.excludeTags.split(",").map((t) => t.trim()) : void 0;
261
+ if (includeTags && !includeTags.some((t) => tags.includes(t))) {
262
+ if (options.json) {
263
+ console.log(JSON.stringify({ status: "skipped", hunt: huntName, reason: "no matching include tags" }));
264
+ } else {
265
+ console.log(chalk3.yellow(` Skipped "${huntName}" \u2014 no matching include tags`));
266
+ }
267
+ return;
268
+ }
269
+ if (excludeTags && excludeTags.some((t) => tags.includes(t))) {
270
+ if (options.json) {
271
+ console.log(JSON.stringify({ status: "skipped", hunt: huntName, reason: "matched exclude tags" }));
272
+ } else {
273
+ console.log(chalk3.yellow(` Skipped "${huntName}" \u2014 matched exclude tags`));
274
+ }
275
+ return;
276
+ }
277
+ }
278
+ if (!options.json) {
279
+ printHuntHeader(huntName);
280
+ }
281
+ const { result, runDir } = await runHunt({
282
+ huntName,
283
+ urlOverride: options.url,
284
+ headed: Boolean(options.headed),
285
+ slowMo: Number.isFinite(options.slowMo) ? options.slowMo : void 0,
286
+ trace: Boolean(options.trace),
287
+ browser: options.browser,
288
+ channel: options.channel,
289
+ viewport: options.viewport,
290
+ junit: Boolean(options.junit),
291
+ configPath: options.config,
292
+ onStep: options.json ? void 0 : (stepResult, step, index) => {
293
+ printStepResult(stepResult, step, index);
294
+ }
295
+ });
296
+ if (options.json) {
297
+ console.log(JSON.stringify(result, null, 2));
298
+ } else {
299
+ console.log(resultMascot(result.status, huntName));
300
+ printHuntSummary(result, runDir);
301
+ }
302
+ process.exitCode = result.exitCode;
303
+ } catch (error) {
304
+ const message = error instanceof Error ? error.message : "Run failed";
305
+ if (options.json) {
306
+ console.log(JSON.stringify({ status: "fail", exitCode: 1, hunt: huntName, error: message }));
307
+ } else {
308
+ console.log(resultMascot("fail", huntName));
309
+ console.error(`
310
+ Error: ${message}
311
+ `);
312
+ }
313
+ process.exitCode = 1;
314
+ }
315
+ });
316
+ return command;
317
+ }
318
+
319
+ // src/cli/commands/init.ts
320
+ import fs from "fs";
321
+ import path from "path";
322
+ import { fileURLToPath } from "url";
323
+ import { Command as Command2 } from "commander";
324
+ import chalk4 from "chalk";
325
+ function getPackageRoot() {
326
+ const currentFile = fileURLToPath(import.meta.url);
327
+ let dir = path.dirname(currentFile);
328
+ const root = path.parse(dir).root;
329
+ while (dir !== root) {
330
+ if (fs.existsSync(path.join(dir, "package.json"))) {
331
+ return dir;
332
+ }
333
+ dir = path.dirname(dir);
334
+ }
335
+ if (fs.existsSync(path.join(root, "package.json"))) {
336
+ return root;
337
+ }
338
+ throw new Error("Cannot find package root. Reinstall prowl-tools.");
339
+ }
340
+ function copyFile(source, destination) {
341
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
342
+ fs.copyFileSync(source, destination);
343
+ }
344
+ function buildInitCommand() {
345
+ const command = new Command2("init").option("--force", `Overwrite existing ${CONFIG_DIR} directory`).action((options) => {
346
+ const root = process.cwd();
347
+ const prowlDir = path.join(root, CONFIG_DIR);
348
+ if (fs.existsSync(prowlDir) && !options.force) {
349
+ console.error(
350
+ chalk4.red(
351
+ `${CONFIG_DIR} already exists. Run with --force to reinitialize prowl configuration without deleting existing files.`
352
+ )
353
+ );
354
+ process.exitCode = 1;
355
+ return;
356
+ }
357
+ const packageRoot = getPackageRoot();
358
+ const examplesDir = path.join(packageRoot, "examples");
359
+ const exampleConfig = path.join(examplesDir, "config.yml");
360
+ const exampleHuntsDir = path.join(examplesDir, "hunts");
361
+ if (!fs.existsSync(exampleConfig) || !fs.existsSync(exampleHuntsDir)) {
362
+ console.error(chalk4.red("Examples not found in package. Reinstall prowl-tools."));
363
+ process.exitCode = 1;
364
+ return;
365
+ }
366
+ copyFile(exampleConfig, path.join(prowlDir, "config.yml"));
367
+ const huntFiles = fs.readdirSync(exampleHuntsDir).filter((f) => f.endsWith(".yml"));
368
+ for (const huntFile of huntFiles) {
369
+ copyFile(
370
+ path.join(exampleHuntsDir, huntFile),
371
+ path.join(prowlDir, "hunts", huntFile)
372
+ );
373
+ }
374
+ const gitignore = [
375
+ "# Run artifacts (screenshots, logs, reports)",
376
+ "runs/",
377
+ "",
378
+ "# Auth state (tokens, cookies)",
379
+ "auth-state.json",
380
+ "",
381
+ "# Environment variables (credentials)",
382
+ ".env",
383
+ ""
384
+ ].join("\n");
385
+ fs.writeFileSync(path.join(prowlDir, ".gitignore"), gitignore);
386
+ console.log(welcomeBanner());
387
+ console.log(chalk4.green(` Initialized ${CONFIG_DIR} directory.`));
388
+ console.log(chalk4.gray(" Run ") + chalk4.bold("prowl run hello") + chalk4.gray(" to get started."));
389
+ console.log(chalk4.gray(" Browse hunt templates at ") + chalk4.cyan("https://hub.prowl.tools") + "\n");
390
+ });
391
+ return command;
392
+ }
393
+
394
+ // src/cli/commands/login.ts
395
+ import path2 from "path";
396
+ import readline from "readline";
397
+ import chalk5 from "chalk";
398
+ import { Command as Command3 } from "commander";
399
+ import { chromium } from "playwright";
400
+ function resolvePath(configDir, inputPath) {
401
+ if (path2.isAbsolute(inputPath)) {
402
+ return inputPath;
403
+ }
404
+ const projectRoot = path2.dirname(configDir);
405
+ return path2.join(projectRoot, inputPath);
406
+ }
407
+ function waitForEnter(prompt) {
408
+ return new Promise((resolve) => {
409
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
410
+ rl.question(prompt, () => {
411
+ rl.close();
412
+ resolve();
413
+ });
414
+ });
415
+ }
416
+ function buildLoginCommand() {
417
+ const command = new Command3("login").option("--url <target>", "Override target URL").option("--config <path>", "Custom config path").action(async (options) => {
418
+ let browser = null;
419
+ let context = null;
420
+ try {
421
+ const { config, configDir } = loadConfig(options.config);
422
+ const targetUrl = options.url ?? config.target.url;
423
+ const storageStatePath = config.auth.storageStatePath ? resolvePath(configDir, config.auth.storageStatePath) : resolvePath(configDir, ".prowl/auth-state.json");
424
+ browser = await chromium.launch({ headless: false });
425
+ context = await browser.newContext();
426
+ const page = await context.newPage();
427
+ await page.goto(targetUrl);
428
+ console.log(chalk5.green("Browser opened. Log in manually."));
429
+ await waitForEnter("Press Enter to save auth state and close the browser... ");
430
+ await context.storageState({ path: storageStatePath });
431
+ console.log(chalk5.green(`Saved auth state to ${storageStatePath}`));
432
+ } catch (error) {
433
+ const message = error instanceof Error ? error.message : "Login failed";
434
+ console.error(chalk5.red(`Error: ${message}`));
435
+ process.exitCode = 1;
436
+ } finally {
437
+ if (context) {
438
+ await context.close();
439
+ }
440
+ if (browser) {
441
+ await browser.close();
442
+ }
443
+ }
444
+ });
445
+ return command;
446
+ }
447
+
448
+ // src/cli/commands/list.ts
449
+ import { Command as Command4 } from "commander";
450
+ import chalk6 from "chalk";
451
+ function buildListCommand() {
452
+ const command = new Command4("list").option("--config <path>", "Custom config path").option("--json", "Output as JSON array").action((options) => {
453
+ try {
454
+ const { configDir } = loadConfig(options.config);
455
+ const hunts = listHunts(configDir);
456
+ if (hunts.length === 0) {
457
+ const huntsPath = `${configDir}/hunts`;
458
+ console.log(chalk6.yellow(`No hunts found in ${huntsPath}.`));
459
+ return;
460
+ }
461
+ const metas = hunts.map((name) => ({
462
+ name,
463
+ ...loadHuntMeta(name, configDir)
464
+ }));
465
+ if (options.json) {
466
+ console.log(JSON.stringify(metas, null, 2));
467
+ return;
468
+ }
469
+ const maxName = Math.max(...metas.map((m) => m.name.length));
470
+ metas.forEach((m) => {
471
+ const padded = m.name.padEnd(maxName);
472
+ const desc = m.description ? ` ${truncate(m.description, 40)}` : "";
473
+ const tags = m.tags.length > 0 ? chalk6.gray(` [${m.tags.join(", ")}]`) : "";
474
+ console.log(` ${padded}${desc}${tags}`);
475
+ });
476
+ } catch (error) {
477
+ const message = error instanceof Error ? error.message : "List failed";
478
+ console.error(chalk6.red(`Error: ${message}`));
479
+ process.exitCode = 1;
480
+ }
481
+ });
482
+ return command;
483
+ }
484
+
485
+ // src/cli/commands/watch.ts
486
+ import fs2 from "fs";
487
+ import chalk7 from "chalk";
488
+ import { Command as Command5 } from "commander";
489
+
490
+ // src/cli/watch-utils.ts
491
+ import path3 from "path";
492
+ function getWatchTargets(configDir, huntName) {
493
+ return [
494
+ path3.join(configDir, "hunts", `${huntName}.yml`),
495
+ path3.join(configDir, "config.yml"),
496
+ path3.join(configDir, ".env")
497
+ ];
498
+ }
499
+ function createDebouncer(delayMs, fn) {
500
+ let timer;
501
+ return {
502
+ trigger: () => {
503
+ if (timer) {
504
+ clearTimeout(timer);
505
+ }
506
+ timer = setTimeout(() => {
507
+ timer = void 0;
508
+ fn();
509
+ }, delayMs);
510
+ },
511
+ cancel: () => {
512
+ if (timer) {
513
+ clearTimeout(timer);
514
+ timer = void 0;
515
+ }
516
+ }
517
+ };
518
+ }
519
+
520
+ // src/cli/commands/watch.ts
521
+ function buildWatchCommand() {
522
+ const command = new Command5("watch").argument("<hunt-name>", "Hunt name or path (e.g. homepage or admin/users-crud)").option("--url <target>", "Override target URL").option("--headed", "Show browser window").option("--slow-mo <ms>", "Slow down Playwright actions", (value) => Number(value)).option("--trace", "Capture Playwright trace").option("--config <path>", "Custom config path").action(async (huntName, options) => {
523
+ const { configDir } = loadConfig(options.config);
524
+ const watchTargets = getWatchTargets(configDir, huntName);
525
+ let running = false;
526
+ let pending = false;
527
+ let stopped = false;
528
+ const runOnce = async () => {
529
+ if (stopped) {
530
+ return;
531
+ }
532
+ if (running) {
533
+ pending = true;
534
+ return;
535
+ }
536
+ running = true;
537
+ do {
538
+ pending = false;
539
+ try {
540
+ printHuntHeader(huntName);
541
+ const { result, runDir } = await runHunt({
542
+ huntName,
543
+ urlOverride: options.url,
544
+ headed: Boolean(options.headed),
545
+ slowMo: Number.isFinite(options.slowMo) ? options.slowMo : void 0,
546
+ trace: Boolean(options.trace),
547
+ configPath: options.config,
548
+ onStep(stepResult, step, index) {
549
+ printStepResult(stepResult, step, index);
550
+ }
551
+ });
552
+ printHuntSummary(result, runDir);
553
+ } catch (error) {
554
+ const message = error instanceof Error ? error.message : "Run failed";
555
+ console.error(chalk7.red(`Error: ${message}`));
556
+ }
557
+ } while (pending && !stopped);
558
+ running = false;
559
+ };
560
+ const debounced = createDebouncer(300, () => {
561
+ void runOnce();
562
+ });
563
+ const unwatch = [];
564
+ for (const target of watchTargets) {
565
+ fs2.watchFile(target, { interval: 150 }, (curr, prev) => {
566
+ if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) {
567
+ return;
568
+ }
569
+ console.log(chalk7.gray(`Change detected: ${target}`));
570
+ debounced.trigger();
571
+ });
572
+ unwatch.push(() => fs2.unwatchFile(target));
573
+ }
574
+ const stop = () => {
575
+ if (stopped) {
576
+ return;
577
+ }
578
+ stopped = true;
579
+ debounced.cancel();
580
+ unwatch.forEach((dispose) => dispose());
581
+ process.off("SIGINT", stop);
582
+ console.log(chalk7.yellow("\nWatch stopped."));
583
+ process.exit(0);
584
+ };
585
+ process.on("SIGINT", stop);
586
+ console.log(chalk7.gray(`Watching hunt: ${huntName}`));
587
+ console.log(chalk7.gray(`Files: ${watchTargets.join(", ")}`));
588
+ await runOnce();
589
+ });
590
+ return command;
591
+ }
592
+
593
+ // src/cli/commands/ci.ts
594
+ import { Command as Command6 } from "commander";
595
+ import chalk8 from "chalk";
596
+ function parseTagList(value, flag) {
597
+ if (value === void 0) return void 0;
598
+ const tags = value.split(",").map((tag) => tag.trim()).filter(Boolean);
599
+ if (tags.length === 0) {
600
+ throw new Error(`${flag} requires at least one non-empty tag`);
601
+ }
602
+ return tags;
603
+ }
604
+ function printFailureDetails(results) {
605
+ for (const hunt of results) {
606
+ if (hunt.status === "fail" && hunt.error) {
607
+ console.error(` ${chalk8.red("Error")} ${hunt.hunt}: ${hunt.error}`);
608
+ }
609
+ }
610
+ }
611
+ function buildCiCommand() {
612
+ const command = new Command6("ci").description("Run all hunts and produce a combined pass/fail result for CI pipelines").option("--config <path>", "Custom config path").option("--url <target>", "Override target URL").option("--headed", "Show browser window").option("--slow-mo <ms>", "Slow down Playwright actions", (value) => Number(value)).option("--trace", "Capture Playwright traces").option("--browser <engine>", "Browser engine: chromium, firefox, or webkit").option("--channel <name>", "Browser channel: chrome, msedge, chrome-beta, etc.").option("--viewport <size>", "Viewport size: WxH (e.g. 1920x1080) or preset (mobile, tablet, desktop)").option("--junit", "Generate JUnit XML reports").option("--include-tags <tags>", "Only run hunts matching these tags (comma-separated)").option("--exclude-tags <tags>", "Skip hunts matching these tags (comma-separated)").option("--json", "Output results as JSON").option("--parallel <count>", "Run hunts in parallel with N workers", (value) => {
613
+ const n = Number(value);
614
+ if (!Number.isInteger(n) || n < 1) {
615
+ throw new Error("--parallel must be a positive integer");
616
+ }
617
+ return n;
618
+ }).action(async (options) => {
619
+ const includeTags = parseTagList(options.includeTags, "--include-tags");
620
+ const excludeTags = parseTagList(options.excludeTags, "--exclude-tags");
621
+ const parallel = options.parallel;
622
+ const isParallel = parallel !== void 0 && parallel > 1;
623
+ const showProgress = !options.json && !isParallel;
624
+ const { result, resultPath } = await runSuite({
625
+ configPath: options.config,
626
+ urlOverride: options.url,
627
+ headed: Boolean(options.headed),
628
+ slowMo: Number.isFinite(options.slowMo) ? options.slowMo : void 0,
629
+ trace: Boolean(options.trace),
630
+ browser: options.browser,
631
+ channel: options.channel,
632
+ viewport: options.viewport,
633
+ junit: Boolean(options.junit),
634
+ includeTags,
635
+ excludeTags,
636
+ parallel,
637
+ hooks: {
638
+ onHuntStart: showProgress ? (huntName) => printHuntHeader(huntName) : void 0,
639
+ onStep: showProgress ? (stepResult, step, index) => printStepResult(stepResult, step, index) : void 0,
640
+ onHuntSuccess: showProgress ? (huntName, runResult, runDir) => {
641
+ console.log(resultMascot(runResult.status, huntName));
642
+ printHuntSummary(runResult, runDir);
643
+ } : void 0,
644
+ onHuntFailure: showProgress ? (huntName, message) => {
645
+ console.log(resultMascot("fail", huntName));
646
+ console.error(`
647
+ Error: ${message}
648
+ `);
649
+ } : void 0,
650
+ onHuntSkipped: options.json ? void 0 : (huntName, reason) => {
651
+ const why = reason === "include" ? "no matching include tags" : "matched exclude tags";
652
+ console.log(chalk8.yellow(` \u25CB Skipped "${huntName}" \u2014 ${why}`));
653
+ }
654
+ }
655
+ });
656
+ if (result.status === "no-hunts") {
657
+ if (options.json) {
658
+ console.log(JSON.stringify(result, null, 2));
659
+ } else {
660
+ console.log(chalk8.yellow("\n No hunts found. Create hunts in .prowl/hunts/\n"));
661
+ }
662
+ process.exitCode = 2;
663
+ return;
664
+ }
665
+ if (options.json) {
666
+ console.log(JSON.stringify(result, null, 2));
667
+ } else {
668
+ printCiSummary(result.hunts, result.durationMs, result.flaky, result.clusters);
669
+ if (!showProgress) {
670
+ printFailureDetails(result.hunts);
671
+ }
672
+ if (resultPath) {
673
+ console.log(`
674
+ CI Result: ${chalk8.gray(resultPath)}
675
+ `);
676
+ }
677
+ if (result.status === "all-skipped") {
678
+ console.log(chalk8.yellow(" All hunts were skipped by tag filters.\n"));
679
+ }
680
+ }
681
+ if (result.status === "fail") {
682
+ process.exitCode = 1;
683
+ } else if (result.status === "all-skipped") {
684
+ process.exitCode = 2;
685
+ } else {
686
+ process.exitCode = 0;
687
+ }
688
+ });
689
+ return command;
690
+ }
691
+
692
+ // src/cli/commands/update-baselines.ts
693
+ import fs3 from "fs";
694
+ import path4 from "path";
695
+ import { Command as Command7 } from "commander";
696
+ import chalk9 from "chalk";
697
+ function buildUpdateBaselinesCommand() {
698
+ const command = new Command7("update-baselines").description("Accept current screenshots as new visual regression baselines").option("--run <dir>", "Specific run directory to use").option("--name <name>", "Update only a specific baseline by name").option("--config <path>", "Custom config path").option("-y, --yes", "Skip confirmation prompt").action(async (options) => {
699
+ try {
700
+ const { configDir } = loadConfig(options.config);
701
+ const baselinesDir = path4.join(configDir, "baselines");
702
+ fs3.mkdirSync(baselinesDir, { recursive: true });
703
+ let runDir;
704
+ if (options.run) {
705
+ runDir = path4.isAbsolute(options.run) ? options.run : path4.resolve(options.run);
706
+ } else {
707
+ const runsDir = path4.join(configDir, "runs");
708
+ if (!fs3.existsSync(runsDir)) {
709
+ console.error(chalk9.red(" No runs directory found. Run a hunt first."));
710
+ process.exitCode = 1;
711
+ return;
712
+ }
713
+ const entries = fs3.readdirSync(runsDir).filter((e) => fs3.statSync(path4.join(runsDir, e)).isDirectory()).sort().reverse();
714
+ if (entries.length === 0) {
715
+ console.error(chalk9.red(" No run directories found. Run a hunt first."));
716
+ process.exitCode = 1;
717
+ return;
718
+ }
719
+ runDir = path4.join(runsDir, entries[0]);
720
+ }
721
+ const screenshotsDir = path4.join(runDir, "screenshots");
722
+ if (!fs3.existsSync(screenshotsDir)) {
723
+ console.error(chalk9.red(` No screenshots found in ${runDir}`));
724
+ process.exitCode = 1;
725
+ return;
726
+ }
727
+ const screenshots = fs3.readdirSync(screenshotsDir).filter((f) => f.endsWith("-current.png"));
728
+ if (screenshots.length === 0) {
729
+ console.log(chalk9.yellow(" No assertScreenshot results found in this run."));
730
+ return;
731
+ }
732
+ const nameFilter = options.name;
733
+ const filtered = nameFilter ? screenshots.filter((f) => f === `${nameFilter}-current.png`) : screenshots;
734
+ if (filtered.length === 0) {
735
+ console.log(chalk9.yellow(` No screenshot matching "${nameFilter}" found.`));
736
+ return;
737
+ }
738
+ let updated = 0;
739
+ for (const file of filtered) {
740
+ const baselineName = file.replace("-current.png", ".png");
741
+ const sourcePath = path4.join(screenshotsDir, file);
742
+ const destPath = path4.join(baselinesDir, baselineName);
743
+ const exists = fs3.existsSync(destPath);
744
+ fs3.copyFileSync(sourcePath, destPath);
745
+ updated++;
746
+ const status = exists ? chalk9.yellow("updated") : chalk9.green("created");
747
+ console.log(` ${status} ${baselineName}`);
748
+ }
749
+ console.log(chalk9.green(`
750
+ ${updated} baseline(s) updated in ${baselinesDir}
751
+ `));
752
+ } catch (error) {
753
+ const message = error instanceof Error ? error.message : "Update baselines failed";
754
+ console.error(`
755
+ Error: ${message}
756
+ `);
757
+ process.exitCode = 1;
758
+ }
759
+ });
760
+ return command;
761
+ }
762
+
763
+ // src/cli/commands/analyze.ts
764
+ import { Command as Command8 } from "commander";
765
+ import chalk10 from "chalk";
766
+ import { chromium as chromium2, firefox, webkit } from "playwright";
767
+ var ENGINES = { chromium: chromium2, firefox, webkit };
768
+ function parseViewportFlag(value) {
769
+ const match = /^(\d+)x(\d+)$/i.exec(value);
770
+ if (match) {
771
+ return { width: Number(match[1]), height: Number(match[2]) };
772
+ }
773
+ return value;
774
+ }
775
+ function buildAnalyzeCommand() {
776
+ const command = new Command8("analyze").argument("<url>", "URL to analyze").description("Analyze a page to discover interactive elements and selectors").option("--json", "Output as JSON").option("--browser <engine>", "Browser engine: chromium, firefox, or webkit").option("--channel <name>", "Browser channel: chrome, msedge, etc.").option("--viewport <size>", "Viewport size: WxH or preset (mobile, tablet, desktop)").option("--headed", "Show browser window").option("--config <path>", "Custom config path").action(async (url, options) => {
777
+ try {
778
+ const engine = options.browser ?? "chromium";
779
+ const channel = options.channel;
780
+ const viewport = options.viewport ? resolveViewport(parseViewportFlag(options.viewport)) : { width: 1280, height: 720 };
781
+ const browserEngine = ENGINES[engine];
782
+ const browser = await browserEngine.launch({
783
+ headless: !options.headed,
784
+ channel
785
+ });
786
+ const context = await browser.newContext({ viewport });
787
+ const page = await context.newPage();
788
+ try {
789
+ await page.goto(url, { waitUntil: "networkidle" });
790
+ const result = await analyzePage(page);
791
+ if (options.json) {
792
+ console.log(JSON.stringify(result, null, 2));
793
+ } else {
794
+ console.log(`
795
+ ${chalk10.bold("Page Analysis:")} ${result.title}`);
796
+ console.log(` ${chalk10.gray("URL:")} ${result.url}
797
+ `);
798
+ if (result.forms.length > 0) {
799
+ console.log(chalk10.bold(" Forms:"));
800
+ for (const form of result.forms) {
801
+ const method = form.method ? chalk10.cyan(form.method) : "";
802
+ const action = form.action ? chalk10.gray(form.action) : "";
803
+ console.log(` [${form.index}] ${method} ${action} (${form.fieldCount} fields)`);
804
+ }
805
+ console.log();
806
+ }
807
+ if (result.elements.length > 0) {
808
+ console.log(chalk10.bold(" Interactive Elements:"));
809
+ for (const el of result.elements) {
810
+ const tag = chalk10.cyan(el.tag);
811
+ const type = el.type ? chalk10.gray(`[${el.type}]`) : "";
812
+ const bestSelector = el.selectors.testId ?? el.selectors.label ?? el.selectors.ariaLabel ?? el.selectors.css ?? el.selectors.name ?? "";
813
+ const selectorStr = bestSelector ? chalk10.yellow(bestSelector) : chalk10.gray("(no selector)");
814
+ const req = el.required ? chalk10.red(" *") : "";
815
+ const form = el.formGroup !== void 0 ? chalk10.gray(` form[${el.formGroup}]`) : "";
816
+ console.log(` ${tag}${type} ${selectorStr}${req}${form}`);
817
+ }
818
+ console.log();
819
+ }
820
+ if (result.links.length > 0) {
821
+ console.log(chalk10.bold(" Links:"));
822
+ for (const link of result.links.slice(0, 20)) {
823
+ const text = link.text || chalk10.gray("(no text)");
824
+ console.log(` ${text} ${chalk10.gray("\u2192")} ${chalk10.blue(link.href)}`);
825
+ }
826
+ if (result.links.length > 20) {
827
+ console.log(chalk10.gray(` ... and ${result.links.length - 20} more`));
828
+ }
829
+ console.log();
830
+ }
831
+ console.log(chalk10.gray(` ${result.elements.length} elements, ${result.forms.length} forms, ${result.links.length} links
832
+ `));
833
+ }
834
+ } finally {
835
+ await context.close();
836
+ await browser.close();
837
+ }
838
+ } catch (error) {
839
+ const message = error instanceof Error ? error.message : "Analysis failed";
840
+ if (options.json) {
841
+ console.log(JSON.stringify({ error: message }));
842
+ } else {
843
+ console.error(`
844
+ Error: ${message}
845
+ `);
846
+ }
847
+ process.exitCode = 1;
848
+ }
849
+ });
850
+ return command;
851
+ }
852
+
853
+ // src/cli/commands/generate.ts
854
+ import fs4 from "fs";
855
+ import path5 from "path";
856
+ import { Command as Command9 } from "commander";
857
+ import chalk11 from "chalk";
858
+ import ora from "ora";
859
+ async function readStdin() {
860
+ if (process.stdin.isTTY) return null;
861
+ return new Promise((resolve) => {
862
+ let data = "";
863
+ process.stdin.setEncoding("utf-8");
864
+ process.stdin.on("data", (chunk) => {
865
+ data += chunk;
866
+ });
867
+ process.stdin.on("end", () => resolve(data || null));
868
+ setTimeout(() => resolve(data || null), 100);
869
+ });
870
+ }
871
+ function buildGenerateCommand() {
872
+ const command = new Command9("generate").description("Generate a hunt file from page analysis and intent using AI").option("--url <url>", "URL to analyze and generate for").option("--intent <description>", "What to test (required)").option("--output <name>", "Hunt file name (saved to .prowl/hunts/)").option("--stdout", "Print YAML to stdout instead of saving").option("--browser <engine>", "Browser engine for analysis").option("--viewport <size>", "Viewport for analysis").option("--config <path>", "Custom config path").action(async (options) => {
873
+ try {
874
+ const intent = options.intent;
875
+ if (!intent) {
876
+ console.error(chalk11.red(" --intent is required"));
877
+ process.exitCode = 1;
878
+ return;
879
+ }
880
+ let analysis;
881
+ const stdinData = await readStdin();
882
+ if (stdinData) {
883
+ try {
884
+ analysis = JSON.parse(stdinData);
885
+ } catch {
886
+ console.error(chalk11.red(" Failed to parse piped JSON input"));
887
+ process.exitCode = 1;
888
+ return;
889
+ }
890
+ }
891
+ if (!analysis && !options.url) {
892
+ console.error(chalk11.red(" Either --url or piped analysis JSON is required"));
893
+ process.exitCode = 1;
894
+ return;
895
+ }
896
+ const spinner = ora("Generating hunt...").start();
897
+ try {
898
+ const yamlStr = await generateHunt({
899
+ url: options.url,
900
+ analysis,
901
+ intent,
902
+ browser: options.browser,
903
+ viewport: options.viewport
904
+ });
905
+ spinner.succeed("Hunt generated");
906
+ if (options.stdout) {
907
+ console.log(yamlStr);
908
+ } else if (options.output) {
909
+ let configDir;
910
+ try {
911
+ const { loadConfig: loadConfig2 } = await import("./loader-5RDNTJHH.js");
912
+ const result = loadConfig2(options.config);
913
+ configDir = result.configDir;
914
+ } catch {
915
+ configDir = path5.join(process.cwd(), ".prowl");
916
+ }
917
+ const huntsDir = path5.join(configDir, "hunts");
918
+ fs4.mkdirSync(huntsDir, { recursive: true });
919
+ const fileName = options.output.endsWith(".yml") ? options.output : `${options.output}.yml`;
920
+ const filePath = path5.join(huntsDir, fileName);
921
+ fs4.writeFileSync(filePath, yamlStr + "\n", "utf-8");
922
+ console.log(chalk11.green(` Saved to ${filePath}`));
923
+ } else {
924
+ console.log(yamlStr);
925
+ }
926
+ } catch (error) {
927
+ spinner.fail("Generation failed");
928
+ throw error;
929
+ }
930
+ } catch (error) {
931
+ const message = error instanceof Error ? error.message : "Generation failed";
932
+ console.error(`
933
+ Error: ${message}
934
+ `);
935
+ process.exitCode = 1;
936
+ }
937
+ });
938
+ return command;
939
+ }
940
+
941
+ // src/cli/commands/history.ts
942
+ import { Command as Command10 } from "commander";
943
+ import chalk12 from "chalk";
944
+ function buildHistoryCommand() {
945
+ const command = new Command10("history").argument("<hunt-name>", "Hunt name or path (e.g. homepage or admin/users-crud)").description("Show run history for a hunt").option("--config <path>", "Custom config path").option("--limit <n>", "Show the last N runs (default: 20)", (value) => {
946
+ const n = Number(value);
947
+ if (!Number.isInteger(n) || n < 1) {
948
+ throw new Error("--limit must be a positive integer");
949
+ }
950
+ return n;
951
+ }).option("--json", "Output as JSON").action((huntName, options) => {
952
+ try {
953
+ const { configDir } = loadConfig(options.config);
954
+ const entries = readHuntHistory(configDir, huntName);
955
+ const limit = options.limit ?? 20;
956
+ const recent = entries.slice(-limit);
957
+ if (options.json) {
958
+ console.log(JSON.stringify(recent, null, 2));
959
+ return;
960
+ }
961
+ if (recent.length === 0) {
962
+ console.log(
963
+ chalk12.yellow(
964
+ `
965
+ No history found for "${huntName}". Run it at least once with \`prowl run ${huntName}\`.
966
+ `
967
+ )
968
+ );
969
+ return;
970
+ }
971
+ console.log();
972
+ console.log(` ${chalk12.bold(huntName)} \u2014 last ${recent.length} of ${entries.length} runs`);
973
+ console.log();
974
+ console.log(formatTable(recent));
975
+ console.log();
976
+ } catch (error) {
977
+ const message = error instanceof Error ? error.message : "history failed";
978
+ if (options.json) {
979
+ console.log(JSON.stringify({ error: message }));
980
+ } else {
981
+ console.error(chalk12.red(`Error: ${message}`));
982
+ }
983
+ process.exitCode = 1;
984
+ }
985
+ });
986
+ return command;
987
+ }
988
+ function formatTable(entries) {
989
+ const headers = ["Status", "Started", "Duration"];
990
+ const rows = entries.map((entry) => [
991
+ formatStatus(entry.status),
992
+ entry.startedAt,
993
+ formatDuration(entry.durationMs)
994
+ ]);
995
+ const widths = headers.map(
996
+ (header, index) => Math.max(
997
+ stripAnsi(header).length,
998
+ ...rows.map((row) => stripAnsi(row[index]).length)
999
+ )
1000
+ );
1001
+ const headerLine = ` ${headers.map((h, i) => pad(h, widths[i])).join(" ")}`;
1002
+ const rule = ` ${widths.map((w) => "-".repeat(w)).join(" ")}`;
1003
+ const body = rows.map((row) => ` ${row.map((cell, i) => pad(cell, widths[i])).join(" ")}`).join("\n");
1004
+ return `${headerLine}
1005
+ ${rule}
1006
+ ${body}`;
1007
+ }
1008
+ function formatStatus(status) {
1009
+ return status === "pass" ? chalk12.green("pass") : chalk12.red("fail");
1010
+ }
1011
+ function formatDuration(ms) {
1012
+ if (ms < 1e3) {
1013
+ return `${ms}ms`;
1014
+ }
1015
+ return `${(ms / 1e3).toFixed(2)}s`;
1016
+ }
1017
+ function pad(value, width) {
1018
+ const bareLength = stripAnsi(value).length;
1019
+ if (bareLength >= width) {
1020
+ return value;
1021
+ }
1022
+ return `${value}${" ".repeat(width - bareLength)}`;
1023
+ }
1024
+ function stripAnsi(value) {
1025
+ return value.replace(/\[[0-9;]*m/g, "");
1026
+ }
1027
+
1028
+ // src/cli/commands/flaky.ts
1029
+ import { Command as Command11 } from "commander";
1030
+ import chalk13 from "chalk";
1031
+ function buildFlakyCommand() {
1032
+ const command = new Command11("flaky").description("Rank hunts by flake score (pass/fail oscillation) from run history").option("--config <path>", "Custom config path").option("--limit <n>", "Only score the most recent N runs per hunt", (value) => {
1033
+ const n = Number(value);
1034
+ if (!Number.isInteger(n) || n < 1) {
1035
+ throw new Error("--limit must be a positive integer");
1036
+ }
1037
+ return n;
1038
+ }).option("--threshold <value>", "Flaky threshold 0-1 (overrides config)", (value) => {
1039
+ const n = Number(value);
1040
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
1041
+ throw new Error("--threshold must be a number between 0 and 1");
1042
+ }
1043
+ return n;
1044
+ }).option("--json", "Output as JSON").action((options) => {
1045
+ try {
1046
+ const { config, configDir } = loadConfig(options.config);
1047
+ const threshold = options.threshold ?? config.reliability?.flakyThreshold ?? DEFAULT_FLAKY_THRESHOLD;
1048
+ const scores = rankFlaky(configDir, {
1049
+ lastN: options.limit,
1050
+ threshold
1051
+ });
1052
+ if (options.json) {
1053
+ console.log(JSON.stringify(scores, null, 2));
1054
+ return;
1055
+ }
1056
+ if (scores.length === 0) {
1057
+ console.log(
1058
+ chalk13.yellow(
1059
+ "\n No run history found. Run hunts with `prowl run` or `prowl ci` first.\n"
1060
+ )
1061
+ );
1062
+ return;
1063
+ }
1064
+ console.log();
1065
+ console.log(` ${chalk13.bold("Flake scores")} (threshold ${threshold})`);
1066
+ console.log();
1067
+ console.log(formatTable2(scores));
1068
+ console.log();
1069
+ } catch (error) {
1070
+ const message = error instanceof Error ? error.message : "flaky failed";
1071
+ if (options.json) {
1072
+ console.log(JSON.stringify({ error: message }));
1073
+ } else {
1074
+ console.error(chalk13.red(`Error: ${message}`));
1075
+ }
1076
+ process.exitCode = 1;
1077
+ }
1078
+ });
1079
+ return command;
1080
+ }
1081
+ function formatTable2(scores) {
1082
+ const headers = ["Hunt", "Score", "Runs", "Flaky"];
1083
+ const rows = scores.map((s) => [
1084
+ s.hunt,
1085
+ s.score.toFixed(2),
1086
+ String(s.runs),
1087
+ s.flaky ? chalk13.red("yes") : chalk13.green("no")
1088
+ ]);
1089
+ const widths = headers.map(
1090
+ (header, index) => Math.max(stripAnsi2(header).length, ...rows.map((row) => stripAnsi2(row[index]).length))
1091
+ );
1092
+ const headerLine = ` ${headers.map((h, i) => pad2(h, widths[i])).join(" ")}`;
1093
+ const rule = ` ${widths.map((w) => "-".repeat(w)).join(" ")}`;
1094
+ const body = rows.map((row) => ` ${row.map((cell, i) => pad2(cell, widths[i])).join(" ")}`).join("\n");
1095
+ return `${headerLine}
1096
+ ${rule}
1097
+ ${body}`;
1098
+ }
1099
+ function pad2(value, width) {
1100
+ const bareLength = stripAnsi2(value).length;
1101
+ return bareLength >= width ? value : `${value}${" ".repeat(width - bareLength)}`;
1102
+ }
1103
+ function stripAnsi2(value) {
1104
+ return value.replace(/\x1b\[[0-9;]*m/g, "");
1105
+ }
1106
+
1107
+ // src/cli/commands/mcp.ts
1108
+ import { Command as Command12 } from "commander";
1109
+
1110
+ // src/mcp/server.ts
1111
+ import { z as z2 } from "zod";
1112
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
1113
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1114
+
1115
+ // src/mcp/tools.ts
1116
+ import path6 from "path";
1117
+ function listHuntsTool(configPath) {
1118
+ const { configDir } = loadConfig(configPath);
1119
+ return { hunts: listHunts(configDir) };
1120
+ }
1121
+ async function runSuiteTool(args = {}, options = {}) {
1122
+ const { configPath: resolvedConfigPath, configDir, config } = loadConfig(options.configPath);
1123
+ const projectRoot = options.projectRoot ?? path6.dirname(configDir);
1124
+ const suite = await runSuite({
1125
+ configPath: resolvedConfigPath,
1126
+ includeTags: args.includeTags,
1127
+ excludeTags: args.excludeTags,
1128
+ parallel: args.parallel
1129
+ });
1130
+ const bugLogCfg = config.bugLog ?? {};
1131
+ const logBugs = args.logBugs ?? bugLogCfg.enabled ?? true;
1132
+ const backlogPath = bugLogCfg.backlogPath ? path6.resolve(projectRoot, bugLogCfg.backlogPath) : void 0;
1133
+ const resolvedPath = bugLogCfg.resolvedPath ? path6.resolve(projectRoot, bugLogCfg.resolvedPath) : backlogPath ? path6.join(path6.dirname(backlogPath), "resolved.md") : void 0;
1134
+ const bugs = logBugs ? updateBacklogFromSuite(suite, { projectRoot, backlogPath, resolvedPath }) : { created: [], regressions: [], skipped: [], backlogPath: null };
1135
+ const { status, totalHunts, passed, failed, skipped } = suite.result;
1136
+ return {
1137
+ status,
1138
+ totalHunts,
1139
+ passed,
1140
+ failed,
1141
+ skipped,
1142
+ resultPath: suite.resultPath,
1143
+ bugs: {
1144
+ created: bugs.created,
1145
+ regressions: bugs.regressions,
1146
+ alreadyOpen: bugs.skipped,
1147
+ backlogPath: bugs.backlogPath
1148
+ }
1149
+ };
1150
+ }
1151
+ async function runHuntTool(args, configPath) {
1152
+ const { result, runDir } = await runHunt({
1153
+ huntName: args.hunt,
1154
+ ...configPath ? { configPath } : {}
1155
+ });
1156
+ return { ...result, runDir };
1157
+ }
1158
+
1159
+ // src/mcp/projects.ts
1160
+ import fs5 from "fs";
1161
+ import os from "os";
1162
+ import path7 from "path";
1163
+ import yaml from "yaml";
1164
+ import { z } from "zod";
1165
+ var projectEntrySchema = z.object({
1166
+ root: z.string().min(1),
1167
+ /** Optional override; defaults to <root>/.prowl/config.yml (legacy: .prowlqa/). */
1168
+ configPath: z.string().min(1).optional()
1169
+ }).strict();
1170
+ var projectRegistrySchema = z.object({
1171
+ projects: z.record(z.string().min(1), projectEntrySchema)
1172
+ }).strict();
1173
+ function defaultRegistryPath() {
1174
+ return path7.join(os.homedir(), ".prowl", "projects.yml");
1175
+ }
1176
+ function legacyRegistryPath() {
1177
+ return path7.join(os.homedir(), ".prowlqa", "projects.yml");
1178
+ }
1179
+ function resolveProjectConfigPath(root) {
1180
+ const preferred = path7.join(root, ".prowl", "config.yml");
1181
+ if (fs5.existsSync(preferred)) return preferred;
1182
+ const legacy = path7.join(root, ".prowlqa", "config.yml");
1183
+ if (fs5.existsSync(legacy)) return legacy;
1184
+ return preferred;
1185
+ }
1186
+ function resolveRegistryRelativePath(registry, inputPath) {
1187
+ return path7.isAbsolute(inputPath) ? inputPath : path7.resolve(path7.dirname(registry.registryPath), inputPath);
1188
+ }
1189
+ function resolveRegistryPath(explicitPath) {
1190
+ if (explicitPath) return path7.resolve(explicitPath);
1191
+ const envPath = process.env.PROWL_PROJECTS ?? process.env.PROWLQA_PROJECTS;
1192
+ if (envPath) return path7.resolve(envPath);
1193
+ const fallback = defaultRegistryPath();
1194
+ if (fs5.existsSync(fallback)) return fallback;
1195
+ const legacy = legacyRegistryPath();
1196
+ return fs5.existsSync(legacy) ? legacy : null;
1197
+ }
1198
+ function loadProjectRegistry(explicitPath) {
1199
+ const registryPath = resolveRegistryPath(explicitPath);
1200
+ if (!registryPath) return null;
1201
+ if (!fs5.existsSync(registryPath)) {
1202
+ throw new Error(`Project registry not found at ${registryPath}`);
1203
+ }
1204
+ const raw = fs5.readFileSync(registryPath, "utf-8");
1205
+ const parsed = yaml.parse(raw) ?? {};
1206
+ const validated = projectRegistrySchema.parse(parsed);
1207
+ return { projects: validated.projects, registryPath };
1208
+ }
1209
+ function resolveProject(registry, name) {
1210
+ const entry = registry.projects[name];
1211
+ if (!entry) {
1212
+ const known = Object.keys(registry.projects).sort().join(", ") || "(none)";
1213
+ throw new Error(`Unknown project "${name}". Registered projects: ${known}`);
1214
+ }
1215
+ const root = resolveRegistryRelativePath(registry, entry.root);
1216
+ const configPath = entry.configPath ? resolveRegistryRelativePath(registry, entry.configPath) : resolveProjectConfigPath(root);
1217
+ return { name, root, configPath };
1218
+ }
1219
+ function listRegisteredProjects(registry) {
1220
+ if (!registry) return [];
1221
+ return Object.entries(registry.projects).map(([name, entry]) => ({
1222
+ name,
1223
+ root: resolveRegistryRelativePath(registry, entry.root)
1224
+ }));
1225
+ }
1226
+
1227
+ // src/mcp/server.ts
1228
+ function textResult(data) {
1229
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
1230
+ }
1231
+ function errorMessage(error) {
1232
+ return error instanceof Error ? error.message : String(error);
1233
+ }
1234
+ function safeStringify(value) {
1235
+ try {
1236
+ return JSON.stringify(value) ?? "undefined";
1237
+ } catch {
1238
+ return "[unserializable]";
1239
+ }
1240
+ }
1241
+ async function toolResult(toolName, args, action) {
1242
+ try {
1243
+ return textResult(await action());
1244
+ } catch (error) {
1245
+ throw new Error(`${toolName} failed for args=${safeStringify(args)}: ${errorMessage(error)}`, {
1246
+ cause: error
1247
+ });
1248
+ }
1249
+ }
1250
+ var PROJECT_ARG_DESCRIPTION = "Registered project name to target (from the registry). Omit to use the current working directory.";
1251
+ function buildMcpServer(options = {}) {
1252
+ const registry = options.registry ?? null;
1253
+ const server = new McpServer({ name: "prowl", version: package_default.version });
1254
+ const projectFor = (project) => {
1255
+ if (!project) return null;
1256
+ if (!registry) {
1257
+ throw new Error(
1258
+ `No project registry is configured, so project "${project}" cannot be resolved. Start the server with \`prowl mcp --projects <path>\` (or set PROWL_PROJECTS).`
1259
+ );
1260
+ }
1261
+ return resolveProject(registry, project);
1262
+ };
1263
+ const configPathFor = (project) => {
1264
+ return projectFor(project)?.configPath;
1265
+ };
1266
+ server.registerTool(
1267
+ "list_projects",
1268
+ {
1269
+ description: "List the projects registered with this server. Empty when no registry is configured.",
1270
+ inputSchema: z2.object({}).strict()
1271
+ },
1272
+ async (args) => toolResult("list_projects", args, () => ({ projects: listRegisteredProjects(registry) }))
1273
+ );
1274
+ server.registerTool(
1275
+ "list_hunts",
1276
+ {
1277
+ description: "List all hunts in the target project, in run order.",
1278
+ inputSchema: {
1279
+ project: z2.string().min(1).describe(PROJECT_ARG_DESCRIPTION).optional()
1280
+ }
1281
+ },
1282
+ async (args) => toolResult("list_hunts", args, () => listHuntsTool(configPathFor(args.project)))
1283
+ );
1284
+ server.registerTool(
1285
+ "run_suite",
1286
+ {
1287
+ description: "Run all hunts and, by default, log any failures as deduplicated bug tickets in the project backlog. Returns pass/fail/skip counts and the QA-NNN ticket ids created.",
1288
+ inputSchema: {
1289
+ project: z2.string().min(1).describe(PROJECT_ARG_DESCRIPTION).optional(),
1290
+ includeTags: z2.array(z2.string()).optional(),
1291
+ excludeTags: z2.array(z2.string()).optional(),
1292
+ parallel: z2.number().int().min(1).optional(),
1293
+ logBugs: z2.boolean().optional()
1294
+ }
1295
+ },
1296
+ async (args) => toolResult("run_suite", args, () => {
1297
+ const project = projectFor(args.project);
1298
+ return runSuiteTool(args, { configPath: project?.configPath, projectRoot: project?.root });
1299
+ })
1300
+ );
1301
+ server.registerTool(
1302
+ "run_hunt",
1303
+ {
1304
+ description: "Run a single hunt by name and return its full result.",
1305
+ inputSchema: {
1306
+ hunt: z2.string().min(1),
1307
+ project: z2.string().min(1).describe(PROJECT_ARG_DESCRIPTION).optional()
1308
+ }
1309
+ },
1310
+ async (args) => toolResult("run_hunt", args, () => runHuntTool(args, configPathFor(args.project)))
1311
+ );
1312
+ return server;
1313
+ }
1314
+ async function startMcpServer(options = {}) {
1315
+ const registry = loadProjectRegistry(options.registryPath);
1316
+ const server = buildMcpServer({ registry });
1317
+ const transport = new StdioServerTransport();
1318
+ await server.connect(transport);
1319
+ }
1320
+
1321
+ // src/cli/commands/mcp.ts
1322
+ function errorMessage2(error) {
1323
+ return error instanceof Error ? error.message : String(error);
1324
+ }
1325
+ function buildMcpCommand() {
1326
+ return new Command12("mcp").description("Start an MCP server (stdio) exposing Prowl tools to AI agents").option(
1327
+ "--projects <path>",
1328
+ "Path to a project registry (YAML) so tools can target multiple repos by name"
1329
+ ).action(async (options) => {
1330
+ try {
1331
+ await startMcpServer({ registryPath: options.projects });
1332
+ } catch (error) {
1333
+ console.error(`Failed to start MCP server: ${errorMessage2(error)}`);
1334
+ process.exit(1);
1335
+ }
1336
+ });
1337
+ }
1338
+
1339
+ // src/cli/program.ts
1340
+ var CLI_VERSION = package_default.version;
1341
+ function buildProgram() {
1342
+ const program2 = new Command13();
1343
+ program2.name("prowl").description("CLI-first QA testing tool for deterministic Playwright flows").version(CLI_VERSION);
1344
+ program2.addCommand(buildRunCommand());
1345
+ program2.addCommand(buildCiCommand());
1346
+ program2.addCommand(buildWatchCommand());
1347
+ program2.addCommand(buildInitCommand());
1348
+ program2.addCommand(buildLoginCommand());
1349
+ program2.addCommand(buildListCommand());
1350
+ program2.addCommand(buildUpdateBaselinesCommand());
1351
+ program2.addCommand(buildAnalyzeCommand());
1352
+ program2.addCommand(buildGenerateCommand());
1353
+ program2.addCommand(buildHistoryCommand());
1354
+ program2.addCommand(buildFlakyCommand());
1355
+ program2.addCommand(buildMcpCommand());
1356
+ return program2;
1357
+ }
1358
+
1359
+ // src/cli/index.ts
1360
+ var program = buildProgram();
1361
+ program.parseAsync(process.argv).catch((error) => {
1362
+ const message = error instanceof Error ? error.message : "Command failed";
1363
+ console.error(message);
1364
+ process.exit(1);
1365
+ });
1366
+ //# sourceMappingURL=index.js.map