@hasna/testers 0.0.8 → 0.0.10

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/cli/index.js CHANGED
@@ -2666,7 +2666,8 @@ __export(exports_runs, {
2666
2666
  listRuns: () => listRuns,
2667
2667
  getRun: () => getRun,
2668
2668
  deleteRun: () => deleteRun,
2669
- createRun: () => createRun
2669
+ createRun: () => createRun,
2670
+ countRuns: () => countRuns
2670
2671
  });
2671
2672
  function createRun(input) {
2672
2673
  const db2 = getDatabase();
@@ -2719,6 +2720,24 @@ function listRuns(filter) {
2719
2720
  const rows = db2.query(sql).all(...params);
2720
2721
  return rows.map(runFromRow);
2721
2722
  }
2723
+ function countRuns(filter) {
2724
+ const db2 = getDatabase();
2725
+ const conditions = [];
2726
+ const params = [];
2727
+ if (filter?.projectId) {
2728
+ conditions.push("project_id = ?");
2729
+ params.push(filter.projectId);
2730
+ }
2731
+ if (filter?.status) {
2732
+ conditions.push("status = ?");
2733
+ params.push(filter.status);
2734
+ }
2735
+ let sql = "SELECT COUNT(*) as count FROM runs";
2736
+ if (conditions.length > 0)
2737
+ sql += " WHERE " + conditions.join(" AND ");
2738
+ const row = db2.query(sql).get(...params);
2739
+ return row.count;
2740
+ }
2722
2741
  function updateRun(id, updates) {
2723
2742
  const db2 = getDatabase();
2724
2743
  const existing = getRun(id);
@@ -3383,11 +3402,95 @@ var {
3383
3402
  Help
3384
3403
  } = import__.default;
3385
3404
 
3405
+ // src/cli/index.tsx
3406
+ import chalk5 from "chalk";
3407
+ // package.json
3408
+ var package_default = {
3409
+ name: "@hasna/testers",
3410
+ version: "0.0.10",
3411
+ description: "AI-powered QA testing CLI \u2014 spawns cheap AI agents to test web apps with headless browsers",
3412
+ type: "module",
3413
+ main: "dist/index.js",
3414
+ types: "dist/index.d.ts",
3415
+ bin: {
3416
+ testers: "dist/cli/index.js",
3417
+ "testers-mcp": "dist/mcp/index.js",
3418
+ "testers-serve": "dist/server/index.js"
3419
+ },
3420
+ exports: {
3421
+ ".": {
3422
+ types: "./dist/index.d.ts",
3423
+ import: "./dist/index.js"
3424
+ }
3425
+ },
3426
+ files: [
3427
+ "dist/",
3428
+ "dashboard/dist/",
3429
+ "LICENSE",
3430
+ "README.md"
3431
+ ],
3432
+ scripts: {
3433
+ build: "bun run build:dashboard && bun run build:cli && bun run build:mcp && bun run build:server && bun run build:lib && bun run build:types",
3434
+ "build:cli": "bun build src/cli/index.tsx --outdir dist/cli --target bun --external ink --external react --external chalk --external @modelcontextprotocol/sdk --external @anthropic-ai/sdk --external playwright",
3435
+ "build:mcp": "bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external @anthropic-ai/sdk --external playwright",
3436
+ "build:server": "bun build src/server/index.ts --outdir dist/server --target bun --external @anthropic-ai/sdk --external playwright",
3437
+ "build:lib": "bun build src/index.ts --outdir dist --target bun --external playwright --external @anthropic-ai/sdk --external @modelcontextprotocol/sdk",
3438
+ "build:types": "NODE_OPTIONS='--max-old-space-size=8192' tsc --emitDeclarationOnly --outDir dist --skipLibCheck",
3439
+ "build:dashboard": "cd dashboard && bun run build",
3440
+ typecheck: "tsc --noEmit",
3441
+ test: "bun test",
3442
+ "dev:cli": "bun run src/cli/index.tsx",
3443
+ "dev:mcp": "bun run src/mcp/index.ts",
3444
+ "dev:serve": "bun run src/server/index.ts",
3445
+ prepublishOnly: "bun run build"
3446
+ },
3447
+ dependencies: {
3448
+ "@anthropic-ai/sdk": "^0.52.0",
3449
+ "@modelcontextprotocol/sdk": "^1.12.1",
3450
+ chalk: "^5.4.1",
3451
+ commander: "^13.1.0",
3452
+ ink: "^5.2.0",
3453
+ playwright: "^1.50.0",
3454
+ react: "^18.3.1",
3455
+ zod: "^3.24.2"
3456
+ },
3457
+ devDependencies: {
3458
+ "@types/bun": "latest",
3459
+ "@types/react": "^18.3.18",
3460
+ typescript: "^5.7.3"
3461
+ },
3462
+ engines: {
3463
+ bun: ">=1.0.0"
3464
+ },
3465
+ publishConfig: {
3466
+ access: "public",
3467
+ registry: "https://registry.npmjs.org/"
3468
+ },
3469
+ repository: {
3470
+ type: "git",
3471
+ url: "https://github.com/hasna/open-testers.git"
3472
+ },
3473
+ license: "MIT",
3474
+ keywords: [
3475
+ "testing",
3476
+ "qa",
3477
+ "ai",
3478
+ "playwright",
3479
+ "browser",
3480
+ "screenshot",
3481
+ "automation",
3482
+ "cli",
3483
+ "mcp"
3484
+ ]
3485
+ };
3486
+
3386
3487
  // src/cli/index.tsx
3387
3488
  init_scenarios();
3388
3489
  init_runs();
3389
- import chalk5 from "chalk";
3490
+ import { render, Box, Text, useInput, useApp } from "ink";
3491
+ import React, { useState } from "react";
3390
3492
  import { readFileSync as readFileSync6, readdirSync, writeFileSync as writeFileSync3 } from "fs";
3493
+ import { createInterface } from "readline";
3391
3494
  import { join as join6, resolve } from "path";
3392
3495
 
3393
3496
  // src/db/results.ts
@@ -4595,6 +4698,38 @@ async function dispatchWebhooks(event, run, schedule) {
4595
4698
  }
4596
4699
  }
4597
4700
 
4701
+ // src/lib/logs-integration.ts
4702
+ async function pushFailedRunToLogs(run, failedResults, scenarios) {
4703
+ const logsUrl = process.env.LOGS_URL;
4704
+ if (!logsUrl)
4705
+ return;
4706
+ const scenarioMap = new Map(scenarios.map((s) => [s.id, s]));
4707
+ const entries = failedResults.map((result) => {
4708
+ const scenario = scenarioMap.get(result.scenarioId);
4709
+ return {
4710
+ level: "error",
4711
+ source: "sdk",
4712
+ service: "testers",
4713
+ message: `[testers] Scenario failed: ${scenario?.name ?? result.scenarioId}${result.error ? ` \u2014 ${result.error}` : ""}`,
4714
+ metadata: {
4715
+ run_id: run.id,
4716
+ scenario_id: result.scenarioId,
4717
+ scenario_name: scenario?.name,
4718
+ url: run.url,
4719
+ status: result.status,
4720
+ duration_ms: result.durationMs
4721
+ }
4722
+ };
4723
+ });
4724
+ try {
4725
+ await fetch(`${logsUrl.replace(/\/$/, "")}/api/logs`, {
4726
+ method: "POST",
4727
+ headers: { "Content-Type": "application/json" },
4728
+ body: JSON.stringify(entries)
4729
+ });
4730
+ } catch {}
4731
+ }
4732
+
4598
4733
  // src/lib/runner.ts
4599
4734
  var eventHandler = null;
4600
4735
  function onRunEvent(handler) {
@@ -4607,7 +4742,7 @@ function emit(event) {
4607
4742
  function withTimeout(promise, ms, label) {
4608
4743
  return new Promise((resolve, reject) => {
4609
4744
  const timer = setTimeout(() => {
4610
- reject(new Error(`Scenario timeout after ${ms}ms: ${label}`));
4745
+ reject(new Error(`Scenario '${label}' timed out after ${ms}ms. Try: testers run --timeout ${ms * 2} or simplify the scenario steps.`));
4611
4746
  }, ms);
4612
4747
  promise.then((val) => {
4613
4748
  clearTimeout(timer);
@@ -4740,6 +4875,7 @@ async function runBatch(scenarios, options) {
4740
4875
  } catch {}
4741
4876
  return true;
4742
4877
  };
4878
+ const maxRetries = options.retry ?? 0;
4743
4879
  if (parallel <= 1) {
4744
4880
  for (const scenario of sortedScenarios) {
4745
4881
  if (!await canRun(scenario)) {
@@ -4750,7 +4886,13 @@ async function runBatch(scenarios, options) {
4750
4886
  emit({ type: "scenario:error", scenarioId: scenario.id, scenarioName: scenario.name, error: "Dependency failed \u2014 skipped", runId: run.id });
4751
4887
  continue;
4752
4888
  }
4753
- const result = await runSingleScenario(scenario, run.id, options);
4889
+ let result = await runSingleScenario(scenario, run.id, options);
4890
+ let attempt = 1;
4891
+ while ((result.status === "failed" || result.status === "error") && attempt <= maxRetries) {
4892
+ emit({ type: "scenario:start", scenarioId: scenario.id, scenarioName: scenario.name, runId: run.id, retryAttempt: attempt + 1, maxRetries: maxRetries + 1 });
4893
+ result = await runSingleScenario(scenario, run.id, options);
4894
+ attempt++;
4895
+ }
4754
4896
  results.push(result);
4755
4897
  if (result.status === "failed" || result.status === "error") {
4756
4898
  failedScenarioIds.add(scenario.id);
@@ -4797,6 +4939,10 @@ async function runBatch(scenarios, options) {
4797
4939
  emit({ type: "run:complete", runId: run.id });
4798
4940
  const eventType = finalRun.status === "failed" ? "failed" : "completed";
4799
4941
  dispatchWebhooks(eventType, finalRun).catch(() => {});
4942
+ if (finalRun.status === "failed") {
4943
+ const failedResults = results.filter((r) => r.status === "failed" || r.status === "error");
4944
+ pushFailedRunToLogs(finalRun, failedResults, scenarios).catch(() => {});
4945
+ }
4800
4946
  return { run: finalRun, results };
4801
4947
  }
4802
4948
  async function runByFilter(options) {
@@ -4912,6 +5058,10 @@ function estimateCost(model, tokens) {
4912
5058
  // src/lib/reporter.ts
4913
5059
  import chalk from "chalk";
4914
5060
  init_scenarios();
5061
+ init_database();
5062
+ function useEmoji() {
5063
+ return !process.env["NO_COLOR"] && process.argv.indexOf("--no-color") === -1;
5064
+ }
4915
5065
  function formatTerminal(run, results) {
4916
5066
  const lines = [];
4917
5067
  lines.push("");
@@ -4926,21 +5076,22 @@ function formatTerminal(run, results) {
4926
5076
  const screenshotCount = screenshots.length;
4927
5077
  let statusIcon;
4928
5078
  let statusColor;
5079
+ const emoji = useEmoji();
4929
5080
  switch (result.status) {
4930
5081
  case "passed":
4931
- statusIcon = chalk.green("PASS");
5082
+ statusIcon = emoji ? "\u2705" : chalk.green("PASS");
4932
5083
  statusColor = chalk.green;
4933
5084
  break;
4934
5085
  case "failed":
4935
- statusIcon = chalk.red("FAIL");
5086
+ statusIcon = emoji ? "\u274C" : chalk.red("FAIL");
4936
5087
  statusColor = chalk.red;
4937
5088
  break;
4938
5089
  case "error":
4939
- statusIcon = chalk.yellow("ERR ");
5090
+ statusIcon = emoji ? "\u26A0\uFE0F " : chalk.yellow("ERR ");
4940
5091
  statusColor = chalk.yellow;
4941
5092
  break;
4942
5093
  default:
4943
- statusIcon = chalk.dim("SKIP");
5094
+ statusIcon = emoji ? "\u23ED\uFE0F " : chalk.dim("SKIP");
4944
5095
  statusColor = chalk.dim;
4945
5096
  break;
4946
5097
  }
@@ -4953,17 +5104,34 @@ function formatTerminal(run, results) {
4953
5104
  }
4954
5105
  }
4955
5106
  lines.push("");
4956
- lines.push(formatSummary(run));
5107
+ lines.push(formatActionableSummary(run, results));
4957
5108
  lines.push("");
4958
5109
  return lines.join(`
4959
5110
  `);
4960
5111
  }
4961
- function formatSummary(run) {
4962
- const duration = run.finishedAt ? `${((new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000).toFixed(1)}s` : "running";
4963
- const passedStr = chalk.green(`${run.passed} passed`);
4964
- const failedStr = run.failed > 0 ? chalk.red(` ${run.failed} failed`) : "";
4965
- const totalStr = chalk.dim(` (${run.total} total)`);
4966
- return ` ${passedStr}${failedStr}${totalStr} in ${duration}`;
5112
+ function formatActionableSummary(run, results) {
5113
+ const emoji = useEmoji();
5114
+ const passedCount = results.filter((r) => r.status === "passed").length;
5115
+ const failedCount = results.filter((r) => r.status === "failed" || r.status === "error").length;
5116
+ const shortId = run.id.slice(0, 8);
5117
+ const passStr = `${emoji ? "\u2705" : "PASS"} ${passedCount} passed`;
5118
+ const failStr = failedCount > 0 ? ` ${emoji ? "\u274C" : "FAIL"} ${failedCount} failed` : "";
5119
+ const lines = [];
5120
+ lines.push(` ${chalk.bold(passStr)}${failedCount > 0 ? chalk.bold(failStr) : ""}`);
5121
+ if (failedCount > 0) {
5122
+ lines.push(chalk.dim(` retry failed: testers retry ${shortId} | view: testers results ${shortId}`));
5123
+ } else {
5124
+ lines.push(chalk.dim(` view: testers results ${shortId}`));
5125
+ }
5126
+ const totalCostCents = results.reduce((sum, r) => sum + (r.costCents ?? 0), 0);
5127
+ const totalTokens = results.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
5128
+ if (totalTokens > 0) {
5129
+ const costStr = `$${(totalCostCents / 100).toFixed(4)}`;
5130
+ const tokensStr = totalTokens.toLocaleString();
5131
+ lines.push(chalk.dim(` ${emoji ? "\uD83D\uDCB0" : "cost:"} Cost: ${costStr} (${tokensStr} tokens)`));
5132
+ }
5133
+ return lines.join(`
5134
+ `);
4967
5135
  }
4968
5136
  function formatJSON(run, results) {
4969
5137
  const output = {
@@ -5043,6 +5211,15 @@ function formatRunList(runs) {
5043
5211
  return lines.join(`
5044
5212
  `);
5045
5213
  }
5214
+ function getScenarioRunStats(scenarioId) {
5215
+ const db2 = getDatabase();
5216
+ const lastRow = db2.query("SELECT status FROM results WHERE scenario_id = ? ORDER BY created_at DESC LIMIT 1").get(scenarioId);
5217
+ const statsRow = db2.query("SELECT COUNT(*) as total, SUM(CASE WHEN status = 'passed' THEN 1 ELSE 0 END) as passed FROM results WHERE scenario_id = ?").get(scenarioId);
5218
+ return {
5219
+ lastStatus: lastRow ? lastRow.status : null,
5220
+ passRate: statsRow && statsRow.total > 0 ? `${statsRow.passed}/${statsRow.total}` : "\u2014"
5221
+ };
5222
+ }
5046
5223
  function formatScenarioList(scenarios) {
5047
5224
  const lines = [];
5048
5225
  lines.push("");
@@ -5057,7 +5234,21 @@ function formatScenarioList(scenarios) {
5057
5234
  for (const s of scenarios) {
5058
5235
  const priorityColor = s.priority === "critical" ? chalk.red : s.priority === "high" ? chalk.yellow : s.priority === "medium" ? chalk.blue : chalk.dim;
5059
5236
  const tags = s.tags.length > 0 ? chalk.dim(` [${s.tags.join(", ")}]`) : "";
5060
- lines.push(` ${chalk.cyan(s.shortId)} ${s.name} ${priorityColor(s.priority)}${tags}`);
5237
+ let lastStatusIcon = chalk.dim("\u2014");
5238
+ let passRateStr = chalk.dim("\u2014");
5239
+ if (s.id) {
5240
+ const stats = getScenarioRunStats(s.id);
5241
+ if (stats.lastStatus === "passed")
5242
+ lastStatusIcon = chalk.green("\u2713");
5243
+ else if (stats.lastStatus === "failed")
5244
+ lastStatusIcon = chalk.red("\u2717");
5245
+ else if (stats.lastStatus === "error")
5246
+ lastStatusIcon = chalk.yellow("!");
5247
+ else if (stats.lastStatus === "skipped")
5248
+ lastStatusIcon = chalk.dim("~");
5249
+ passRateStr = stats.passRate === "\u2014" ? chalk.dim("\u2014") : chalk.dim(stats.passRate);
5250
+ }
5251
+ lines.push(` ${chalk.cyan(s.shortId)} ${s.name} ${priorityColor(s.priority)}${tags} ${lastStatusIcon} ${passRateStr}`);
5061
5252
  }
5062
5253
  lines.push("");
5063
5254
  return lines.join(`
@@ -5251,26 +5442,146 @@ function detectFramework(dir) {
5251
5442
  return null;
5252
5443
  }
5253
5444
  function getStarterScenarios(framework, projectId) {
5445
+ if (framework.name === "Next.js") {
5446
+ const scenarios2 = [
5447
+ {
5448
+ name: "Homepage loads",
5449
+ description: "Navigate to the homepage and verify it loads correctly. Check that the main heading and content are visible, and there are no console errors.",
5450
+ tags: ["smoke"],
5451
+ priority: "high",
5452
+ projectId
5453
+ },
5454
+ {
5455
+ name: "404 page works",
5456
+ description: "Navigate to a non-existent URL (e.g. /this-page-does-not-exist) and verify the Next.js 404 page renders correctly.",
5457
+ tags: ["smoke"],
5458
+ priority: "medium",
5459
+ projectId
5460
+ },
5461
+ {
5462
+ name: "Navigation links work",
5463
+ description: "Click through the main navigation links and verify each page loads without errors. Check that client-side routing is working correctly.",
5464
+ tags: ["smoke"],
5465
+ priority: "medium",
5466
+ projectId
5467
+ }
5468
+ ];
5469
+ if (framework.features.includes("hasAuth")) {
5470
+ scenarios2.push({
5471
+ name: "Login flow",
5472
+ description: "Navigate to the login page, enter valid credentials, and verify successful authentication and redirect.",
5473
+ tags: ["auth"],
5474
+ priority: "critical",
5475
+ projectId
5476
+ }, {
5477
+ name: "Protected route redirect",
5478
+ description: "Try to access a protected route without authentication and verify you are redirected to the login page.",
5479
+ tags: ["auth"],
5480
+ priority: "high",
5481
+ projectId
5482
+ });
5483
+ }
5484
+ if (framework.features.includes("hasForms")) {
5485
+ scenarios2.push({
5486
+ name: "Form validation",
5487
+ description: "Submit forms with empty/invalid data and verify validation errors appear correctly.",
5488
+ tags: ["forms"],
5489
+ priority: "medium",
5490
+ projectId
5491
+ });
5492
+ }
5493
+ return scenarios2;
5494
+ }
5495
+ if (framework.name === "Vite" || framework.name === "SvelteKit") {
5496
+ const scenarios2 = [
5497
+ {
5498
+ name: "Homepage loads",
5499
+ description: "Navigate to the homepage and verify it loads correctly with no console errors.",
5500
+ tags: ["smoke"],
5501
+ priority: "high",
5502
+ projectId
5503
+ },
5504
+ {
5505
+ name: "Mobile viewport check",
5506
+ description: "Set the viewport to 375x812 (iPhone) and verify the homepage renders correctly without horizontal scrolling or layout issues.",
5507
+ tags: ["responsive"],
5508
+ priority: "medium",
5509
+ projectId
5510
+ },
5511
+ {
5512
+ name: "No console errors",
5513
+ description: "Navigate through the app and verify there are no JavaScript errors or warnings in the browser console.",
5514
+ tags: ["smoke"],
5515
+ priority: "high",
5516
+ projectId
5517
+ }
5518
+ ];
5519
+ if (framework.features.includes("hasAuth")) {
5520
+ scenarios2.push({
5521
+ name: "Login flow",
5522
+ description: "Navigate to the login page, enter valid credentials, and verify successful authentication.",
5523
+ tags: ["auth"],
5524
+ priority: "critical",
5525
+ projectId
5526
+ });
5527
+ }
5528
+ return scenarios2;
5529
+ }
5530
+ if (framework.name === "Nuxt") {
5531
+ const scenarios2 = [
5532
+ {
5533
+ name: "Homepage loads",
5534
+ description: "Navigate to the homepage and verify it loads correctly. Check that the main heading and content are visible.",
5535
+ tags: ["smoke"],
5536
+ priority: "high",
5537
+ projectId
5538
+ },
5539
+ {
5540
+ name: "Navigation works",
5541
+ description: "Click through main navigation links and verify each page loads without errors.",
5542
+ tags: ["smoke"],
5543
+ priority: "medium",
5544
+ projectId
5545
+ },
5546
+ {
5547
+ name: "Mobile viewport check",
5548
+ description: "Set the viewport to 375x812 and verify the homepage renders correctly on mobile.",
5549
+ tags: ["responsive"],
5550
+ priority: "medium",
5551
+ projectId
5552
+ }
5553
+ ];
5554
+ if (framework.features.includes("hasAuth")) {
5555
+ scenarios2.push({
5556
+ name: "Login flow",
5557
+ description: "Navigate to the login page, enter valid credentials, and verify successful authentication.",
5558
+ tags: ["auth"],
5559
+ priority: "critical",
5560
+ projectId
5561
+ });
5562
+ }
5563
+ return scenarios2;
5564
+ }
5254
5565
  const scenarios = [
5255
5566
  {
5256
- name: "Landing page loads",
5257
- description: "Navigate to the landing page and verify it loads correctly with no console errors. Check that the main heading, navigation, and primary CTA are visible.",
5567
+ name: "Homepage loads",
5568
+ description: "Navigate to the homepage and verify it loads correctly with no console errors. Check that the main heading, navigation, and primary CTA are visible.",
5258
5569
  tags: ["smoke"],
5259
5570
  priority: "high",
5260
5571
  projectId
5261
5572
  },
5262
5573
  {
5263
- name: "Navigation works",
5264
- description: "Click through main navigation links and verify each page loads without errors.",
5574
+ name: "Form submit works",
5575
+ description: "Find the main form on the page, fill it in with valid test data, submit it, and verify the success state.",
5265
5576
  tags: ["smoke"],
5266
5577
  priority: "medium",
5267
5578
  projectId
5268
5579
  },
5269
5580
  {
5270
- name: "No console errors",
5271
- description: "Navigate through the main pages and check the browser console for any JavaScript errors or warnings.",
5272
- tags: ["smoke"],
5273
- priority: "high",
5581
+ name: "Mobile viewport check",
5582
+ description: "Set the viewport to 375x812 (iPhone) and verify the homepage renders correctly without horizontal scrolling or layout issues.",
5583
+ tags: ["responsive"],
5584
+ priority: "medium",
5274
5585
  projectId
5275
5586
  }
5276
5587
  ];
@@ -6051,6 +6362,15 @@ function getPeriodDays(period) {
6051
6362
  return 30;
6052
6363
  }
6053
6364
  }
6365
+ function loadBudgetConfig() {
6366
+ const config = loadConfig();
6367
+ const budget = config.budget;
6368
+ return {
6369
+ maxPerRunCents: budget?.maxPerRunCents ?? 50,
6370
+ maxPerDayCents: budget?.maxPerDayCents ?? 500,
6371
+ warnAtPercent: budget?.warnAtPercent ?? 0.8
6372
+ };
6373
+ }
6054
6374
  function getCostSummary(options) {
6055
6375
  const db2 = getDatabase();
6056
6376
  const period = options?.period ?? "month";
@@ -6118,6 +6438,30 @@ function getCostSummary(options) {
6118
6438
  estimatedMonthlyCents
6119
6439
  };
6120
6440
  }
6441
+ function checkBudget(estimatedCostCents) {
6442
+ const budget = loadBudgetConfig();
6443
+ if (estimatedCostCents > budget.maxPerRunCents) {
6444
+ return {
6445
+ allowed: false,
6446
+ warning: `Estimated cost (${formatDollars(estimatedCostCents)}) exceeds per-run limit (${formatDollars(budget.maxPerRunCents)})`
6447
+ };
6448
+ }
6449
+ const todaySummary = getCostSummary({ period: "day" });
6450
+ const projectedDaily = todaySummary.totalCostCents + estimatedCostCents;
6451
+ if (projectedDaily > budget.maxPerDayCents) {
6452
+ return {
6453
+ allowed: false,
6454
+ warning: `Daily spending (${formatDollars(todaySummary.totalCostCents)}) + this run (${formatDollars(estimatedCostCents)}) would exceed daily limit (${formatDollars(budget.maxPerDayCents)})`
6455
+ };
6456
+ }
6457
+ if (projectedDaily > budget.maxPerDayCents * budget.warnAtPercent) {
6458
+ return {
6459
+ allowed: true,
6460
+ warning: `Approaching daily limit: ${formatDollars(projectedDaily)} of ${formatDollars(budget.maxPerDayCents)} (${Math.round(projectedDaily / budget.maxPerDayCents * 100)}%)`
6461
+ };
6462
+ }
6463
+ return { allowed: true };
6464
+ }
6121
6465
  function formatDollars(cents) {
6122
6466
  return `$${(cents / 100).toFixed(2)}`;
6123
6467
  }
@@ -6148,12 +6492,13 @@ function formatCostsTerminal(summary) {
6148
6492
  }
6149
6493
  if (summary.byScenario.length > 0) {
6150
6494
  lines.push("");
6151
- lines.push(chalk4.bold(" Top Scenarios by Cost"));
6152
- lines.push(` ${"Scenario".padEnd(40)} ${"Cost".padEnd(12)} ${"Tokens".padEnd(12)} Runs`);
6153
- lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(12)} ${"\u2500".repeat(12)} ${"\u2500".repeat(6)}`);
6495
+ lines.push(chalk4.bold(" Scenarios by Cost (most expensive first)"));
6496
+ lines.push(` ${"Scenario".padEnd(40)} ${"Total Cost".padEnd(12)} ${"Avg/Run".padEnd(12)} ${"Runs".padEnd(6)} Tokens`);
6497
+ lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(12)} ${"\u2500".repeat(12)} ${"\u2500".repeat(6)} ${"\u2500".repeat(10)}`);
6154
6498
  for (const s of summary.byScenario) {
6155
6499
  const label = s.name.length > 38 ? s.name.slice(0, 35) + "..." : s.name;
6156
- lines.push(` ${label.padEnd(40)} ${formatDollars(s.costCents).padEnd(12)} ${formatTokens(s.tokens).padEnd(12)} ${s.runs}`);
6500
+ const avgPerRun = s.runs > 0 ? s.costCents / s.runs : 0;
6501
+ lines.push(` ${label.padEnd(40)} ${formatDollars(s.costCents).padEnd(12)} ${formatDollars(avgPerRun).padEnd(12)} ${String(s.runs).padEnd(6)} ${formatTokens(s.tokens)}`);
6157
6502
  }
6158
6503
  }
6159
6504
  lines.push("");
@@ -6163,6 +6508,17 @@ function formatCostsTerminal(summary) {
6163
6508
  function formatCostsJSON(summary) {
6164
6509
  return JSON.stringify(summary, null, 2);
6165
6510
  }
6511
+ function formatCostsCsv(summary) {
6512
+ const lines = [];
6513
+ lines.push("scenario,runs,total_cost_cents,avg_cost_cents,tokens");
6514
+ for (const s of summary.byScenario) {
6515
+ const avgCostCents = s.runs > 0 ? s.costCents / s.runs : 0;
6516
+ const name = s.name.includes(",") ? `"${s.name.replace(/"/g, '""')}"` : s.name;
6517
+ lines.push(`${name},${s.runs},${s.costCents},${avgCostCents.toFixed(2)},${s.tokens}`);
6518
+ }
6519
+ return lines.join(`
6520
+ `);
6521
+ }
6166
6522
 
6167
6523
  // src/db/schedules.ts
6168
6524
  init_database();
@@ -6505,6 +6861,241 @@ function parseAssertionString(str) {
6505
6861
 
6506
6862
  // src/cli/index.tsx
6507
6863
  import { existsSync as existsSync8, mkdirSync as mkdirSync4 } from "fs";
6864
+ import { jsxDEV } from "react/jsx-dev-runtime";
6865
+ var PRIORITIES = ["low", "medium", "high", "critical"];
6866
+ function AddForm({ onComplete }) {
6867
+ const { exit } = useApp();
6868
+ const [state, setState] = useState({
6869
+ name: "",
6870
+ url: "",
6871
+ description: "",
6872
+ priority: "medium",
6873
+ tags: "",
6874
+ field: "name",
6875
+ buffer: ""
6876
+ });
6877
+ useInput((input, key) => {
6878
+ if (key.escape) {
6879
+ onComplete(null);
6880
+ exit();
6881
+ return;
6882
+ }
6883
+ if (key.return) {
6884
+ if (state.field === "name") {
6885
+ const val = state.buffer.trim();
6886
+ if (!val)
6887
+ return;
6888
+ setState((s) => ({ ...s, name: val, buffer: "", field: "url" }));
6889
+ } else if (state.field === "url") {
6890
+ setState((s) => ({ ...s, url: s.buffer.trim(), buffer: "", field: "description" }));
6891
+ } else if (state.field === "description") {
6892
+ setState((s) => ({ ...s, description: s.buffer.trim(), buffer: "", field: "priority" }));
6893
+ } else if (state.field === "priority") {
6894
+ setState((s) => ({ ...s, buffer: "", field: "tags" }));
6895
+ } else if (state.field === "tags") {
6896
+ setState((s) => ({ ...s, tags: s.buffer.trim(), buffer: "", field: "confirm" }));
6897
+ } else if (state.field === "confirm") {
6898
+ onComplete(state);
6899
+ exit();
6900
+ }
6901
+ return;
6902
+ }
6903
+ if (key.backspace || key.delete) {
6904
+ if (state.field === "priority")
6905
+ return;
6906
+ setState((s) => ({ ...s, buffer: s.buffer.slice(0, -1) }));
6907
+ return;
6908
+ }
6909
+ if (state.field === "priority") {
6910
+ if (key.leftArrow || key.rightArrow) {
6911
+ const idx = PRIORITIES.indexOf(state.priority);
6912
+ const next = key.rightArrow ? PRIORITIES[(idx + 1) % PRIORITIES.length] : PRIORITIES[(idx - 1 + PRIORITIES.length) % PRIORITIES.length];
6913
+ setState((s) => ({ ...s, priority: next }));
6914
+ }
6915
+ return;
6916
+ }
6917
+ if (!key.ctrl && !key.meta && input) {
6918
+ setState((s) => ({ ...s, buffer: s.buffer + input }));
6919
+ }
6920
+ });
6921
+ const fieldLabel = (label, field, value, hint) => {
6922
+ const active = state.field === field;
6923
+ const displayValue = active ? state.buffer + (active ? "\u2588" : "") : value;
6924
+ return /* @__PURE__ */ jsxDEV(Box, {
6925
+ flexDirection: "row",
6926
+ gap: 1,
6927
+ children: [
6928
+ /* @__PURE__ */ jsxDEV(Text, {
6929
+ color: active ? "cyan" : "gray",
6930
+ children: active ? "\u2192" : " "
6931
+ }, undefined, false, undefined, this),
6932
+ /* @__PURE__ */ jsxDEV(Text, {
6933
+ color: active ? "white" : "gray",
6934
+ bold: active,
6935
+ children: [
6936
+ label,
6937
+ ":"
6938
+ ]
6939
+ }, undefined, true, undefined, this),
6940
+ /* @__PURE__ */ jsxDEV(Text, {
6941
+ color: active ? "white" : "gray",
6942
+ children: displayValue || (hint ? hint : "")
6943
+ }, undefined, false, undefined, this),
6944
+ !displayValue && hint && /* @__PURE__ */ jsxDEV(Text, {
6945
+ color: "gray",
6946
+ children: " "
6947
+ }, undefined, false, undefined, this)
6948
+ ]
6949
+ }, field, true, undefined, this);
6950
+ };
6951
+ return /* @__PURE__ */ jsxDEV(Box, {
6952
+ flexDirection: "column",
6953
+ gap: 0,
6954
+ paddingY: 1,
6955
+ children: [
6956
+ /* @__PURE__ */ jsxDEV(Text, {
6957
+ bold: true,
6958
+ color: "cyan",
6959
+ children: " New Test Scenario"
6960
+ }, undefined, false, undefined, this),
6961
+ /* @__PURE__ */ jsxDEV(Text, {
6962
+ color: "gray",
6963
+ children: " \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"
6964
+ }, undefined, false, undefined, this),
6965
+ fieldLabel(" Name ", "name", state.name),
6966
+ fieldLabel(" URL ", "url", state.url, "(optional)"),
6967
+ fieldLabel(" Description", "description", state.description, "(optional)"),
6968
+ /* @__PURE__ */ jsxDEV(Box, {
6969
+ flexDirection: "row",
6970
+ gap: 1,
6971
+ children: [
6972
+ /* @__PURE__ */ jsxDEV(Text, {
6973
+ color: state.field === "priority" ? "cyan" : "gray",
6974
+ children: state.field === "priority" ? "\u2192" : " "
6975
+ }, undefined, false, undefined, this),
6976
+ /* @__PURE__ */ jsxDEV(Text, {
6977
+ color: state.field === "priority" ? "white" : "gray",
6978
+ bold: state.field === "priority",
6979
+ children: " Priority :"
6980
+ }, undefined, false, undefined, this),
6981
+ PRIORITIES.map((p) => /* @__PURE__ */ jsxDEV(Text, {
6982
+ color: p === state.priority ? "cyan" : "gray",
6983
+ bold: p === state.priority,
6984
+ children: p === state.priority ? `[${p}]` : ` ${p} `
6985
+ }, p, false, undefined, this)),
6986
+ state.field === "priority" && /* @__PURE__ */ jsxDEV(Text, {
6987
+ color: "gray",
6988
+ children: " \u2190 \u2192"
6989
+ }, undefined, false, undefined, this)
6990
+ ]
6991
+ }, undefined, true, undefined, this),
6992
+ fieldLabel(" Tags ", "tags", state.tags, "comma-separated, optional"),
6993
+ state.field === "confirm" && /* @__PURE__ */ jsxDEV(Box, {
6994
+ flexDirection: "column",
6995
+ marginTop: 1,
6996
+ children: [
6997
+ /* @__PURE__ */ jsxDEV(Text, {
6998
+ color: "gray",
6999
+ children: " \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500"
7000
+ }, undefined, false, undefined, this),
7001
+ /* @__PURE__ */ jsxDEV(Text, {
7002
+ bold: true,
7003
+ color: "white",
7004
+ children: " Preview:"
7005
+ }, undefined, false, undefined, this),
7006
+ /* @__PURE__ */ jsxDEV(Text, {
7007
+ color: "gray",
7008
+ children: [
7009
+ " name: ",
7010
+ /* @__PURE__ */ jsxDEV(Text, {
7011
+ color: "white",
7012
+ children: state.name
7013
+ }, undefined, false, undefined, this)
7014
+ ]
7015
+ }, undefined, true, undefined, this),
7016
+ state.url && /* @__PURE__ */ jsxDEV(Text, {
7017
+ color: "gray",
7018
+ children: [
7019
+ " url: ",
7020
+ /* @__PURE__ */ jsxDEV(Text, {
7021
+ color: "white",
7022
+ children: state.url
7023
+ }, undefined, false, undefined, this)
7024
+ ]
7025
+ }, undefined, true, undefined, this),
7026
+ state.description && /* @__PURE__ */ jsxDEV(Text, {
7027
+ color: "gray",
7028
+ children: [
7029
+ " description: ",
7030
+ /* @__PURE__ */ jsxDEV(Text, {
7031
+ color: "white",
7032
+ children: state.description
7033
+ }, undefined, false, undefined, this)
7034
+ ]
7035
+ }, undefined, true, undefined, this),
7036
+ /* @__PURE__ */ jsxDEV(Text, {
7037
+ color: "gray",
7038
+ children: [
7039
+ " priority: ",
7040
+ /* @__PURE__ */ jsxDEV(Text, {
7041
+ color: "cyan",
7042
+ children: state.priority
7043
+ }, undefined, false, undefined, this)
7044
+ ]
7045
+ }, undefined, true, undefined, this),
7046
+ state.tags && /* @__PURE__ */ jsxDEV(Text, {
7047
+ color: "gray",
7048
+ children: [
7049
+ " tags: ",
7050
+ /* @__PURE__ */ jsxDEV(Text, {
7051
+ color: "white",
7052
+ children: state.tags
7053
+ }, undefined, false, undefined, this)
7054
+ ]
7055
+ }, undefined, true, undefined, this),
7056
+ /* @__PURE__ */ jsxDEV(Text, {
7057
+ children: " "
7058
+ }, undefined, false, undefined, this),
7059
+ /* @__PURE__ */ jsxDEV(Text, {
7060
+ color: "green",
7061
+ children: " Press Enter to save, Escape to cancel"
7062
+ }, undefined, false, undefined, this)
7063
+ ]
7064
+ }, undefined, true, undefined, this),
7065
+ state.field !== "confirm" && /* @__PURE__ */ jsxDEV(Text, {
7066
+ color: "gray",
7067
+ dimColor: true,
7068
+ children: " Tab/Enter to advance \xB7 Escape to cancel"
7069
+ }, undefined, false, undefined, this)
7070
+ ]
7071
+ }, undefined, true, undefined, this);
7072
+ }
7073
+ async function runInteractiveAdd(projectId) {
7074
+ let savedResult = null;
7075
+ const { waitUntilExit } = render(React.createElement(AddForm, {
7076
+ onComplete: (data) => {
7077
+ savedResult = data;
7078
+ }
7079
+ }));
7080
+ await waitUntilExit();
7081
+ if (savedResult) {
7082
+ const result = savedResult;
7083
+ const tags = result.tags ? result.tags.split(",").map((t) => t.trim()).filter(Boolean) : [];
7084
+ const scenario = createScenario({
7085
+ name: result.name,
7086
+ description: result.description || result.name,
7087
+ steps: [],
7088
+ tags,
7089
+ priority: result.priority,
7090
+ projectId
7091
+ });
7092
+ log(chalk5.green(`
7093
+ Created scenario ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
7094
+ } else {
7095
+ log(chalk5.dim(`
7096
+ Cancelled.`));
7097
+ }
7098
+ }
6508
7099
  function formatToolInput(input) {
6509
7100
  const parts = [];
6510
7101
  for (const [key, value] of Object.entries(input)) {
@@ -6515,7 +7106,19 @@ function formatToolInput(input) {
6515
7106
  return parts.join(" ");
6516
7107
  }
6517
7108
  var program2 = new Command;
6518
- program2.name("testers").version("0.0.4").description("AI-powered browser testing CLI");
7109
+ var QUIET = false;
7110
+ var NO_COLOR = false;
7111
+ function log(...args) {
7112
+ if (QUIET)
7113
+ return;
7114
+ console.log(...args);
7115
+ }
7116
+ function logError(...args) {
7117
+ if (QUIET)
7118
+ return;
7119
+ console.error(...args);
7120
+ }
7121
+ program2.name("testers").version(package_default.version).description("AI-powered browser testing CLI").option("-q, --quiet", "Suppress all output", false).option("--no-color", "Disable color output");
6519
7122
  var CONFIG_DIR2 = join6(process.env["HOME"] ?? "~", ".testers");
6520
7123
  var CONFIG_PATH2 = join6(CONFIG_DIR2, "config.json");
6521
7124
  function getActiveProject() {
@@ -6530,7 +7133,7 @@ function getActiveProject() {
6530
7133
  function resolveProject(optProject) {
6531
7134
  return optProject ?? getActiveProject();
6532
7135
  }
6533
- program2.command("add <name>").description("Create a new test scenario").option("-d, --description <text>", "Scenario description", "").option("-s, --steps <step>", "Test step (repeatable)", (val, acc) => {
7136
+ program2.command("add [name]").alias("create").description("Create a new test scenario (interactive if no name/flags given)").option("-d, --description <text>", "Scenario description", "").option("-s, --steps <step>", "Test step (repeatable)", (val, acc) => {
6534
7137
  acc.push(val);
6535
7138
  return acc;
6536
7139
  }, []).option("-t, --tag <tag>", "Tag (repeatable)", (val, acc) => {
@@ -6539,18 +7142,28 @@ program2.command("add <name>").description("Create a new test scenario").option(
6539
7142
  }, []).option("-p, --priority <level>", "Priority level", "medium").option("-m, --model <model>", "AI model to use").option("--path <path>", "Target path on the URL").option("--auth", "Requires authentication", false).option("--timeout <ms>", "Timeout in milliseconds").option("--project <id>", "Project ID").option("--template <name>", "Seed scenarios from a template (auth, crud, forms, nav, a11y)").option("--assert <assertion>", "Structured assertion (repeatable). Formats: selector:<sel> visible, text:<sel> contains:<text>, no-console-errors, url:contains:<path>, title:contains:<text>, count:<sel> eq:<n>", (val, acc) => {
6540
7143
  acc.push(val);
6541
7144
  return acc;
6542
- }, []).action((name, opts) => {
7145
+ }, []).action(async (name, opts) => {
6543
7146
  try {
7147
+ const hasFlags = opts.description || opts.steps?.length || opts.tag?.length || opts.model || opts.path || opts.auth || opts.timeout || opts.template || opts.assert?.length;
7148
+ if (!name && !hasFlags) {
7149
+ const projectId2 = resolveProject(opts.project);
7150
+ await runInteractiveAdd(projectId2);
7151
+ return;
7152
+ }
7153
+ if (!name) {
7154
+ logError(chalk5.red("Error: scenario name is required"));
7155
+ process.exit(1);
7156
+ }
6544
7157
  if (opts.template) {
6545
7158
  const template = getTemplate(opts.template);
6546
7159
  if (!template) {
6547
- console.error(chalk5.red(`Unknown template: ${opts.template}. Available: ${listTemplateNames().join(", ")}`));
7160
+ logError(chalk5.red(`Unknown template: ${opts.template}. Available: ${listTemplateNames().join(", ")}`));
6548
7161
  process.exit(1);
6549
7162
  }
6550
7163
  const projectId2 = resolveProject(opts.project);
6551
7164
  for (const input of template) {
6552
7165
  const s = createScenario({ ...input, projectId: projectId2 });
6553
- console.log(chalk5.green(` Created ${s.shortId}: ${s.name}`));
7166
+ log(chalk5.green(` Created ${s.shortId}: ${s.name}`));
6554
7167
  }
6555
7168
  return;
6556
7169
  }
@@ -6569,57 +7182,66 @@ program2.command("add <name>").description("Create a new test scenario").option(
6569
7182
  assertions: assertions.length > 0 ? assertions : undefined,
6570
7183
  projectId
6571
7184
  });
6572
- console.log(chalk5.green(`Created scenario ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
7185
+ log(chalk5.green(`Created scenario ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
6573
7186
  } catch (error) {
6574
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7187
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6575
7188
  process.exit(1);
6576
7189
  }
6577
7190
  });
6578
- program2.command("list").description("List test scenarios").option("-t, --tag <tag>", "Filter by tag").option("-p, --priority <level>", "Filter by priority").option("--project <id>", "Filter by project ID").option("-l, --limit <n>", "Limit results", "50").action((opts) => {
7191
+ program2.command("list").alias("ls").description("List test scenarios").option("-t, --tag <tag>", "Filter by tag").option("-p, --priority <level>", "Filter by priority").option("--project <id>", "Filter by project ID").option("-l, --limit <n>", "Limit results", "50").option("--offset <n>", "Skip first N results", "0").option("--json", "Output as JSON", false).action((opts) => {
6579
7192
  try {
6580
7193
  const scenarios = listScenarios({
6581
7194
  tags: opts.tag ? [opts.tag] : undefined,
6582
7195
  priority: opts.priority,
6583
7196
  projectId: opts.project,
6584
- limit: parseInt(opts.limit, 10)
7197
+ limit: parseInt(opts.limit, 10),
7198
+ offset: parseInt(opts.offset, 10) || undefined
6585
7199
  });
6586
- console.log(formatScenarioList(scenarios));
7200
+ if (opts.json) {
7201
+ log(JSON.stringify(scenarios, null, 2));
7202
+ } else {
7203
+ log(formatScenarioList(scenarios));
7204
+ }
6587
7205
  } catch (error) {
6588
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7206
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6589
7207
  process.exit(1);
6590
7208
  }
6591
7209
  });
6592
- program2.command("show <id>").description("Show scenario details").action((id) => {
7210
+ program2.command("show <id>").description("Show scenario details").option("--json", "Output as JSON", false).action((id, opts) => {
6593
7211
  try {
6594
7212
  const scenario = getScenario(id) ?? getScenarioByShortId(id);
6595
7213
  if (!scenario) {
6596
- console.error(chalk5.red(`Scenario not found: ${id}`));
7214
+ logError(chalk5.red(`Scenario not found: ${id}`));
6597
7215
  process.exit(1);
6598
7216
  }
6599
- console.log("");
6600
- console.log(chalk5.bold(` Scenario ${scenario.shortId}`));
6601
- console.log(` Name: ${scenario.name}`);
6602
- console.log(` ID: ${chalk5.dim(scenario.id)}`);
6603
- console.log(` Description: ${scenario.description}`);
6604
- console.log(` Priority: ${scenario.priority}`);
6605
- console.log(` Model: ${scenario.model ?? chalk5.dim("default")}`);
6606
- console.log(` Tags: ${scenario.tags.length > 0 ? scenario.tags.join(", ") : chalk5.dim("none")}`);
6607
- console.log(` Path: ${scenario.targetPath ?? chalk5.dim("none")}`);
6608
- console.log(` Auth: ${scenario.requiresAuth ? "yes" : "no"}`);
6609
- console.log(` Timeout: ${scenario.timeoutMs ? `${scenario.timeoutMs}ms` : chalk5.dim("default")}`);
6610
- console.log(` Version: ${scenario.version}`);
6611
- console.log(` Created: ${scenario.createdAt}`);
6612
- console.log(` Updated: ${scenario.updatedAt}`);
7217
+ if (opts.json) {
7218
+ log(JSON.stringify(scenario, null, 2));
7219
+ return;
7220
+ }
7221
+ log("");
7222
+ log(chalk5.bold(` Scenario ${scenario.shortId}`));
7223
+ log(` Name: ${scenario.name}`);
7224
+ log(` ID: ${chalk5.dim(scenario.id)}`);
7225
+ log(` Description: ${scenario.description}`);
7226
+ log(` Priority: ${scenario.priority}`);
7227
+ log(` Model: ${scenario.model ?? chalk5.dim("default")}`);
7228
+ log(` Tags: ${scenario.tags.length > 0 ? scenario.tags.join(", ") : chalk5.dim("none")}`);
7229
+ log(` Path: ${scenario.targetPath ?? chalk5.dim("none")}`);
7230
+ log(` Auth: ${scenario.requiresAuth ? "yes" : "no"}`);
7231
+ log(` Timeout: ${scenario.timeoutMs ? `${scenario.timeoutMs}ms` : chalk5.dim("default")}`);
7232
+ log(` Version: ${scenario.version}`);
7233
+ log(` Created: ${scenario.createdAt}`);
7234
+ log(` Updated: ${scenario.updatedAt}`);
6613
7235
  if (scenario.steps.length > 0) {
6614
- console.log("");
6615
- console.log(chalk5.bold(" Steps:"));
7236
+ log("");
7237
+ log(chalk5.bold(" Steps:"));
6616
7238
  for (let i = 0;i < scenario.steps.length; i++) {
6617
- console.log(` ${i + 1}. ${scenario.steps[i]}`);
7239
+ log(` ${i + 1}. ${scenario.steps[i]}`);
6618
7240
  }
6619
7241
  }
6620
- console.log("");
7242
+ log("");
6621
7243
  } catch (error) {
6622
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7244
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6623
7245
  process.exit(1);
6624
7246
  }
6625
7247
  });
@@ -6633,7 +7255,7 @@ program2.command("update <id>").description("Update a scenario").option("-n, --n
6633
7255
  try {
6634
7256
  const scenario = getScenario(id) ?? getScenarioByShortId(id);
6635
7257
  if (!scenario) {
6636
- console.error(chalk5.red(`Scenario not found: ${id}`));
7258
+ logError(chalk5.red(`Scenario not found: ${id}`));
6637
7259
  process.exit(1);
6638
7260
  }
6639
7261
  const updated = updateScenario(scenario.id, {
@@ -6644,42 +7266,62 @@ program2.command("update <id>").description("Update a scenario").option("-n, --n
6644
7266
  priority: opts.priority,
6645
7267
  model: opts.model
6646
7268
  }, scenario.version);
6647
- console.log(chalk5.green(`Updated scenario ${chalk5.bold(updated.shortId)}: ${updated.name}`));
7269
+ log(chalk5.green(`Updated scenario ${chalk5.bold(updated.shortId)}: ${updated.name}`));
6648
7270
  } catch (error) {
6649
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7271
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6650
7272
  process.exit(1);
6651
7273
  }
6652
7274
  });
6653
- program2.command("delete <id>").description("Delete a scenario").action((id) => {
7275
+ program2.command("delete <id>").description("Delete a scenario").option("-y, --yes", "Skip confirmation prompt", false).action(async (id, opts) => {
6654
7276
  try {
6655
7277
  const scenario = getScenario(id) ?? getScenarioByShortId(id);
6656
7278
  if (!scenario) {
6657
- console.error(chalk5.red(`Scenario not found: ${id}`));
7279
+ logError(chalk5.red(`Scenario not found: ${id}`));
6658
7280
  process.exit(1);
6659
7281
  }
7282
+ if (!opts.yes) {
7283
+ process.stdout.write(chalk5.yellow(`Delete scenario ${scenario.shortId} "${scenario.name}"? [y/N] `));
7284
+ const answer = await new Promise((resolve2) => {
7285
+ let buf = "";
7286
+ process.stdin.setRawMode?.(true);
7287
+ process.stdin.resume();
7288
+ process.stdin.once("data", (chunk) => {
7289
+ buf = chunk.toString().trim().toLowerCase();
7290
+ process.stdin.setRawMode?.(false);
7291
+ process.stdin.pause();
7292
+ process.stdout.write(`
7293
+ `);
7294
+ resolve2(buf);
7295
+ });
7296
+ });
7297
+ if (answer !== "y" && answer !== "yes") {
7298
+ log(chalk5.dim("Cancelled."));
7299
+ return;
7300
+ }
7301
+ }
6660
7302
  const deleted = deleteScenario(scenario.id);
6661
7303
  if (deleted) {
6662
- console.log(chalk5.green(`Deleted scenario ${scenario.shortId}: ${scenario.name}`));
7304
+ log(chalk5.green(`Deleted scenario ${scenario.shortId}: ${scenario.name}`));
6663
7305
  } else {
6664
- console.error(chalk5.red(`Failed to delete scenario: ${id}`));
7306
+ logError(chalk5.red(`Failed to delete scenario: ${id}`));
6665
7307
  process.exit(1);
6666
7308
  }
6667
7309
  } catch (error) {
6668
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7310
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6669
7311
  process.exit(1);
6670
7312
  }
6671
7313
  });
6672
- program2.command("run [url] [description]").description("Run test scenarios against a URL").option("-t, --tag <tag>", "Filter by tag (repeatable)", (val, acc) => {
7314
+ program2.command("run [url] [description]").alias("test").description("Run test scenarios against a URL").option("-t, --tag <tag>", "Filter by tag (repeatable)", (val, acc) => {
6673
7315
  acc.push(val);
6674
7316
  return acc;
6675
- }, []).option("-s, --scenario <id>", "Run specific scenario ID").option("-p, --priority <level>", "Filter by priority").option("--headed", "Run browser in headed mode", false).option("-m, --model <model>", "AI model to use").option("--parallel <n>", "Number of parallel browsers", "1").option("--json", "Output results as JSON", false).option("-o, --output <filepath>", "Write JSON results to file").option("--timeout <ms>", "Timeout in milliseconds").option("--from-todos", "Import scenarios from todos before running", false).option("--project <id>", "Project ID").option("-b, --background", "Start run in background and return immediately", false).option("--browser <engine>", "Browser engine: playwright or lightpanda", "playwright").option("--env <name>", "Use a named environment for the URL").action(async (urlArg, description, opts) => {
7317
+ }, []).option("-s, --scenario <id>", "Run specific scenario ID").option("-p, --priority <level>", "Filter by priority").option("--headed", "Run browser in headed mode", false).option("-m, --model <model>", "AI model to use").option("--parallel <n>", "Number of parallel browsers", "1").option("--json", "Output results as JSON", false).option("-o, --output <filepath>", "Write JSON results to file").option("--timeout <ms>", "Timeout in milliseconds").option("--from-todos", "Import scenarios from todos before running", false).option("--project <id>", "Project ID").option("-b, --background", "Start run in background and return immediately", false).option("--browser <engine>", "Browser engine: playwright or lightpanda", "playwright").option("--env <name>", "Use a named environment for the URL").option("--dry-run", "Print what would run without launching browser", false).option("--retry <n>", "Retry failed scenarios up to n times", "0").action(async (urlArg, description, opts) => {
6676
7318
  try {
6677
7319
  const projectId = resolveProject(opts.project);
6678
7320
  let url = urlArg;
6679
7321
  if (!url && opts.env) {
6680
7322
  const env = getEnvironment(opts.env);
6681
7323
  if (!env) {
6682
- console.error(chalk5.red(`Environment not found: ${opts.env}`));
7324
+ logError(chalk5.red(`Environment not found: ${opts.env}`));
6683
7325
  process.exit(1);
6684
7326
  }
6685
7327
  url = env.url;
@@ -6688,16 +7330,76 @@ program2.command("run [url] [description]").description("Run test scenarios agai
6688
7330
  const defaultEnv = getDefaultEnvironment();
6689
7331
  if (defaultEnv) {
6690
7332
  url = defaultEnv.url;
6691
- console.log(chalk5.dim(`Using default environment: ${defaultEnv.name} (${defaultEnv.url})`));
7333
+ log(chalk5.dim(`Using default environment: ${defaultEnv.name} (${defaultEnv.url})`));
6692
7334
  }
6693
7335
  }
6694
7336
  if (!url) {
6695
- console.error(chalk5.red("No URL provided. Pass a URL argument, use --env <name>, or set a default environment with 'testers env use <name>'."));
7337
+ logError(chalk5.red("No URL provided. Pass a URL argument, use --env <name>, or set a default environment with 'testers env use <name>'."));
6696
7338
  process.exit(1);
6697
7339
  }
7340
+ if (!opts.dryRun && !opts.background) {
7341
+ const budgetResult = checkBudget(0);
7342
+ if (budgetResult.warning) {
7343
+ log(chalk5.yellow(` \u26A0\uFE0F Budget warning: ${budgetResult.warning}`));
7344
+ if (!budgetResult.allowed) {
7345
+ if (!opts.yes) {
7346
+ log(chalk5.yellow(" Use --yes to run anyway, or check your budget config."));
7347
+ process.exit(1);
7348
+ }
7349
+ log(chalk5.yellow(" --yes passed, proceeding despite budget limit."));
7350
+ }
7351
+ }
7352
+ }
6698
7353
  if (opts.fromTodos) {
6699
7354
  const result = importFromTodos({ projectId });
6700
- console.log(chalk5.blue(`Imported ${result.imported} scenarios from todos (${result.skipped} skipped)`));
7355
+ log(chalk5.blue(`Imported ${result.imported} scenarios from todos (${result.skipped} skipped)`));
7356
+ }
7357
+ if (opts.dryRun) {
7358
+ const dryScenarios = listScenarios({
7359
+ tags: opts.tag.length > 0 ? opts.tag : undefined,
7360
+ projectId
7361
+ }).filter((s) => {
7362
+ if (opts.scenario && s.id !== opts.scenario && s.shortId !== opts.scenario)
7363
+ return false;
7364
+ if (opts.priority && s.priority !== opts.priority)
7365
+ return false;
7366
+ return true;
7367
+ });
7368
+ log("");
7369
+ log(chalk5.bold(" Dry Run \u2014 scenarios that would execute:"));
7370
+ log("");
7371
+ if (dryScenarios.length === 0) {
7372
+ log(chalk5.yellow(" No matching scenarios found."));
7373
+ } else {
7374
+ for (const s of dryScenarios) {
7375
+ const assertionErrors = [];
7376
+ for (const a of s.assertions ?? []) {
7377
+ try {
7378
+ parseAssertionString(a);
7379
+ } catch {
7380
+ assertionErrors.push(a);
7381
+ }
7382
+ }
7383
+ let authOk = true;
7384
+ if (s.authPreset) {
7385
+ const presets = listAuthPresets();
7386
+ authOk = presets.some((p) => p.name === s.authPreset);
7387
+ }
7388
+ const statusIcon = assertionErrors.length === 0 && authOk ? chalk5.green("\u2713") : chalk5.red("\u2717");
7389
+ log(` ${statusIcon} ${chalk5.cyan(s.shortId)} ${s.name} ${chalk5.dim(`[${s.tags.join(", ")}]`)}`);
7390
+ if (assertionErrors.length > 0) {
7391
+ log(chalk5.red(` Invalid assertions: ${assertionErrors.join(", ")}`));
7392
+ }
7393
+ if (!authOk) {
7394
+ log(chalk5.red(` Auth preset not found: ${s.authPreset}`));
7395
+ }
7396
+ }
7397
+ }
7398
+ log("");
7399
+ log(chalk5.dim(` URL: ${url}`));
7400
+ log(chalk5.dim(` Total: ${dryScenarios.length} scenarios`));
7401
+ log("");
7402
+ process.exit(0);
6701
7403
  }
6702
7404
  if (opts.background) {
6703
7405
  if (description) {
@@ -6715,52 +7417,56 @@ program2.command("run [url] [description]").description("Run test scenarios agai
6715
7417
  projectId,
6716
7418
  engine: opts.browser
6717
7419
  });
6718
- console.log(chalk5.green(`Run started in background: ${chalk5.bold(runId.slice(0, 8))}`));
6719
- console.log(chalk5.dim(` Scenarios: ${scenarioCount}`));
6720
- console.log(chalk5.dim(` URL: ${url}`));
6721
- console.log(chalk5.dim(` Check progress: testers results ${runId.slice(0, 8)}`));
7420
+ log(chalk5.green(`Run started in background: ${chalk5.bold(runId.slice(0, 8))}`));
7421
+ log(chalk5.dim(` Scenarios: ${scenarioCount}`));
7422
+ log(chalk5.dim(` URL: ${url}`));
7423
+ log(chalk5.dim(` Check progress: testers results ${runId.slice(0, 8)}`));
6722
7424
  process.exit(0);
6723
7425
  }
6724
7426
  if (!opts.json && !opts.output) {
6725
7427
  onRunEvent((event) => {
6726
7428
  switch (event.type) {
6727
7429
  case "scenario:start":
6728
- console.log(chalk5.blue(` [start] ${event.scenarioName ?? event.scenarioId}`));
7430
+ if (event.retryAttempt) {
7431
+ log(chalk5.yellow(` [retry] Retrying scenario ${event.scenarioName ?? event.scenarioId} (attempt ${event.retryAttempt}/${event.maxRetries})...`));
7432
+ } else {
7433
+ log(chalk5.blue(` [start] ${event.scenarioName ?? event.scenarioId}`));
7434
+ }
6729
7435
  break;
6730
7436
  case "step:thinking":
6731
7437
  if (event.thinking) {
6732
7438
  const preview = event.thinking.length > 120 ? event.thinking.slice(0, 120) + "..." : event.thinking;
6733
- console.log(chalk5.dim(` [think] ${preview}`));
7439
+ log(chalk5.dim(` [think] ${preview}`));
6734
7440
  }
6735
7441
  break;
6736
7442
  case "step:tool_call":
6737
- console.log(chalk5.cyan(` [step ${event.stepNumber}] ${event.toolName}${event.toolInput ? ` ${formatToolInput(event.toolInput)}` : ""}`));
7443
+ log(chalk5.cyan(` [step ${event.stepNumber}] ${event.toolName}${event.toolInput ? ` ${formatToolInput(event.toolInput)}` : ""}`));
6738
7444
  break;
6739
7445
  case "step:tool_result":
6740
7446
  if (event.toolName === "report_result") {
6741
- console.log(chalk5.bold(` [result] ${event.toolResult}`));
7447
+ log(chalk5.bold(` [result] ${event.toolResult}`));
6742
7448
  } else {
6743
7449
  const resultPreview = (event.toolResult ?? "").length > 100 ? (event.toolResult ?? "").slice(0, 100) + "..." : event.toolResult ?? "";
6744
- console.log(chalk5.dim(` [done] ${resultPreview}`));
7450
+ log(chalk5.dim(` [done] ${resultPreview}`));
6745
7451
  }
6746
7452
  break;
6747
7453
  case "screenshot:captured":
6748
- console.log(chalk5.dim(` [screenshot] ${event.screenshotPath}`));
7454
+ log(chalk5.dim(` [screenshot] ${event.screenshotPath}`));
6749
7455
  break;
6750
7456
  case "scenario:pass":
6751
- console.log(chalk5.green(` [PASS] ${event.scenarioName}`));
7457
+ log(chalk5.green(` [PASS] ${event.scenarioName}`));
6752
7458
  break;
6753
7459
  case "scenario:fail":
6754
- console.log(chalk5.red(` [FAIL] ${event.scenarioName}`));
7460
+ log(chalk5.red(` [FAIL] ${event.scenarioName}`));
6755
7461
  break;
6756
7462
  case "scenario:error":
6757
- console.log(chalk5.yellow(` [ERR] ${event.scenarioName}: ${event.error}`));
7463
+ log(chalk5.yellow(` [ERR] ${event.scenarioName}: ${event.error}`));
6758
7464
  break;
6759
7465
  }
6760
7466
  });
6761
- console.log("");
6762
- console.log(chalk5.bold(` Running tests against ${url}`));
6763
- console.log("");
7467
+ log("");
7468
+ log(chalk5.bold(` Running tests against ${url}`));
7469
+ log("");
6764
7470
  }
6765
7471
  if (description) {
6766
7472
  const scenario = createScenario({
@@ -6776,6 +7482,7 @@ program2.command("run [url] [description]").description("Run test scenarios agai
6776
7482
  headed: opts.headed,
6777
7483
  parallel: parseInt(opts.parallel, 10),
6778
7484
  timeout: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
7485
+ retry: parseInt(opts.retry ?? "0", 10),
6779
7486
  projectId,
6780
7487
  engine: opts.browser
6781
7488
  });
@@ -6783,16 +7490,22 @@ program2.command("run [url] [description]").description("Run test scenarios agai
6783
7490
  const jsonOutput = formatJSON(run2, results2);
6784
7491
  if (opts.output) {
6785
7492
  writeFileSync3(opts.output, jsonOutput, "utf-8");
6786
- console.log(chalk5.green(`Results written to ${opts.output}`));
7493
+ log(chalk5.green(`Results written to ${opts.output}`));
6787
7494
  }
6788
7495
  if (opts.json) {
6789
- console.log(jsonOutput);
7496
+ log(jsonOutput);
6790
7497
  }
6791
7498
  } else {
6792
- console.log(formatTerminal(run2, results2));
7499
+ log(formatTerminal(run2, results2));
6793
7500
  }
6794
7501
  process.exit(getExitCode(run2));
6795
7502
  }
7503
+ const noFilters = !opts.scenario && opts.tag.length === 0 && !opts.priority;
7504
+ if (noFilters && !opts.json && !opts.output) {
7505
+ const allScenarios = listScenarios({ projectId });
7506
+ log(chalk5.bold(` Running all ${allScenarios.length} scenarios...`));
7507
+ log("");
7508
+ }
6796
7509
  const { run, results } = await runByFilter({
6797
7510
  url,
6798
7511
  tags: opts.tag.length > 0 ? opts.tag : undefined,
@@ -6802,6 +7515,7 @@ program2.command("run [url] [description]").description("Run test scenarios agai
6802
7515
  headed: opts.headed,
6803
7516
  parallel: parseInt(opts.parallel, 10),
6804
7517
  timeout: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
7518
+ retry: parseInt(opts.retry ?? "0", 10),
6805
7519
  projectId,
6806
7520
  engine: opts.browser
6807
7521
  });
@@ -6809,43 +7523,52 @@ program2.command("run [url] [description]").description("Run test scenarios agai
6809
7523
  const jsonOutput = formatJSON(run, results);
6810
7524
  if (opts.output) {
6811
7525
  writeFileSync3(opts.output, jsonOutput, "utf-8");
6812
- console.log(chalk5.green(`Results written to ${opts.output}`));
7526
+ log(chalk5.green(`Results written to ${opts.output}`));
6813
7527
  }
6814
7528
  if (opts.json) {
6815
- console.log(jsonOutput);
7529
+ log(jsonOutput);
6816
7530
  }
6817
7531
  } else {
6818
- console.log(formatTerminal(run, results));
7532
+ log(formatTerminal(run, results));
6819
7533
  }
6820
7534
  process.exit(getExitCode(run));
6821
7535
  } catch (error) {
6822
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7536
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6823
7537
  process.exit(1);
6824
7538
  }
6825
7539
  });
6826
- program2.command("runs").description("List past test runs").option("--status <status>", "Filter by status").option("-l, --limit <n>", "Limit results", "20").action((opts) => {
7540
+ program2.command("runs").description("List past test runs").option("--status <status>", "Filter by status").option("-l, --limit <n>", "Limit results", "20").option("--offset <n>", "Skip first N results", "0").option("--json", "Output as JSON", false).action((opts) => {
6827
7541
  try {
6828
7542
  const runs = listRuns({
6829
7543
  status: opts.status,
6830
- limit: parseInt(opts.limit, 10)
7544
+ limit: parseInt(opts.limit, 10),
7545
+ offset: parseInt(opts.offset, 10) || undefined
6831
7546
  });
6832
- console.log(formatRunList(runs));
7547
+ if (opts.json) {
7548
+ log(JSON.stringify(runs, null, 2));
7549
+ } else {
7550
+ log(formatRunList(runs));
7551
+ }
6833
7552
  } catch (error) {
6834
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7553
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6835
7554
  process.exit(1);
6836
7555
  }
6837
7556
  });
6838
- program2.command("results <run-id>").description("Show results for a test run").action((runId) => {
7557
+ program2.command("results <run-id>").description("Show results for a test run").option("--json", "Output as JSON", false).action((runId, opts) => {
6839
7558
  try {
6840
7559
  const run = getRun(runId);
6841
7560
  if (!run) {
6842
- console.error(chalk5.red(`Run not found: ${runId}`));
7561
+ logError(chalk5.red(`Run not found: ${runId}`));
6843
7562
  process.exit(1);
6844
7563
  }
6845
7564
  const results = getResultsByRun(run.id);
6846
- console.log(formatTerminal(run, results));
7565
+ if (opts.json) {
7566
+ log(formatJSON(run, results));
7567
+ } else {
7568
+ log(formatTerminal(run, results));
7569
+ }
6847
7570
  } catch (error) {
6848
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7571
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6849
7572
  process.exit(1);
6850
7573
  }
6851
7574
  });
@@ -6855,43 +7578,43 @@ program2.command("screenshots <id>").description("List screenshots for a run or
6855
7578
  if (run) {
6856
7579
  const results = getResultsByRun(run.id);
6857
7580
  let total = 0;
6858
- console.log("");
6859
- console.log(chalk5.bold(` Screenshots for run ${run.id.slice(0, 8)}`));
6860
- console.log("");
7581
+ log("");
7582
+ log(chalk5.bold(` Screenshots for run ${run.id.slice(0, 8)}`));
7583
+ log("");
6861
7584
  for (const result of results) {
6862
7585
  const screenshots2 = listScreenshots(result.id);
6863
7586
  if (screenshots2.length > 0) {
6864
7587
  const scenario = getScenario(result.scenarioId);
6865
7588
  const label = scenario ? `${scenario.shortId}: ${scenario.name}` : result.scenarioId.slice(0, 8);
6866
- console.log(chalk5.bold(` ${label}`));
7589
+ log(chalk5.bold(` ${label}`));
6867
7590
  for (const ss of screenshots2) {
6868
- console.log(` ${chalk5.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk5.dim(ss.filePath)}`);
7591
+ log(` ${chalk5.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk5.dim(ss.filePath)}`);
6869
7592
  total++;
6870
7593
  }
6871
- console.log("");
7594
+ log("");
6872
7595
  }
6873
7596
  }
6874
7597
  if (total === 0) {
6875
- console.log(chalk5.dim(" No screenshots found."));
6876
- console.log("");
7598
+ log(chalk5.dim(" No screenshots found."));
7599
+ log("");
6877
7600
  }
6878
7601
  return;
6879
7602
  }
6880
7603
  const screenshots = listScreenshots(id);
6881
7604
  if (screenshots.length > 0) {
6882
- console.log("");
6883
- console.log(chalk5.bold(` Screenshots for result ${id.slice(0, 8)}`));
6884
- console.log("");
7605
+ log("");
7606
+ log(chalk5.bold(` Screenshots for result ${id.slice(0, 8)}`));
7607
+ log("");
6885
7608
  for (const ss of screenshots) {
6886
- console.log(` ${chalk5.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk5.dim(ss.filePath)}`);
7609
+ log(` ${chalk5.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk5.dim(ss.filePath)}`);
6887
7610
  }
6888
- console.log("");
7611
+ log("");
6889
7612
  return;
6890
7613
  }
6891
- console.error(chalk5.red(`No screenshots found for: ${id}`));
7614
+ logError(chalk5.red(`No screenshots found for: ${id}`));
6892
7615
  process.exit(1);
6893
7616
  } catch (error) {
6894
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7617
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6895
7618
  process.exit(1);
6896
7619
  }
6897
7620
  });
@@ -6900,7 +7623,7 @@ program2.command("import <dir>").description("Import markdown test files as scen
6900
7623
  const absDir = resolve(dir);
6901
7624
  const files = readdirSync(absDir).filter((f) => f.endsWith(".md"));
6902
7625
  if (files.length === 0) {
6903
- console.log(chalk5.dim("No .md files found in directory."));
7626
+ log(chalk5.dim("No .md files found in directory."));
6904
7627
  return;
6905
7628
  }
6906
7629
  let imported = 0;
@@ -6930,22 +7653,22 @@ program2.command("import <dir>").description("Import markdown test files as scen
6930
7653
  description: descriptionLines.join(" ") || name,
6931
7654
  steps
6932
7655
  });
6933
- console.log(chalk5.green(` Imported ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
7656
+ log(chalk5.green(` Imported ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
6934
7657
  imported++;
6935
7658
  }
6936
- console.log("");
6937
- console.log(chalk5.green(`Imported ${imported} scenario(s) from ${absDir}`));
7659
+ log("");
7660
+ log(chalk5.green(`Imported ${imported} scenario(s) from ${absDir}`));
6938
7661
  } catch (error) {
6939
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7662
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6940
7663
  process.exit(1);
6941
7664
  }
6942
7665
  });
6943
7666
  program2.command("config").description("Show current configuration").action(() => {
6944
7667
  try {
6945
7668
  const config = loadConfig();
6946
- console.log(JSON.stringify(config, null, 2));
7669
+ log(JSON.stringify(config, null, 2));
6947
7670
  } catch (error) {
6948
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7671
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6949
7672
  process.exit(1);
6950
7673
  }
6951
7674
  });
@@ -6954,33 +7677,33 @@ program2.command("status").description("Show database and auth status").action((
6954
7677
  const config = loadConfig();
6955
7678
  const hasApiKey = !!config.anthropicApiKey || !!process.env["ANTHROPIC_API_KEY"];
6956
7679
  const dbPath = join6(process.env["HOME"] ?? "~", ".testers", "testers.db");
6957
- console.log("");
6958
- console.log(chalk5.bold(" Open Testers Status"));
6959
- console.log("");
6960
- console.log(` ANTHROPIC_API_KEY: ${hasApiKey ? chalk5.green("set") : chalk5.red("not set")}`);
6961
- console.log(` Database: ${dbPath}`);
6962
- console.log(` Default model: ${config.defaultModel}`);
6963
- console.log(` Screenshots dir: ${config.screenshots.dir}`);
6964
- console.log("");
7680
+ log("");
7681
+ log(chalk5.bold(" Open Testers Status"));
7682
+ log("");
7683
+ log(` ANTHROPIC_API_KEY: ${hasApiKey ? chalk5.green("set") : chalk5.red("not set")}`);
7684
+ log(` Database: ${dbPath}`);
7685
+ log(` Default model: ${config.defaultModel}`);
7686
+ log(` Screenshots dir: ${config.screenshots.dir}`);
7687
+ log("");
6965
7688
  } catch (error) {
6966
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7689
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6967
7690
  process.exit(1);
6968
7691
  }
6969
7692
  });
6970
7693
  program2.command("install-browser").description("Install browser engine").option("--engine <engine>", "Engine to install: playwright, lightpanda, or all", "playwright").action(async (opts) => {
6971
7694
  try {
6972
7695
  if (opts.engine === "all" || opts.engine === "playwright") {
6973
- console.log(chalk5.blue("Installing Playwright Chromium..."));
7696
+ log(chalk5.blue("Installing Playwright Chromium..."));
6974
7697
  await installBrowser("playwright");
6975
- console.log(chalk5.green("Playwright Chromium installed."));
7698
+ log(chalk5.green("Playwright Chromium installed."));
6976
7699
  }
6977
7700
  if (opts.engine === "all" || opts.engine === "lightpanda") {
6978
- console.log(chalk5.blue("Installing Lightpanda..."));
7701
+ log(chalk5.blue("Installing Lightpanda..."));
6979
7702
  await installBrowser("lightpanda");
6980
- console.log(chalk5.green("Lightpanda installed."));
7703
+ log(chalk5.green("Lightpanda installed."));
6981
7704
  }
6982
7705
  } catch (error) {
6983
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7706
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6984
7707
  process.exit(1);
6985
7708
  }
6986
7709
  });
@@ -6992,9 +7715,9 @@ projectCmd.command("create <name>").description("Create a new project").option("
6992
7715
  path: opts.path,
6993
7716
  description: opts.description
6994
7717
  });
6995
- console.log(chalk5.green(`Created project ${chalk5.bold(project.name)} (${project.id})`));
7718
+ log(chalk5.green(`Created project ${chalk5.bold(project.name)} (${project.id})`));
6996
7719
  } catch (error) {
6997
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7720
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6998
7721
  process.exit(1);
6999
7722
  }
7000
7723
  });
@@ -7002,20 +7725,20 @@ projectCmd.command("list").description("List all projects").action(() => {
7002
7725
  try {
7003
7726
  const projects = listProjects();
7004
7727
  if (projects.length === 0) {
7005
- console.log(chalk5.dim("No projects found."));
7728
+ log(chalk5.dim("No projects found."));
7006
7729
  return;
7007
7730
  }
7008
- console.log("");
7009
- console.log(chalk5.bold(" Projects"));
7010
- console.log("");
7011
- console.log(` ${"ID".padEnd(38)} ${"Name".padEnd(24)} ${"Path".padEnd(30)} Created`);
7012
- console.log(` ${"\u2500".repeat(38)} ${"\u2500".repeat(24)} ${"\u2500".repeat(30)} ${"\u2500".repeat(20)}`);
7731
+ log("");
7732
+ log(chalk5.bold(" Projects"));
7733
+ log("");
7734
+ log(` ${"ID".padEnd(38)} ${"Name".padEnd(24)} ${"Path".padEnd(30)} Created`);
7735
+ log(` ${"\u2500".repeat(38)} ${"\u2500".repeat(24)} ${"\u2500".repeat(30)} ${"\u2500".repeat(20)}`);
7013
7736
  for (const p of projects) {
7014
- console.log(` ${p.id.padEnd(38)} ${p.name.padEnd(24)} ${(p.path ?? chalk5.dim("\u2014")).toString().padEnd(30)} ${p.createdAt}`);
7737
+ log(` ${p.id.padEnd(38)} ${p.name.padEnd(24)} ${(p.path ?? chalk5.dim("\u2014")).toString().padEnd(30)} ${p.createdAt}`);
7015
7738
  }
7016
- console.log("");
7739
+ log("");
7017
7740
  } catch (error) {
7018
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7741
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7019
7742
  process.exit(1);
7020
7743
  }
7021
7744
  });
@@ -7023,19 +7746,19 @@ projectCmd.command("show <id>").description("Show project details").action((id)
7023
7746
  try {
7024
7747
  const project = getProject(id);
7025
7748
  if (!project) {
7026
- console.error(chalk5.red(`Project not found: ${id}`));
7749
+ logError(chalk5.red(`Project not found: ${id}`));
7027
7750
  process.exit(1);
7028
7751
  }
7029
- console.log("");
7030
- console.log(chalk5.bold(` Project: ${project.name}`));
7031
- console.log(` ID: ${project.id}`);
7032
- console.log(` Path: ${project.path ?? chalk5.dim("none")}`);
7033
- console.log(` Description: ${project.description ?? chalk5.dim("none")}`);
7034
- console.log(` Created: ${project.createdAt}`);
7035
- console.log(` Updated: ${project.updatedAt}`);
7036
- console.log("");
7752
+ log("");
7753
+ log(chalk5.bold(` Project: ${project.name}`));
7754
+ log(` ID: ${project.id}`);
7755
+ log(` Path: ${project.path ?? chalk5.dim("none")}`);
7756
+ log(` Description: ${project.description ?? chalk5.dim("none")}`);
7757
+ log(` Created: ${project.createdAt}`);
7758
+ log(` Updated: ${project.updatedAt}`);
7759
+ log("");
7037
7760
  } catch (error) {
7038
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7761
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7039
7762
  process.exit(1);
7040
7763
  }
7041
7764
  });
@@ -7053,9 +7776,9 @@ projectCmd.command("use <name>").description("Set active project (find or create
7053
7776
  }
7054
7777
  config.activeProject = project.id;
7055
7778
  writeFileSync3(CONFIG_PATH2, JSON.stringify(config, null, 2), "utf-8");
7056
- console.log(chalk5.green(`Active project set to ${chalk5.bold(project.name)} (${project.id})`));
7779
+ log(chalk5.green(`Active project set to ${chalk5.bold(project.name)} (${project.id})`));
7057
7780
  } catch (error) {
7058
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7781
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7059
7782
  process.exit(1);
7060
7783
  }
7061
7784
  });
@@ -7080,40 +7803,44 @@ scheduleCmd.command("create <name>").description("Create a new schedule").requir
7080
7803
  timeoutMs: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
7081
7804
  projectId
7082
7805
  });
7083
- console.log(chalk5.green(`Created schedule ${chalk5.bold(schedule.name)} (${schedule.id})`));
7806
+ log(chalk5.green(`Created schedule ${chalk5.bold(schedule.name)} (${schedule.id})`));
7084
7807
  if (schedule.nextRunAt) {
7085
- console.log(chalk5.dim(` Next run at: ${schedule.nextRunAt}`));
7808
+ log(chalk5.dim(` Next run at: ${schedule.nextRunAt}`));
7086
7809
  }
7087
7810
  } catch (error) {
7088
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7811
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7089
7812
  process.exit(1);
7090
7813
  }
7091
7814
  });
7092
- scheduleCmd.command("list").description("List schedules").option("--project <id>", "Filter by project ID").option("--enabled", "Show only enabled schedules").action((opts) => {
7815
+ scheduleCmd.command("list").description("List schedules").option("--project <id>", "Filter by project ID").option("--enabled", "Show only enabled schedules").option("--json", "Output as JSON", false).action((opts) => {
7093
7816
  try {
7094
7817
  const projectId = resolveProject(opts.project);
7095
7818
  const schedules = listSchedules({
7096
7819
  projectId,
7097
7820
  enabled: opts.enabled ? true : undefined
7098
7821
  });
7822
+ if (opts.json) {
7823
+ log(JSON.stringify(schedules, null, 2));
7824
+ return;
7825
+ }
7099
7826
  if (schedules.length === 0) {
7100
- console.log(chalk5.dim("No schedules found."));
7827
+ log(chalk5.dim("No schedules found."));
7101
7828
  return;
7102
7829
  }
7103
- console.log("");
7104
- console.log(chalk5.bold(" Schedules"));
7105
- console.log("");
7106
- console.log(` ${"Name".padEnd(20)} ${"Cron".padEnd(18)} ${"URL".padEnd(30)} ${"Enabled".padEnd(9)} ${"Next Run".padEnd(22)} Last Run`);
7107
- console.log(` ${"\u2500".repeat(20)} ${"\u2500".repeat(18)} ${"\u2500".repeat(30)} ${"\u2500".repeat(9)} ${"\u2500".repeat(22)} ${"\u2500".repeat(22)}`);
7830
+ log("");
7831
+ log(chalk5.bold(" Schedules"));
7832
+ log("");
7833
+ log(` ${"Name".padEnd(20)} ${"Cron".padEnd(18)} ${"URL".padEnd(30)} ${"Enabled".padEnd(9)} ${"Next Run".padEnd(22)} Last Run`);
7834
+ log(` ${"\u2500".repeat(20)} ${"\u2500".repeat(18)} ${"\u2500".repeat(30)} ${"\u2500".repeat(9)} ${"\u2500".repeat(22)} ${"\u2500".repeat(22)}`);
7108
7835
  for (const s of schedules) {
7109
7836
  const enabled = s.enabled ? chalk5.green("yes") : chalk5.red("no");
7110
7837
  const nextRun = s.nextRunAt ?? chalk5.dim("\u2014");
7111
7838
  const lastRun = s.lastRunAt ?? chalk5.dim("\u2014");
7112
- console.log(` ${s.name.padEnd(20)} ${s.cronExpression.padEnd(18)} ${s.url.padEnd(30)} ${enabled.toString().padEnd(9)} ${nextRun.toString().padEnd(22)} ${lastRun}`);
7839
+ log(` ${s.name.padEnd(20)} ${s.cronExpression.padEnd(18)} ${s.url.padEnd(30)} ${enabled.toString().padEnd(9)} ${nextRun.toString().padEnd(22)} ${lastRun}`);
7113
7840
  }
7114
- console.log("");
7841
+ log("");
7115
7842
  } catch (error) {
7116
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7843
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7117
7844
  process.exit(1);
7118
7845
  }
7119
7846
  });
@@ -7121,47 +7848,47 @@ scheduleCmd.command("show <id>").description("Show schedule details").action((id
7121
7848
  try {
7122
7849
  const schedule = getSchedule(id);
7123
7850
  if (!schedule) {
7124
- console.error(chalk5.red(`Schedule not found: ${id}`));
7851
+ logError(chalk5.red(`Schedule not found: ${id}`));
7125
7852
  process.exit(1);
7126
7853
  }
7127
- console.log("");
7128
- console.log(chalk5.bold(` Schedule: ${schedule.name}`));
7129
- console.log(` ID: ${schedule.id}`);
7130
- console.log(` Cron: ${schedule.cronExpression}`);
7131
- console.log(` URL: ${schedule.url}`);
7132
- console.log(` Enabled: ${schedule.enabled ? chalk5.green("yes") : chalk5.red("no")}`);
7133
- console.log(` Model: ${schedule.model ?? chalk5.dim("default")}`);
7134
- console.log(` Headed: ${schedule.headed ? "yes" : "no"}`);
7135
- console.log(` Parallel: ${schedule.parallel}`);
7136
- console.log(` Timeout: ${schedule.timeoutMs ? `${schedule.timeoutMs}ms` : chalk5.dim("default")}`);
7137
- console.log(` Project: ${schedule.projectId ?? chalk5.dim("none")}`);
7138
- console.log(` Filter: ${JSON.stringify(schedule.scenarioFilter)}`);
7139
- console.log(` Next run: ${schedule.nextRunAt ?? chalk5.dim("not scheduled")}`);
7140
- console.log(` Last run: ${schedule.lastRunAt ?? chalk5.dim("never")}`);
7141
- console.log(` Last run ID: ${schedule.lastRunId ?? chalk5.dim("none")}`);
7142
- console.log(` Created: ${schedule.createdAt}`);
7143
- console.log(` Updated: ${schedule.updatedAt}`);
7144
- console.log("");
7854
+ log("");
7855
+ log(chalk5.bold(` Schedule: ${schedule.name}`));
7856
+ log(` ID: ${schedule.id}`);
7857
+ log(` Cron: ${schedule.cronExpression}`);
7858
+ log(` URL: ${schedule.url}`);
7859
+ log(` Enabled: ${schedule.enabled ? chalk5.green("yes") : chalk5.red("no")}`);
7860
+ log(` Model: ${schedule.model ?? chalk5.dim("default")}`);
7861
+ log(` Headed: ${schedule.headed ? "yes" : "no"}`);
7862
+ log(` Parallel: ${schedule.parallel}`);
7863
+ log(` Timeout: ${schedule.timeoutMs ? `${schedule.timeoutMs}ms` : chalk5.dim("default")}`);
7864
+ log(` Project: ${schedule.projectId ?? chalk5.dim("none")}`);
7865
+ log(` Filter: ${JSON.stringify(schedule.scenarioFilter)}`);
7866
+ log(` Next run: ${schedule.nextRunAt ?? chalk5.dim("not scheduled")}`);
7867
+ log(` Last run: ${schedule.lastRunAt ?? chalk5.dim("never")}`);
7868
+ log(` Last run ID: ${schedule.lastRunId ?? chalk5.dim("none")}`);
7869
+ log(` Created: ${schedule.createdAt}`);
7870
+ log(` Updated: ${schedule.updatedAt}`);
7871
+ log("");
7145
7872
  } catch (error) {
7146
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7873
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7147
7874
  process.exit(1);
7148
7875
  }
7149
7876
  });
7150
7877
  scheduleCmd.command("enable <id>").description("Enable a schedule").action((id) => {
7151
7878
  try {
7152
7879
  const schedule = updateSchedule(id, { enabled: true });
7153
- console.log(chalk5.green(`Enabled schedule ${chalk5.bold(schedule.name)}`));
7880
+ log(chalk5.green(`Enabled schedule ${chalk5.bold(schedule.name)}`));
7154
7881
  } catch (error) {
7155
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7882
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7156
7883
  process.exit(1);
7157
7884
  }
7158
7885
  });
7159
7886
  scheduleCmd.command("disable <id>").description("Disable a schedule").action((id) => {
7160
7887
  try {
7161
7888
  const schedule = updateSchedule(id, { enabled: false });
7162
- console.log(chalk5.green(`Disabled schedule ${chalk5.bold(schedule.name)}`));
7889
+ log(chalk5.green(`Disabled schedule ${chalk5.bold(schedule.name)}`));
7163
7890
  } catch (error) {
7164
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7891
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7165
7892
  process.exit(1);
7166
7893
  }
7167
7894
  });
@@ -7169,13 +7896,13 @@ scheduleCmd.command("delete <id>").description("Delete a schedule").action((id)
7169
7896
  try {
7170
7897
  const deleted = deleteSchedule(id);
7171
7898
  if (deleted) {
7172
- console.log(chalk5.green(`Deleted schedule: ${id}`));
7899
+ log(chalk5.green(`Deleted schedule: ${id}`));
7173
7900
  } else {
7174
- console.error(chalk5.red(`Schedule not found: ${id}`));
7901
+ logError(chalk5.red(`Schedule not found: ${id}`));
7175
7902
  process.exit(1);
7176
7903
  }
7177
7904
  } catch (error) {
7178
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7905
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7179
7906
  process.exit(1);
7180
7907
  }
7181
7908
  });
@@ -7183,11 +7910,11 @@ scheduleCmd.command("run <id>").description("Manually trigger a schedule").optio
7183
7910
  try {
7184
7911
  const schedule = getSchedule(id);
7185
7912
  if (!schedule) {
7186
- console.error(chalk5.red(`Schedule not found: ${id}`));
7913
+ logError(chalk5.red(`Schedule not found: ${id}`));
7187
7914
  process.exit(1);
7188
7915
  return;
7189
7916
  }
7190
- console.log(chalk5.blue(`Running schedule ${chalk5.bold(schedule.name)} against ${schedule.url}...`));
7917
+ log(chalk5.blue(`Running schedule ${chalk5.bold(schedule.name)} against ${schedule.url}...`));
7191
7918
  const { run, results } = await runByFilter({
7192
7919
  url: schedule.url,
7193
7920
  tags: schedule.scenarioFilter.tags,
@@ -7200,21 +7927,21 @@ scheduleCmd.command("run <id>").description("Manually trigger a schedule").optio
7200
7927
  projectId: schedule.projectId ?? undefined
7201
7928
  });
7202
7929
  if (opts.json) {
7203
- console.log(formatJSON(run, results));
7930
+ log(formatJSON(run, results));
7204
7931
  } else {
7205
- console.log(formatTerminal(run, results));
7932
+ log(formatTerminal(run, results));
7206
7933
  }
7207
7934
  process.exit(getExitCode(run));
7208
7935
  } catch (error) {
7209
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7936
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7210
7937
  process.exit(1);
7211
7938
  }
7212
7939
  });
7213
7940
  program2.command("daemon").description("Start the scheduler daemon").option("--interval <seconds>", "Check interval in seconds", "60").action(async (opts) => {
7214
7941
  try {
7215
7942
  const intervalMs = parseInt(opts.interval, 10) * 1000;
7216
- console.log(chalk5.blue("Scheduler daemon started. Press Ctrl+C to stop."));
7217
- console.log(chalk5.dim(` Check interval: ${opts.interval}s`));
7943
+ log(chalk5.blue("Scheduler daemon started. Press Ctrl+C to stop."));
7944
+ log(chalk5.dim(` Check interval: ${opts.interval}s`));
7218
7945
  let running = true;
7219
7946
  const checkAndRun = async () => {
7220
7947
  while (running) {
@@ -7223,7 +7950,7 @@ program2.command("daemon").description("Start the scheduler daemon").option("--i
7223
7950
  const now2 = new Date().toISOString();
7224
7951
  for (const schedule of schedules) {
7225
7952
  if (schedule.nextRunAt && schedule.nextRunAt <= now2) {
7226
- console.log(chalk5.blue(`[${new Date().toISOString()}] Triggering schedule: ${schedule.name}`));
7953
+ log(chalk5.blue(`[${new Date().toISOString()}] Triggering schedule: ${schedule.name}`));
7227
7954
  try {
7228
7955
  const { run } = await runByFilter({
7229
7956
  url: schedule.url,
@@ -7237,60 +7964,60 @@ program2.command("daemon").description("Start the scheduler daemon").option("--i
7237
7964
  projectId: schedule.projectId ?? undefined
7238
7965
  });
7239
7966
  const statusColor = run.status === "passed" ? chalk5.green : chalk5.red;
7240
- console.log(` ${statusColor(run.status)} \u2014 ${run.passed}/${run.total} passed`);
7967
+ log(` ${statusColor(run.status)} \u2014 ${run.passed}/${run.total} passed`);
7241
7968
  updateSchedule(schedule.id, {});
7242
7969
  } catch (err) {
7243
- console.error(chalk5.red(` Error running schedule ${schedule.name}: ${err instanceof Error ? err.message : String(err)}`));
7970
+ logError(chalk5.red(` Error running schedule ${schedule.name}: ${err instanceof Error ? err.message : String(err)}`));
7244
7971
  }
7245
7972
  }
7246
7973
  }
7247
7974
  } catch (err) {
7248
- console.error(chalk5.red(`Daemon error: ${err instanceof Error ? err.message : String(err)}`));
7975
+ logError(chalk5.red(`Daemon error: ${err instanceof Error ? err.message : String(err)}`));
7249
7976
  }
7250
7977
  await new Promise((resolve2) => setTimeout(resolve2, intervalMs));
7251
7978
  }
7252
7979
  };
7253
7980
  process.on("SIGINT", () => {
7254
- console.log(chalk5.yellow(`
7981
+ log(chalk5.yellow(`
7255
7982
  Shutting down scheduler daemon...`));
7256
7983
  running = false;
7257
7984
  process.exit(0);
7258
7985
  });
7259
7986
  process.on("SIGTERM", () => {
7260
- console.log(chalk5.yellow(`
7987
+ log(chalk5.yellow(`
7261
7988
  Shutting down scheduler daemon...`));
7262
7989
  running = false;
7263
7990
  process.exit(0);
7264
7991
  });
7265
7992
  await checkAndRun();
7266
7993
  } catch (error) {
7267
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7994
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7268
7995
  process.exit(1);
7269
7996
  }
7270
7997
  });
7271
- program2.command("init").description("Initialize a new testing project").option("-n, --name <name>", "Project name").option("-u, --url <url>", "Base URL").option("-p, --path <path>", "Project path").option("--ci <provider>", "Generate CI workflow (github)").action((opts) => {
7998
+ program2.command("init").description("Initialize a new testing project").option("-n, --name <name>", "Project name").option("-u, --url <url>", "Base URL").option("-p, --path <path>", "Project path").option("--ci <provider>", "Generate CI workflow (github)").action(async (opts) => {
7272
7999
  try {
7273
- const { project, scenarios, framework } = initProject({
8000
+ const { project, scenarios, framework, url } = initProject({
7274
8001
  name: opts.name,
7275
8002
  url: opts.url,
7276
8003
  path: opts.path
7277
8004
  });
7278
- console.log("");
7279
- console.log(chalk5.bold(" Project initialized!"));
7280
- console.log("");
8005
+ log("");
8006
+ log(chalk5.bold(" Project initialized!"));
8007
+ log("");
7281
8008
  if (framework) {
7282
- console.log(` Framework: ${chalk5.cyan(framework.name)}`);
8009
+ log(` Framework: ${chalk5.cyan(framework.name)}`);
7283
8010
  if (framework.features.length > 0) {
7284
- console.log(` Features: ${chalk5.dim(framework.features.join(", "))}`);
8011
+ log(` Features: ${chalk5.dim(framework.features.join(", "))}`);
7285
8012
  }
7286
8013
  } else {
7287
- console.log(` Framework: ${chalk5.dim("not detected")}`);
8014
+ log(` Framework: ${chalk5.dim("not detected")}`);
7288
8015
  }
7289
- console.log(` Project: ${chalk5.green(project.name)} ${chalk5.dim(`(${project.id})`)}`);
7290
- console.log(` Scenarios: ${chalk5.green(String(scenarios.length))} starter scenarios created`);
7291
- console.log("");
8016
+ log(` Project: ${chalk5.green(project.name)} ${chalk5.dim(`(${project.id})`)}`);
8017
+ log(` Scenarios: ${chalk5.green(String(scenarios.length))} starter scenarios created`);
8018
+ log("");
7292
8019
  for (const s of scenarios) {
7293
- console.log(` ${chalk5.dim(s.shortId)} ${s.name} ${chalk5.dim(`[${s.tags.join(", ")}]`)}`);
8020
+ log(` ${chalk5.dim(s.shortId)} ${s.name} ${chalk5.dim(`[${s.tags.join(", ")}]`)}`);
7294
8021
  }
7295
8022
  if (opts.ci === "github") {
7296
8023
  const workflowDir = join6(process.cwd(), ".github", "workflows");
@@ -7299,18 +8026,51 @@ program2.command("init").description("Initialize a new testing project").option(
7299
8026
  }
7300
8027
  const workflowPath = join6(workflowDir, "testers.yml");
7301
8028
  writeFileSync3(workflowPath, generateGitHubActionsWorkflow(), "utf-8");
7302
- console.log(` CI: ${chalk5.green("GitHub Actions workflow written to .github/workflows/testers.yml")}`);
8029
+ log(` CI: ${chalk5.green("GitHub Actions workflow written to .github/workflows/testers.yml")}`);
7303
8030
  } else if (opts.ci) {
7304
- console.log(chalk5.yellow(` Unknown CI provider: ${opts.ci}. Supported: github`));
7305
- }
7306
- console.log("");
7307
- console.log(chalk5.bold(" Next steps:"));
7308
- console.log(` 1. Start your dev server`);
7309
- console.log(` 2. Run ${chalk5.cyan("testers run <url>")} to execute tests`);
7310
- console.log(` 3. Add more scenarios with ${chalk5.cyan("testers add <name>")}`);
7311
- console.log("");
8031
+ log(chalk5.yellow(` Unknown CI provider: ${opts.ci}. Supported: github`));
8032
+ }
8033
+ log("");
8034
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
8035
+ const ask = (q) => new Promise((resolve2) => rl.question(q, resolve2));
8036
+ try {
8037
+ const envAnswer = await ask(" Would you like to configure environments? [y/N] ");
8038
+ if (envAnswer.trim().toLowerCase() === "y") {
8039
+ const envName = await ask(" Environment name (default: staging): ");
8040
+ const envUrl = await ask(` Base URL (default: ${url}): `);
8041
+ const resolvedEnvName = envName.trim() || "staging";
8042
+ const resolvedEnvUrl = envUrl.trim() || url;
8043
+ createEnvironment({ name: resolvedEnvName, url: resolvedEnvUrl, projectId: project.id, isDefault: true });
8044
+ log(chalk5.green(` \u2713 Environment '${resolvedEnvName}' created (${resolvedEnvUrl})`));
8045
+ log("");
8046
+ }
8047
+ const scenarioAnswer = await ask(" Would you like to create your first test scenario? [y/N] ");
8048
+ if (scenarioAnswer.trim().toLowerCase() === "y") {
8049
+ const scenarioName = await ask(" Scenario name: ");
8050
+ const scenarioUrl = await ask(` URL to test (default: ${url}): `);
8051
+ const resolvedScenarioName = scenarioName.trim() || "My first scenario";
8052
+ const resolvedScenarioUrl = scenarioUrl.trim() || url;
8053
+ const newScenario = createScenario({
8054
+ name: resolvedScenarioName,
8055
+ description: `Navigate to ${resolvedScenarioUrl} and verify it loads correctly.`,
8056
+ projectId: project.id,
8057
+ targetPath: resolvedScenarioUrl,
8058
+ tags: ["smoke"],
8059
+ priority: "high"
8060
+ });
8061
+ log(chalk5.green(` \u2713 Scenario '${newScenario.name}' created ${chalk5.dim(`(${newScenario.shortId})`)}`));
8062
+ log("");
8063
+ }
8064
+ } finally {
8065
+ rl.close();
8066
+ }
8067
+ log(chalk5.bold(" Next steps:"));
8068
+ log(` 1. Start your dev server`);
8069
+ log(` 2. Run ${chalk5.cyan("testers run <url>")} to execute tests`);
8070
+ log(` 3. Add more scenarios with ${chalk5.cyan("testers add <name>")}`);
8071
+ log("");
7312
8072
  } catch (error) {
7313
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8073
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7314
8074
  process.exit(1);
7315
8075
  }
7316
8076
  });
@@ -7318,16 +8078,16 @@ program2.command("replay <run-id>").description("Re-run all scenarios from a pre
7318
8078
  try {
7319
8079
  const originalRun = getRun(runId);
7320
8080
  if (!originalRun) {
7321
- console.error(chalk5.red(`Run not found: ${runId}`));
8081
+ logError(chalk5.red(`Run not found: ${runId}`));
7322
8082
  process.exit(1);
7323
8083
  }
7324
8084
  const originalResults = getResultsByRun(originalRun.id);
7325
8085
  const scenarioIds = originalResults.map((r) => r.scenarioId);
7326
8086
  if (scenarioIds.length === 0) {
7327
- console.log(chalk5.dim("No scenarios to replay."));
8087
+ log(chalk5.dim("No scenarios to replay."));
7328
8088
  return;
7329
8089
  }
7330
- console.log(chalk5.blue(`Replaying ${scenarioIds.length} scenarios from run ${originalRun.id.slice(0, 8)}...`));
8090
+ log(chalk5.blue(`Replaying ${scenarioIds.length} scenarios from run ${originalRun.id.slice(0, 8)}...`));
7331
8091
  const { run, results } = await runByFilter({
7332
8092
  url: opts.url ?? originalRun.url,
7333
8093
  scenarioIds,
@@ -7336,13 +8096,13 @@ program2.command("replay <run-id>").description("Re-run all scenarios from a pre
7336
8096
  parallel: parseInt(opts.parallel, 10)
7337
8097
  });
7338
8098
  if (opts.json) {
7339
- console.log(formatJSON(run, results));
8099
+ log(formatJSON(run, results));
7340
8100
  } else {
7341
- console.log(formatTerminal(run, results));
8101
+ log(formatTerminal(run, results));
7342
8102
  }
7343
8103
  process.exit(getExitCode(run));
7344
8104
  } catch (error) {
7345
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8105
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7346
8106
  process.exit(1);
7347
8107
  }
7348
8108
  });
@@ -7350,16 +8110,16 @@ program2.command("retry <run-id>").description("Re-run only failed scenarios fro
7350
8110
  try {
7351
8111
  const originalRun = getRun(runId);
7352
8112
  if (!originalRun) {
7353
- console.error(chalk5.red(`Run not found: ${runId}`));
8113
+ logError(chalk5.red(`Run not found: ${runId}`));
7354
8114
  process.exit(1);
7355
8115
  }
7356
8116
  const originalResults = getResultsByRun(originalRun.id);
7357
8117
  const failedScenarioIds = originalResults.filter((r) => r.status === "failed" || r.status === "error").map((r) => r.scenarioId);
7358
8118
  if (failedScenarioIds.length === 0) {
7359
- console.log(chalk5.green("No failed scenarios to retry. All passed!"));
8119
+ log(chalk5.green("No failed scenarios to retry. All passed!"));
7360
8120
  return;
7361
8121
  }
7362
- console.log(chalk5.blue(`Retrying ${failedScenarioIds.length} failed scenarios from run ${originalRun.id.slice(0, 8)}...`));
8122
+ log(chalk5.blue(`Retrying ${failedScenarioIds.length} failed scenarios from run ${originalRun.id.slice(0, 8)}...`));
7363
8123
  const { run, results } = await runByFilter({
7364
8124
  url: opts.url ?? originalRun.url,
7365
8125
  scenarioIds: failedScenarioIds,
@@ -7368,35 +8128,35 @@ program2.command("retry <run-id>").description("Re-run only failed scenarios fro
7368
8128
  parallel: parseInt(opts.parallel, 10)
7369
8129
  });
7370
8130
  if (!opts.json) {
7371
- console.log("");
7372
- console.log(chalk5.bold(" Comparison with original run:"));
8131
+ log("");
8132
+ log(chalk5.bold(" Comparison with original run:"));
7373
8133
  for (const result of results) {
7374
8134
  const original = originalResults.find((r) => r.scenarioId === result.scenarioId);
7375
8135
  if (original) {
7376
8136
  const changed = original.status !== result.status;
7377
8137
  const arrow = changed ? chalk5.yellow(`${original.status} \u2192 ${result.status}`) : chalk5.dim(`${result.status} (unchanged)`);
7378
8138
  const icon = result.status === "passed" ? chalk5.green("\u2713") : chalk5.red("\u2717");
7379
- console.log(` ${icon} ${result.scenarioId.slice(0, 8)}: ${arrow}`);
8139
+ log(` ${icon} ${result.scenarioId.slice(0, 8)}: ${arrow}`);
7380
8140
  }
7381
8141
  }
7382
- console.log("");
8142
+ log("");
7383
8143
  }
7384
8144
  if (opts.json) {
7385
- console.log(formatJSON(run, results));
8145
+ log(formatJSON(run, results));
7386
8146
  } else {
7387
- console.log(formatTerminal(run, results));
8147
+ log(formatTerminal(run, results));
7388
8148
  }
7389
8149
  process.exit(getExitCode(run));
7390
8150
  } catch (error) {
7391
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8151
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7392
8152
  process.exit(1);
7393
8153
  }
7394
8154
  });
7395
8155
  program2.command("smoke <url>").description("Run autonomous smoke test").option("-m, --model <model>", "AI model").option("--headed", "Watch browser", false).option("--timeout <ms>", "Timeout in milliseconds").option("--json", "JSON output", false).option("--project <id>", "Project ID").action(async (url, opts) => {
7396
8156
  try {
7397
8157
  const projectId = resolveProject(opts.project);
7398
- console.log(chalk5.blue(`Running smoke test against ${chalk5.bold(url)}...`));
7399
- console.log("");
8158
+ log(chalk5.blue(`Running smoke test against ${chalk5.bold(url)}...`));
8159
+ log("");
7400
8160
  const smokeResult = await runSmoke({
7401
8161
  url,
7402
8162
  model: opts.model,
@@ -7405,19 +8165,19 @@ program2.command("smoke <url>").description("Run autonomous smoke test").option(
7405
8165
  projectId
7406
8166
  });
7407
8167
  if (opts.json) {
7408
- console.log(JSON.stringify({
8168
+ log(JSON.stringify({
7409
8169
  run: smokeResult.run,
7410
8170
  result: smokeResult.result,
7411
8171
  pagesVisited: smokeResult.pagesVisited,
7412
8172
  issues: smokeResult.issuesFound
7413
8173
  }, null, 2));
7414
8174
  } else {
7415
- console.log(formatSmokeReport(smokeResult));
8175
+ log(formatSmokeReport(smokeResult));
7416
8176
  }
7417
8177
  const hasCritical = smokeResult.issuesFound.some((i) => i.severity === "critical" || i.severity === "high");
7418
8178
  process.exit(hasCritical ? 1 : 0);
7419
8179
  } catch (error) {
7420
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8180
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7421
8181
  process.exit(1);
7422
8182
  }
7423
8183
  });
@@ -7425,27 +8185,27 @@ program2.command("diff <run1> <run2>").description("Compare two test runs").opti
7425
8185
  try {
7426
8186
  const diff = diffRuns(run1, run2);
7427
8187
  if (opts.json) {
7428
- console.log(formatDiffJSON(diff));
8188
+ log(formatDiffJSON(diff));
7429
8189
  } else {
7430
- console.log(formatDiffTerminal(diff));
8190
+ log(formatDiffTerminal(diff));
7431
8191
  }
7432
8192
  const threshold = parseFloat(opts.threshold);
7433
8193
  const visualResults = compareRunScreenshots(run2, run1, threshold);
7434
8194
  if (visualResults.length > 0) {
7435
8195
  if (opts.json) {
7436
- console.log(JSON.stringify({ visualDiff: visualResults }, null, 2));
8196
+ log(JSON.stringify({ visualDiff: visualResults }, null, 2));
7437
8197
  } else {
7438
- console.log(formatVisualDiffTerminal(visualResults, threshold));
8198
+ log(formatVisualDiffTerminal(visualResults, threshold));
7439
8199
  }
7440
8200
  }
7441
8201
  const hasVisualRegressions = visualResults.some((r) => r.isRegression);
7442
8202
  process.exit(diff.regressions.length > 0 || hasVisualRegressions ? 1 : 0);
7443
8203
  } catch (error) {
7444
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8204
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7445
8205
  process.exit(1);
7446
8206
  }
7447
8207
  });
7448
- program2.command("report [run-id]").description("Generate HTML test report").option("--latest", "Use most recent run", false).option("-o, --output <file>", "Output file path", "report.html").action((runId, opts) => {
8208
+ program2.command("report [run-id]").description("Generate HTML test report").option("--latest", "Use most recent run", false).option("-o, --output <file>", "Output file path", "report.html").option("--open", "Open the report in the browser after generating", false).action((runId, opts) => {
7449
8209
  try {
7450
8210
  let html;
7451
8211
  if (opts.latest || !runId) {
@@ -7454,9 +8214,14 @@ program2.command("report [run-id]").description("Generate HTML test report").opt
7454
8214
  html = generateHtmlReport(runId);
7455
8215
  }
7456
8216
  writeFileSync3(opts.output, html, "utf-8");
7457
- console.log(chalk5.green(`Report generated: ${opts.output}`));
8217
+ const absPath = resolve(opts.output);
8218
+ log(chalk5.green(`Report generated: ${absPath}`));
8219
+ if (opts.open) {
8220
+ const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
8221
+ Bun.spawn([openCmd, absPath]);
8222
+ }
7458
8223
  } catch (error) {
7459
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8224
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7460
8225
  process.exit(1);
7461
8226
  }
7462
8227
  });
@@ -7469,9 +8234,9 @@ authCmd.command("add <name>").description("Create an auth preset").requiredOptio
7469
8234
  password: opts.password,
7470
8235
  loginPath: opts.loginPath
7471
8236
  });
7472
- console.log(chalk5.green(`Created auth preset ${chalk5.bold(preset.name)} (${preset.email})`));
8237
+ log(chalk5.green(`Created auth preset ${chalk5.bold(preset.name)} (${preset.email})`));
7473
8238
  } catch (error) {
7474
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8239
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7475
8240
  process.exit(1);
7476
8241
  }
7477
8242
  });
@@ -7479,20 +8244,20 @@ authCmd.command("list").description("List auth presets").action(() => {
7479
8244
  try {
7480
8245
  const presets = listAuthPresets();
7481
8246
  if (presets.length === 0) {
7482
- console.log(chalk5.dim("No auth presets found."));
8247
+ log(chalk5.dim("No auth presets found."));
7483
8248
  return;
7484
8249
  }
7485
- console.log("");
7486
- console.log(chalk5.bold(" Auth Presets"));
7487
- console.log("");
7488
- console.log(` ${"Name".padEnd(20)} ${"Email".padEnd(30)} ${"Login Path".padEnd(15)} Created`);
7489
- console.log(` ${"\u2500".repeat(20)} ${"\u2500".repeat(30)} ${"\u2500".repeat(15)} ${"\u2500".repeat(22)}`);
8250
+ log("");
8251
+ log(chalk5.bold(" Auth Presets"));
8252
+ log("");
8253
+ log(` ${"Name".padEnd(20)} ${"Email".padEnd(30)} ${"Login Path".padEnd(15)} Created`);
8254
+ log(` ${"\u2500".repeat(20)} ${"\u2500".repeat(30)} ${"\u2500".repeat(15)} ${"\u2500".repeat(22)}`);
7490
8255
  for (const p of presets) {
7491
- console.log(` ${p.name.padEnd(20)} ${p.email.padEnd(30)} ${p.loginPath.padEnd(15)} ${p.createdAt}`);
8256
+ log(` ${p.name.padEnd(20)} ${p.email.padEnd(30)} ${p.loginPath.padEnd(15)} ${p.createdAt}`);
7492
8257
  }
7493
- console.log("");
8258
+ log("");
7494
8259
  } catch (error) {
7495
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8260
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7496
8261
  process.exit(1);
7497
8262
  }
7498
8263
  });
@@ -7500,26 +8265,28 @@ authCmd.command("delete <name>").description("Delete an auth preset").action((na
7500
8265
  try {
7501
8266
  const deleted = deleteAuthPreset(name);
7502
8267
  if (deleted) {
7503
- console.log(chalk5.green(`Deleted auth preset: ${name}`));
8268
+ log(chalk5.green(`Deleted auth preset: ${name}`));
7504
8269
  } else {
7505
- console.error(chalk5.red(`Auth preset not found: ${name}`));
8270
+ logError(chalk5.red(`Auth preset not found: ${name}`));
7506
8271
  process.exit(1);
7507
8272
  }
7508
8273
  } catch (error) {
7509
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8274
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7510
8275
  process.exit(1);
7511
8276
  }
7512
8277
  });
7513
- program2.command("costs").description("Show cost tracking and budget status").option("--project <id>", "Project ID").option("--period <period>", "Time period", "month").option("--json", "JSON output", false).action((opts) => {
8278
+ program2.command("costs").description("Show cost tracking and budget status").option("--project <id>", "Project ID").option("--period <period>", "Time period", "month").option("--json", "JSON output", false).option("--csv", "CSV output", false).action((opts) => {
7514
8279
  try {
7515
8280
  const summary = getCostSummary({ projectId: resolveProject(opts.project), period: opts.period });
7516
- if (opts.json) {
7517
- console.log(formatCostsJSON(summary));
8281
+ if (opts.csv) {
8282
+ log(formatCostsCsv(summary));
8283
+ } else if (opts.json) {
8284
+ log(formatCostsJSON(summary));
7518
8285
  } else {
7519
- console.log(formatCostsTerminal(summary));
8286
+ log(formatCostsTerminal(summary));
7520
8287
  }
7521
8288
  } catch (error) {
7522
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8289
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7523
8290
  process.exit(1);
7524
8291
  }
7525
8292
  });
@@ -7527,18 +8294,18 @@ program2.command("chain <scenario-id>").description("Add a dependency to a scena
7527
8294
  try {
7528
8295
  const scenario = getScenario(scenarioId) ?? getScenarioByShortId(scenarioId);
7529
8296
  if (!scenario) {
7530
- console.error(chalk5.red(`Scenario not found: ${scenarioId}`));
8297
+ logError(chalk5.red(`Scenario not found: ${scenarioId}`));
7531
8298
  process.exit(1);
7532
8299
  }
7533
8300
  const dep = getScenario(opts.dependsOn) ?? getScenarioByShortId(opts.dependsOn);
7534
8301
  if (!dep) {
7535
- console.error(chalk5.red(`Dependency scenario not found: ${opts.dependsOn}`));
8302
+ logError(chalk5.red(`Dependency scenario not found: ${opts.dependsOn}`));
7536
8303
  process.exit(1);
7537
8304
  }
7538
8305
  addDependency(scenario.id, dep.id);
7539
- console.log(chalk5.green(`${scenario.shortId} now depends on ${dep.shortId}`));
8306
+ log(chalk5.green(`${scenario.shortId} now depends on ${dep.shortId}`));
7540
8307
  } catch (error) {
7541
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8308
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7542
8309
  process.exit(1);
7543
8310
  }
7544
8311
  });
@@ -7546,18 +8313,18 @@ program2.command("unchain <scenario-id>").description("Remove a dependency from
7546
8313
  try {
7547
8314
  const scenario = getScenario(scenarioId) ?? getScenarioByShortId(scenarioId);
7548
8315
  if (!scenario) {
7549
- console.error(chalk5.red(`Scenario not found: ${scenarioId}`));
8316
+ logError(chalk5.red(`Scenario not found: ${scenarioId}`));
7550
8317
  process.exit(1);
7551
8318
  }
7552
8319
  const dep = getScenario(opts.from) ?? getScenarioByShortId(opts.from);
7553
8320
  if (!dep) {
7554
- console.error(chalk5.red(`Dependency not found: ${opts.from}`));
8321
+ logError(chalk5.red(`Dependency not found: ${opts.from}`));
7555
8322
  process.exit(1);
7556
8323
  }
7557
8324
  removeDependency(scenario.id, dep.id);
7558
- console.log(chalk5.green(`Removed dependency: ${scenario.shortId} no longer depends on ${dep.shortId}`));
8325
+ log(chalk5.green(`Removed dependency: ${scenario.shortId} no longer depends on ${dep.shortId}`));
7559
8326
  } catch (error) {
7560
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8327
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7561
8328
  process.exit(1);
7562
8329
  }
7563
8330
  });
@@ -7565,34 +8332,34 @@ program2.command("deps <scenario-id>").description("Show dependencies for a scen
7565
8332
  try {
7566
8333
  const scenario = getScenario(scenarioId) ?? getScenarioByShortId(scenarioId);
7567
8334
  if (!scenario) {
7568
- console.error(chalk5.red(`Scenario not found: ${scenarioId}`));
8335
+ logError(chalk5.red(`Scenario not found: ${scenarioId}`));
7569
8336
  process.exit(1);
7570
8337
  }
7571
8338
  const deps = getDependencies(scenario.id);
7572
8339
  const dependents = getDependents(scenario.id);
7573
- console.log("");
7574
- console.log(chalk5.bold(` Dependencies for ${scenario.shortId}: ${scenario.name}`));
7575
- console.log("");
8340
+ log("");
8341
+ log(chalk5.bold(` Dependencies for ${scenario.shortId}: ${scenario.name}`));
8342
+ log("");
7576
8343
  if (deps.length > 0) {
7577
- console.log(chalk5.dim(" Depends on:"));
8344
+ log(chalk5.dim(" Depends on:"));
7578
8345
  for (const depId of deps) {
7579
8346
  const s = getScenario(depId);
7580
- console.log(` \u2192 ${s ? `${s.shortId}: ${s.name}` : depId.slice(0, 8)}`);
8347
+ log(` \u2192 ${s ? `${s.shortId}: ${s.name}` : depId.slice(0, 8)}`);
7581
8348
  }
7582
8349
  } else {
7583
- console.log(chalk5.dim(" No dependencies"));
8350
+ log(chalk5.dim(" No dependencies"));
7584
8351
  }
7585
8352
  if (dependents.length > 0) {
7586
- console.log("");
7587
- console.log(chalk5.dim(" Required by:"));
8353
+ log("");
8354
+ log(chalk5.dim(" Required by:"));
7588
8355
  for (const depId of dependents) {
7589
8356
  const s = getScenario(depId);
7590
- console.log(` \u2190 ${s ? `${s.shortId}: ${s.name}` : depId.slice(0, 8)}`);
8357
+ log(` \u2190 ${s ? `${s.shortId}: ${s.name}` : depId.slice(0, 8)}`);
7591
8358
  }
7592
8359
  }
7593
- console.log("");
8360
+ log("");
7594
8361
  } catch (error) {
7595
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8362
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7596
8363
  process.exit(1);
7597
8364
  }
7598
8365
  });
@@ -7602,7 +8369,7 @@ flowCmd.command("create <name>").description("Create a flow from scenario IDs").
7602
8369
  const ids = opts.chain.split(",").map((id) => {
7603
8370
  const s = getScenario(id.trim()) ?? getScenarioByShortId(id.trim());
7604
8371
  if (!s) {
7605
- console.error(chalk5.red(`Scenario not found: ${id.trim()}`));
8372
+ logError(chalk5.red(`Scenario not found: ${id.trim()}`));
7606
8373
  process.exit(1);
7607
8374
  }
7608
8375
  return s.id;
@@ -7613,49 +8380,49 @@ flowCmd.command("create <name>").description("Create a flow from scenario IDs").
7613
8380
  } catch {}
7614
8381
  }
7615
8382
  const flow = createFlow({ name, scenarioIds: ids, projectId: resolveProject(opts.project) });
7616
- console.log(chalk5.green(`Flow created: ${flow.id.slice(0, 8)} \u2014 ${flow.name} (${ids.length} scenarios)`));
8383
+ log(chalk5.green(`Flow created: ${flow.id.slice(0, 8)} \u2014 ${flow.name} (${ids.length} scenarios)`));
7617
8384
  } catch (error) {
7618
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8385
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7619
8386
  process.exit(1);
7620
8387
  }
7621
8388
  });
7622
8389
  flowCmd.command("list").description("List all flows").option("--project <id>", "Project ID").action((opts) => {
7623
8390
  const flows = listFlows(resolveProject(opts.project) ?? undefined);
7624
8391
  if (flows.length === 0) {
7625
- console.log(chalk5.dim(`
8392
+ log(chalk5.dim(`
7626
8393
  No flows found.
7627
8394
  `));
7628
8395
  return;
7629
8396
  }
7630
- console.log("");
7631
- console.log(chalk5.bold(" Flows"));
7632
- console.log("");
8397
+ log("");
8398
+ log(chalk5.bold(" Flows"));
8399
+ log("");
7633
8400
  for (const f of flows) {
7634
- console.log(` ${chalk5.dim(f.id.slice(0, 8))} ${f.name} ${chalk5.dim(`(${f.scenarioIds.length} scenarios)`)}`);
8401
+ log(` ${chalk5.dim(f.id.slice(0, 8))} ${f.name} ${chalk5.dim(`(${f.scenarioIds.length} scenarios)`)}`);
7635
8402
  }
7636
- console.log("");
8403
+ log("");
7637
8404
  });
7638
8405
  flowCmd.command("show <id>").description("Show flow details").action((id) => {
7639
8406
  const flow = getFlow(id);
7640
8407
  if (!flow) {
7641
- console.error(chalk5.red(`Flow not found: ${id}`));
8408
+ logError(chalk5.red(`Flow not found: ${id}`));
7642
8409
  process.exit(1);
7643
8410
  }
7644
- console.log("");
7645
- console.log(chalk5.bold(` Flow: ${flow.name}`));
7646
- console.log(` ID: ${chalk5.dim(flow.id)}`);
7647
- console.log(` Scenarios (in order):`);
8411
+ log("");
8412
+ log(chalk5.bold(` Flow: ${flow.name}`));
8413
+ log(` ID: ${chalk5.dim(flow.id)}`);
8414
+ log(` Scenarios (in order):`);
7648
8415
  for (let i = 0;i < flow.scenarioIds.length; i++) {
7649
8416
  const s = getScenario(flow.scenarioIds[i]);
7650
- console.log(` ${i + 1}. ${s ? `${s.shortId}: ${s.name}` : flow.scenarioIds[i].slice(0, 8)}`);
8417
+ log(` ${i + 1}. ${s ? `${s.shortId}: ${s.name}` : flow.scenarioIds[i].slice(0, 8)}`);
7651
8418
  }
7652
- console.log("");
8419
+ log("");
7653
8420
  });
7654
8421
  flowCmd.command("delete <id>").description("Delete a flow").action((id) => {
7655
8422
  if (deleteFlow(id))
7656
- console.log(chalk5.green("Flow deleted."));
8423
+ log(chalk5.green("Flow deleted."));
7657
8424
  else {
7658
- console.error(chalk5.red("Flow not found."));
8425
+ logError(chalk5.red("Flow not found."));
7659
8426
  process.exit(1);
7660
8427
  }
7661
8428
  });
@@ -7663,14 +8430,14 @@ flowCmd.command("run <id>").description("Run a flow (scenarios in dependency ord
7663
8430
  try {
7664
8431
  const flow = getFlow(id);
7665
8432
  if (!flow) {
7666
- console.error(chalk5.red(`Flow not found: ${id}`));
8433
+ logError(chalk5.red(`Flow not found: ${id}`));
7667
8434
  process.exit(1);
7668
8435
  }
7669
8436
  if (!opts.url) {
7670
- console.error(chalk5.red("--url is required for flow run"));
8437
+ logError(chalk5.red("--url is required for flow run"));
7671
8438
  process.exit(1);
7672
8439
  }
7673
- console.log(chalk5.blue(`Running flow: ${flow.name} (${flow.scenarioIds.length} scenarios)`));
8440
+ log(chalk5.blue(`Running flow: ${flow.name} (${flow.scenarioIds.length} scenarios)`));
7674
8441
  const { run, results } = await runByFilter({
7675
8442
  url: opts.url,
7676
8443
  scenarioIds: flow.scenarioIds,
@@ -7679,12 +8446,12 @@ flowCmd.command("run <id>").description("Run a flow (scenarios in dependency ord
7679
8446
  parallel: 1
7680
8447
  });
7681
8448
  if (opts.json)
7682
- console.log(formatJSON(run, results));
8449
+ log(formatJSON(run, results));
7683
8450
  else
7684
- console.log(formatTerminal(run, results));
8451
+ log(formatTerminal(run, results));
7685
8452
  process.exit(getExitCode(run));
7686
8453
  } catch (error) {
7687
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8454
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7688
8455
  process.exit(1);
7689
8456
  }
7690
8457
  });
@@ -7698,9 +8465,9 @@ envCmd.command("add <name>").description("Add a named environment").requiredOpti
7698
8465
  projectId: opts.project,
7699
8466
  isDefault: opts.default
7700
8467
  });
7701
- console.log(chalk5.green(`Environment added: ${env.name} \u2192 ${env.url}${env.isDefault ? " (default)" : ""}`));
8468
+ log(chalk5.green(`Environment added: ${env.name} \u2192 ${env.url}${env.isDefault ? " (default)" : ""}`));
7702
8469
  } catch (error) {
7703
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8470
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7704
8471
  process.exit(1);
7705
8472
  }
7706
8473
  });
@@ -7708,16 +8475,16 @@ envCmd.command("list").description("List all environments").option("--project <i
7708
8475
  try {
7709
8476
  const envs = listEnvironments(opts.project);
7710
8477
  if (envs.length === 0) {
7711
- console.log(chalk5.dim("No environments configured. Add one with: testers env add <name> --url <url>"));
8478
+ log(chalk5.dim("No environments configured. Add one with: testers env add <name> --url <url>"));
7712
8479
  return;
7713
8480
  }
7714
8481
  for (const env of envs) {
7715
8482
  const marker = env.isDefault ? chalk5.green(" \u2605 default") : "";
7716
8483
  const auth = env.authPresetName ? chalk5.dim(` (auth: ${env.authPresetName})`) : "";
7717
- console.log(` ${chalk5.bold(env.name)} ${env.url}${auth}${marker}`);
8484
+ log(` ${chalk5.bold(env.name)} ${env.url}${auth}${marker}`);
7718
8485
  }
7719
8486
  } catch (error) {
7720
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8487
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7721
8488
  process.exit(1);
7722
8489
  }
7723
8490
  });
@@ -7725,9 +8492,9 @@ envCmd.command("use <name>").description("Set an environment as the default").ac
7725
8492
  try {
7726
8493
  setDefaultEnvironment(name);
7727
8494
  const env = getEnvironment(name);
7728
- console.log(chalk5.green(`Default environment set: ${env.name} \u2192 ${env.url}`));
8495
+ log(chalk5.green(`Default environment set: ${env.name} \u2192 ${env.url}`));
7729
8496
  } catch (error) {
7730
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8497
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7731
8498
  process.exit(1);
7732
8499
  }
7733
8500
  });
@@ -7735,13 +8502,13 @@ envCmd.command("delete <name>").description("Delete an environment").action((nam
7735
8502
  try {
7736
8503
  const deleted = deleteEnvironment(name);
7737
8504
  if (deleted) {
7738
- console.log(chalk5.green(`Environment deleted: ${name}`));
8505
+ log(chalk5.green(`Environment deleted: ${name}`));
7739
8506
  } else {
7740
- console.error(chalk5.red(`Environment not found: ${name}`));
8507
+ logError(chalk5.red(`Environment not found: ${name}`));
7741
8508
  process.exit(1);
7742
8509
  }
7743
8510
  } catch (error) {
7744
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8511
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7745
8512
  process.exit(1);
7746
8513
  }
7747
8514
  });
@@ -7749,9 +8516,9 @@ program2.command("baseline <run-id>").description("Set a run as the visual basel
7749
8516
  try {
7750
8517
  setBaseline(runId);
7751
8518
  const run = getRun(runId);
7752
- console.log(chalk5.green(`Baseline set: ${chalk5.bold(runId.slice(0, 8))}${run ? ` (${run.status}, ${run.total} scenarios)` : ""}`));
8519
+ log(chalk5.green(`Baseline set: ${chalk5.bold(runId.slice(0, 8))}${run ? ` (${run.status}, ${run.total} scenarios)` : ""}`));
7753
8520
  } catch (error) {
7754
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8521
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7755
8522
  process.exit(1);
7756
8523
  }
7757
8524
  });
@@ -7759,29 +8526,99 @@ program2.command("import-api <spec>").description("Import test scenarios from an
7759
8526
  try {
7760
8527
  const { importFromOpenAPI: importFromOpenAPI2 } = await Promise.resolve().then(() => (init_openapi_import(), exports_openapi_import));
7761
8528
  const { imported, scenarios } = importFromOpenAPI2(spec, resolveProject(opts.project) ?? undefined);
7762
- console.log(chalk5.green(`
8529
+ log(chalk5.green(`
7763
8530
  Imported ${imported} scenarios from API spec:`));
7764
8531
  for (const s of scenarios) {
7765
- console.log(` ${chalk5.cyan(s.shortId)} ${s.name} ${chalk5.dim(`[${s.tags.join(", ")}]`)}`);
8532
+ log(` ${chalk5.cyan(s.shortId)} ${s.name} ${chalk5.dim(`[${s.tags.join(", ")}]`)}`);
7766
8533
  }
7767
- console.log("");
8534
+ log("");
7768
8535
  } catch (error) {
7769
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8536
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7770
8537
  process.exit(1);
7771
8538
  }
7772
8539
  });
7773
8540
  program2.command("record <url>").description("Record a browser session and generate a test scenario").option("-n, --name <name>", "Scenario name", "Recorded session").option("--project <id>", "Project ID").action(async (url, opts) => {
7774
8541
  try {
7775
8542
  const { recordAndSave: recordAndSave2 } = await Promise.resolve().then(() => (init_recorder(), exports_recorder));
7776
- console.log(chalk5.blue("Opening browser for recording..."));
8543
+ log(chalk5.blue("Opening browser for recording..."));
7777
8544
  const { recording, scenario } = await recordAndSave2(url, opts.name, resolveProject(opts.project) ?? undefined);
7778
- console.log("");
7779
- console.log(chalk5.green(`Recording saved as scenario ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
7780
- console.log(chalk5.dim(` ${recording.actions.length} actions recorded in ${(recording.duration / 1000).toFixed(0)}s`));
7781
- console.log(chalk5.dim(` ${scenario.steps.length} steps generated`));
8545
+ log("");
8546
+ log(chalk5.green(`Recording saved as scenario ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
8547
+ log(chalk5.dim(` ${recording.actions.length} actions recorded in ${(recording.duration / 1000).toFixed(0)}s`));
8548
+ log(chalk5.dim(` ${scenario.steps.length} steps generated`));
7782
8549
  } catch (error) {
7783
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8550
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7784
8551
  process.exit(1);
7785
8552
  }
7786
8553
  });
8554
+ program2.command("doctor").description("Check system setup and configuration").action(async () => {
8555
+ let allPassed = true;
8556
+ const hasApiKey = Boolean(process.env["ANTHROPIC_API_KEY"]);
8557
+ if (hasApiKey) {
8558
+ log(chalk5.green("\u2713") + " ANTHROPIC_API_KEY is set");
8559
+ } else {
8560
+ log(chalk5.red("\u2717") + " ANTHROPIC_API_KEY is not set (required for AI-powered tests)");
8561
+ allPassed = false;
8562
+ }
8563
+ const dbPath = join6(process.env["HOME"] ?? "~", ".testers", "testers.db");
8564
+ try {
8565
+ const { Database: Database3 } = await import("bun:sqlite");
8566
+ const db2 = new Database3(dbPath, { create: true });
8567
+ db2.close();
8568
+ log(chalk5.green("\u2713") + ` Database accessible: ${dbPath}`);
8569
+ } catch (err) {
8570
+ log(chalk5.red("\u2717") + ` Database not accessible at ${dbPath}: ${err instanceof Error ? err.message : String(err)}`);
8571
+ allPassed = false;
8572
+ }
8573
+ try {
8574
+ const { chromium: chromium4 } = await import("playwright");
8575
+ const execPath = chromium4.executablePath();
8576
+ const { existsSync: fsExists } = await import("fs");
8577
+ if (fsExists(execPath)) {
8578
+ log(chalk5.green("\u2713") + " Playwright chromium is installed");
8579
+ } else {
8580
+ log(chalk5.red("\u2717") + ` Playwright chromium executable not found at ${execPath}. Run: testers install`);
8581
+ allPassed = false;
8582
+ }
8583
+ } catch {
8584
+ log(chalk5.red("\u2717") + " Playwright is not installed. Run: testers install");
8585
+ allPassed = false;
8586
+ }
8587
+ if (!allPassed) {
8588
+ process.exit(1);
8589
+ }
8590
+ });
8591
+ program2.command("serve").description("Start the Open Testers web dashboard").option("--no-open", "Do not open the browser after starting", false).option("--port <port>", "Port to listen on", "19450").action(async (opts) => {
8592
+ try {
8593
+ const port = parseInt(opts.port, 10);
8594
+ const url = `http://localhost:${port}`;
8595
+ const serverBin = join6(resolve(process.execPath, ".."), "..", "dist", "server", "index.js");
8596
+ const { join: pathJoin, resolve: pathResolve, dirname: dirname2 } = await import("path");
8597
+ const { fileURLToPath } = await import("url");
8598
+ const serverPath = pathJoin(dirname2(fileURLToPath(import.meta.url)), "..", "server", "index.js");
8599
+ const proc = Bun.spawn(["bun", "run", serverPath], {
8600
+ env: { ...process.env, TESTERS_PORT: String(port) },
8601
+ stdout: "inherit",
8602
+ stderr: "inherit"
8603
+ });
8604
+ log(chalk5.green(`Open Testers dashboard starting at ${url}`));
8605
+ if (opts.open !== false) {
8606
+ await new Promise((r) => setTimeout(r, 1500));
8607
+ const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
8608
+ Bun.spawn([openCmd, url]);
8609
+ }
8610
+ await proc.exited;
8611
+ } catch (error) {
8612
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8613
+ process.exit(1);
8614
+ }
8615
+ });
8616
+ program2.hook("preAction", () => {
8617
+ const opts = program2.opts();
8618
+ QUIET = opts.quiet === true;
8619
+ NO_COLOR = opts.color === false || process.env["FORCE_COLOR"] === "0";
8620
+ if (NO_COLOR) {
8621
+ process.env["FORCE_COLOR"] = "0";
8622
+ }
8623
+ });
7787
8624
  program2.parse();