@hasna/testers 0.0.8 → 0.0.11

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
@@ -2560,7 +2560,10 @@ function listScenarios(filter) {
2560
2560
  if (conditions.length > 0) {
2561
2561
  sql += " WHERE " + conditions.join(" AND ");
2562
2562
  }
2563
- sql += " ORDER BY created_at DESC";
2563
+ const sortField = filter?.sort ?? "date";
2564
+ const sortDir = filter?.desc === false ? "ASC" : "DESC";
2565
+ const orderByCol = sortField === "name" ? "name" : sortField === "priority" ? "CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END" : "created_at";
2566
+ sql += ` ORDER BY ${orderByCol} ${sortDir}`;
2564
2567
  if (filter?.limit) {
2565
2568
  sql += " LIMIT ?";
2566
2569
  params.push(filter.limit);
@@ -2666,7 +2669,8 @@ __export(exports_runs, {
2666
2669
  listRuns: () => listRuns,
2667
2670
  getRun: () => getRun,
2668
2671
  deleteRun: () => deleteRun,
2669
- createRun: () => createRun
2672
+ createRun: () => createRun,
2673
+ countRuns: () => countRuns
2670
2674
  });
2671
2675
  function createRun(input) {
2672
2676
  const db2 = getDatabase();
@@ -2707,7 +2711,10 @@ function listRuns(filter) {
2707
2711
  if (conditions.length > 0) {
2708
2712
  sql += " WHERE " + conditions.join(" AND ");
2709
2713
  }
2710
- sql += " ORDER BY started_at DESC";
2714
+ const sortField = filter?.sort ?? "date";
2715
+ const sortDir = filter?.desc === false ? "ASC" : "DESC";
2716
+ const orderByCol = sortField === "duration" ? "(CASE WHEN finished_at IS NULL THEN NULL ELSE (julianday(finished_at) - julianday(started_at)) * 86400000 END)" : sortField === "cost" ? "(SELECT COALESCE(SUM(cost_cents), 0) FROM results WHERE run_id = runs.id)" : "started_at";
2717
+ sql += ` ORDER BY ${orderByCol} ${sortDir}`;
2711
2718
  if (filter?.limit) {
2712
2719
  sql += " LIMIT ?";
2713
2720
  params.push(filter.limit);
@@ -2719,6 +2726,24 @@ function listRuns(filter) {
2719
2726
  const rows = db2.query(sql).all(...params);
2720
2727
  return rows.map(runFromRow);
2721
2728
  }
2729
+ function countRuns(filter) {
2730
+ const db2 = getDatabase();
2731
+ const conditions = [];
2732
+ const params = [];
2733
+ if (filter?.projectId) {
2734
+ conditions.push("project_id = ?");
2735
+ params.push(filter.projectId);
2736
+ }
2737
+ if (filter?.status) {
2738
+ conditions.push("status = ?");
2739
+ params.push(filter.status);
2740
+ }
2741
+ let sql = "SELECT COUNT(*) as count FROM runs";
2742
+ if (conditions.length > 0)
2743
+ sql += " WHERE " + conditions.join(" AND ");
2744
+ const row = db2.query(sql).get(...params);
2745
+ return row.count;
2746
+ }
2722
2747
  function updateRun(id, updates) {
2723
2748
  const db2 = getDatabase();
2724
2749
  const existing = getRun(id);
@@ -3383,11 +3408,95 @@ var {
3383
3408
  Help
3384
3409
  } = import__.default;
3385
3410
 
3411
+ // src/cli/index.tsx
3412
+ import chalk5 from "chalk";
3413
+ // package.json
3414
+ var package_default = {
3415
+ name: "@hasna/testers",
3416
+ version: "0.0.11",
3417
+ description: "AI-powered QA testing CLI \u2014 spawns cheap AI agents to test web apps with headless browsers",
3418
+ type: "module",
3419
+ main: "dist/index.js",
3420
+ types: "dist/index.d.ts",
3421
+ bin: {
3422
+ testers: "dist/cli/index.js",
3423
+ "testers-mcp": "dist/mcp/index.js",
3424
+ "testers-serve": "dist/server/index.js"
3425
+ },
3426
+ exports: {
3427
+ ".": {
3428
+ types: "./dist/index.d.ts",
3429
+ import: "./dist/index.js"
3430
+ }
3431
+ },
3432
+ files: [
3433
+ "dist/",
3434
+ "dashboard/dist/",
3435
+ "LICENSE",
3436
+ "README.md"
3437
+ ],
3438
+ scripts: {
3439
+ 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",
3440
+ "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",
3441
+ "build:mcp": "bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external @anthropic-ai/sdk --external playwright",
3442
+ "build:server": "bun build src/server/index.ts --outdir dist/server --target bun --external @anthropic-ai/sdk --external playwright",
3443
+ "build:lib": "bun build src/index.ts --outdir dist --target bun --external playwright --external @anthropic-ai/sdk --external @modelcontextprotocol/sdk",
3444
+ "build:types": "NODE_OPTIONS='--max-old-space-size=8192' tsc --emitDeclarationOnly --outDir dist --skipLibCheck",
3445
+ "build:dashboard": "cd dashboard && bun run build",
3446
+ typecheck: "tsc --noEmit",
3447
+ test: "bun test",
3448
+ "dev:cli": "bun run src/cli/index.tsx",
3449
+ "dev:mcp": "bun run src/mcp/index.ts",
3450
+ "dev:serve": "bun run src/server/index.ts",
3451
+ prepublishOnly: "bun run build"
3452
+ },
3453
+ dependencies: {
3454
+ "@anthropic-ai/sdk": "^0.52.0",
3455
+ "@modelcontextprotocol/sdk": "^1.12.1",
3456
+ chalk: "^5.4.1",
3457
+ commander: "^13.1.0",
3458
+ ink: "^5.2.0",
3459
+ playwright: "^1.50.0",
3460
+ react: "^18.3.1",
3461
+ zod: "^3.24.2"
3462
+ },
3463
+ devDependencies: {
3464
+ "@types/bun": "latest",
3465
+ "@types/react": "^18.3.18",
3466
+ typescript: "^5.7.3"
3467
+ },
3468
+ engines: {
3469
+ bun: ">=1.0.0"
3470
+ },
3471
+ publishConfig: {
3472
+ access: "public",
3473
+ registry: "https://registry.npmjs.org/"
3474
+ },
3475
+ repository: {
3476
+ type: "git",
3477
+ url: "https://github.com/hasna/open-testers.git"
3478
+ },
3479
+ license: "MIT",
3480
+ keywords: [
3481
+ "testing",
3482
+ "qa",
3483
+ "ai",
3484
+ "playwright",
3485
+ "browser",
3486
+ "screenshot",
3487
+ "automation",
3488
+ "cli",
3489
+ "mcp"
3490
+ ]
3491
+ };
3492
+
3386
3493
  // src/cli/index.tsx
3387
3494
  init_scenarios();
3388
3495
  init_runs();
3389
- import chalk5 from "chalk";
3496
+ import { render, Box, Text, useInput, useApp } from "ink";
3497
+ import React, { useState } from "react";
3390
3498
  import { readFileSync as readFileSync6, readdirSync, writeFileSync as writeFileSync3 } from "fs";
3499
+ import { createInterface } from "readline";
3391
3500
  import { join as join6, resolve } from "path";
3392
3501
 
3393
3502
  // src/db/results.ts
@@ -4595,6 +4704,38 @@ async function dispatchWebhooks(event, run, schedule) {
4595
4704
  }
4596
4705
  }
4597
4706
 
4707
+ // src/lib/logs-integration.ts
4708
+ async function pushFailedRunToLogs(run, failedResults, scenarios) {
4709
+ const logsUrl = process.env.LOGS_URL;
4710
+ if (!logsUrl)
4711
+ return;
4712
+ const scenarioMap = new Map(scenarios.map((s) => [s.id, s]));
4713
+ const entries = failedResults.map((result) => {
4714
+ const scenario = scenarioMap.get(result.scenarioId);
4715
+ return {
4716
+ level: "error",
4717
+ source: "sdk",
4718
+ service: "testers",
4719
+ message: `[testers] Scenario failed: ${scenario?.name ?? result.scenarioId}${result.error ? ` \u2014 ${result.error}` : ""}`,
4720
+ metadata: {
4721
+ run_id: run.id,
4722
+ scenario_id: result.scenarioId,
4723
+ scenario_name: scenario?.name,
4724
+ url: run.url,
4725
+ status: result.status,
4726
+ duration_ms: result.durationMs
4727
+ }
4728
+ };
4729
+ });
4730
+ try {
4731
+ await fetch(`${logsUrl.replace(/\/$/, "")}/api/logs`, {
4732
+ method: "POST",
4733
+ headers: { "Content-Type": "application/json" },
4734
+ body: JSON.stringify(entries)
4735
+ });
4736
+ } catch {}
4737
+ }
4738
+
4598
4739
  // src/lib/runner.ts
4599
4740
  var eventHandler = null;
4600
4741
  function onRunEvent(handler) {
@@ -4606,14 +4747,26 @@ function emit(event) {
4606
4747
  }
4607
4748
  function withTimeout(promise, ms, label) {
4608
4749
  return new Promise((resolve, reject) => {
4750
+ const warningAt = Math.floor(ms * 0.8);
4751
+ const warningTimer = setTimeout(() => {
4752
+ emit({
4753
+ type: "scenario:timeout_warning",
4754
+ scenarioName: label,
4755
+ timeoutMs: ms,
4756
+ elapsedMs: warningAt
4757
+ });
4758
+ }, warningAt);
4609
4759
  const timer = setTimeout(() => {
4610
- reject(new Error(`Scenario timeout after ${ms}ms: ${label}`));
4760
+ clearTimeout(warningTimer);
4761
+ reject(new Error(`Scenario '${label}' timed out after ${ms}ms. Try: testers run --timeout ${ms * 2} or simplify the scenario steps.`));
4611
4762
  }, ms);
4612
4763
  promise.then((val) => {
4613
4764
  clearTimeout(timer);
4765
+ clearTimeout(warningTimer);
4614
4766
  resolve(val);
4615
4767
  }, (err) => {
4616
4768
  clearTimeout(timer);
4769
+ clearTimeout(warningTimer);
4617
4770
  reject(err);
4618
4771
  });
4619
4772
  });
@@ -4642,6 +4795,7 @@ async function runSingleScenario(scenario, runId, options) {
4642
4795
  const targetUrl = scenario.targetPath ? `${options.url.replace(/\/$/, "")}${scenario.targetPath}` : options.url;
4643
4796
  const scenarioTimeout = scenario.timeoutMs ?? options.timeout ?? config.browser.timeout ?? 60000;
4644
4797
  await page.goto(targetUrl, { timeout: Math.min(scenarioTimeout, 30000) });
4798
+ const stepStartTimes = new Map;
4645
4799
  const agentResult = await withTimeout(runAgentLoop({
4646
4800
  client,
4647
4801
  page,
@@ -4651,6 +4805,16 @@ async function runSingleScenario(scenario, runId, options) {
4651
4805
  runId,
4652
4806
  maxTurns: 30,
4653
4807
  onStep: (stepEvent) => {
4808
+ let stepDurationMs;
4809
+ if (stepEvent.type === "tool_call") {
4810
+ stepStartTimes.set(stepEvent.stepNumber, Date.now());
4811
+ } else if (stepEvent.type === "tool_result") {
4812
+ const startTime = stepStartTimes.get(stepEvent.stepNumber);
4813
+ if (startTime !== undefined) {
4814
+ stepDurationMs = Date.now() - startTime;
4815
+ stepStartTimes.delete(stepEvent.stepNumber);
4816
+ }
4817
+ }
4654
4818
  emit({
4655
4819
  type: `step:${stepEvent.type}`,
4656
4820
  scenarioId: scenario.id,
@@ -4660,7 +4824,8 @@ async function runSingleScenario(scenario, runId, options) {
4660
4824
  toolInput: stepEvent.toolInput,
4661
4825
  toolResult: stepEvent.toolResult,
4662
4826
  thinking: stepEvent.thinking,
4663
- stepNumber: stepEvent.stepNumber
4827
+ stepNumber: stepEvent.stepNumber,
4828
+ stepDurationMs
4664
4829
  });
4665
4830
  }
4666
4831
  }), scenarioTimeout, scenario.name);
@@ -4740,6 +4905,7 @@ async function runBatch(scenarios, options) {
4740
4905
  } catch {}
4741
4906
  return true;
4742
4907
  };
4908
+ const maxRetries = options.retry ?? 0;
4743
4909
  if (parallel <= 1) {
4744
4910
  for (const scenario of sortedScenarios) {
4745
4911
  if (!await canRun(scenario)) {
@@ -4750,7 +4916,13 @@ async function runBatch(scenarios, options) {
4750
4916
  emit({ type: "scenario:error", scenarioId: scenario.id, scenarioName: scenario.name, error: "Dependency failed \u2014 skipped", runId: run.id });
4751
4917
  continue;
4752
4918
  }
4753
- const result = await runSingleScenario(scenario, run.id, options);
4919
+ let result = await runSingleScenario(scenario, run.id, options);
4920
+ let attempt = 1;
4921
+ while ((result.status === "failed" || result.status === "error") && attempt <= maxRetries) {
4922
+ emit({ type: "scenario:start", scenarioId: scenario.id, scenarioName: scenario.name, runId: run.id, retryAttempt: attempt + 1, maxRetries: maxRetries + 1 });
4923
+ result = await runSingleScenario(scenario, run.id, options);
4924
+ attempt++;
4925
+ }
4754
4926
  results.push(result);
4755
4927
  if (result.status === "failed" || result.status === "error") {
4756
4928
  failedScenarioIds.add(scenario.id);
@@ -4797,6 +4969,10 @@ async function runBatch(scenarios, options) {
4797
4969
  emit({ type: "run:complete", runId: run.id });
4798
4970
  const eventType = finalRun.status === "failed" ? "failed" : "completed";
4799
4971
  dispatchWebhooks(eventType, finalRun).catch(() => {});
4972
+ if (finalRun.status === "failed") {
4973
+ const failedResults = results.filter((r) => r.status === "failed" || r.status === "error");
4974
+ pushFailedRunToLogs(finalRun, failedResults, scenarios).catch(() => {});
4975
+ }
4800
4976
  return { run: finalRun, results };
4801
4977
  }
4802
4978
  async function runByFilter(options) {
@@ -4912,13 +5088,28 @@ function estimateCost(model, tokens) {
4912
5088
  // src/lib/reporter.ts
4913
5089
  import chalk from "chalk";
4914
5090
  init_scenarios();
4915
- function formatTerminal(run, results) {
5091
+ init_database();
5092
+ function useEmoji() {
5093
+ return !process.env["NO_COLOR"] && process.argv.indexOf("--no-color") === -1;
5094
+ }
5095
+ function formatTerminal(run, results, options) {
4916
5096
  const lines = [];
5097
+ const failedOnly = options?.failedOnly ?? false;
4917
5098
  lines.push("");
4918
5099
  lines.push(chalk.bold(` Run ${run.id.slice(0, 8)} \u2014 ${run.url}`));
4919
5100
  lines.push(chalk.dim(` Model: ${run.model} | Parallel: ${run.parallel} | Headed: ${run.headed ? "yes" : "no"}`));
4920
5101
  lines.push("");
5102
+ if (failedOnly) {
5103
+ const passedCount = results.filter((r) => r.status === "passed").length;
5104
+ if (passedCount > 0) {
5105
+ lines.push(chalk.dim(` (${passedCount} passed scenario${passedCount !== 1 ? "s" : ""} hidden \u2014 use without --failed-only to see all)`));
5106
+ lines.push("");
5107
+ }
5108
+ }
4921
5109
  for (const result of results) {
5110
+ if (failedOnly && result.status !== "failed" && result.status !== "error") {
5111
+ continue;
5112
+ }
4922
5113
  const scenario = getScenario(result.scenarioId);
4923
5114
  const name = scenario ? `${scenario.shortId}: ${scenario.name}` : result.scenarioId.slice(0, 8);
4924
5115
  const screenshots = listScreenshots(result.id);
@@ -4926,21 +5117,22 @@ function formatTerminal(run, results) {
4926
5117
  const screenshotCount = screenshots.length;
4927
5118
  let statusIcon;
4928
5119
  let statusColor;
5120
+ const emoji = useEmoji();
4929
5121
  switch (result.status) {
4930
5122
  case "passed":
4931
- statusIcon = chalk.green("PASS");
5123
+ statusIcon = emoji ? "\u2705" : chalk.green("PASS");
4932
5124
  statusColor = chalk.green;
4933
5125
  break;
4934
5126
  case "failed":
4935
- statusIcon = chalk.red("FAIL");
5127
+ statusIcon = emoji ? "\u274C" : chalk.red("FAIL");
4936
5128
  statusColor = chalk.red;
4937
5129
  break;
4938
5130
  case "error":
4939
- statusIcon = chalk.yellow("ERR ");
5131
+ statusIcon = emoji ? "\u26A0\uFE0F " : chalk.yellow("ERR ");
4940
5132
  statusColor = chalk.yellow;
4941
5133
  break;
4942
5134
  default:
4943
- statusIcon = chalk.dim("SKIP");
5135
+ statusIcon = emoji ? "\u23ED\uFE0F " : chalk.dim("SKIP");
4944
5136
  statusColor = chalk.dim;
4945
5137
  break;
4946
5138
  }
@@ -4953,17 +5145,34 @@ function formatTerminal(run, results) {
4953
5145
  }
4954
5146
  }
4955
5147
  lines.push("");
4956
- lines.push(formatSummary(run));
5148
+ lines.push(formatActionableSummary(run, results));
4957
5149
  lines.push("");
4958
5150
  return lines.join(`
4959
5151
  `);
4960
5152
  }
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}`;
5153
+ function formatActionableSummary(run, results) {
5154
+ const emoji = useEmoji();
5155
+ const passedCount = results.filter((r) => r.status === "passed").length;
5156
+ const failedCount = results.filter((r) => r.status === "failed" || r.status === "error").length;
5157
+ const shortId = run.id.slice(0, 8);
5158
+ const passStr = `${emoji ? "\u2705" : "PASS"} ${passedCount} passed`;
5159
+ const failStr = failedCount > 0 ? ` ${emoji ? "\u274C" : "FAIL"} ${failedCount} failed` : "";
5160
+ const lines = [];
5161
+ lines.push(` ${chalk.bold(passStr)}${failedCount > 0 ? chalk.bold(failStr) : ""}`);
5162
+ if (failedCount > 0) {
5163
+ lines.push(chalk.dim(` retry failed: testers retry ${shortId} | view: testers results ${shortId}`));
5164
+ } else {
5165
+ lines.push(chalk.dim(` view: testers results ${shortId}`));
5166
+ }
5167
+ const totalCostCents = results.reduce((sum, r) => sum + (r.costCents ?? 0), 0);
5168
+ const totalTokens = results.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
5169
+ if (totalTokens > 0) {
5170
+ const costStr = `$${(totalCostCents / 100).toFixed(4)}`;
5171
+ const tokensStr = totalTokens.toLocaleString();
5172
+ lines.push(chalk.dim(` ${emoji ? "\uD83D\uDCB0" : "cost:"} Cost: ${costStr} (${tokensStr} tokens)`));
5173
+ }
5174
+ return lines.join(`
5175
+ `);
4967
5176
  }
4968
5177
  function formatJSON(run, results) {
4969
5178
  const output = {
@@ -5043,6 +5252,15 @@ function formatRunList(runs) {
5043
5252
  return lines.join(`
5044
5253
  `);
5045
5254
  }
5255
+ function getScenarioRunStats(scenarioId) {
5256
+ const db2 = getDatabase();
5257
+ const lastRow = db2.query("SELECT status FROM results WHERE scenario_id = ? ORDER BY created_at DESC LIMIT 1").get(scenarioId);
5258
+ 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);
5259
+ return {
5260
+ lastStatus: lastRow ? lastRow.status : null,
5261
+ passRate: statsRow && statsRow.total > 0 ? `${statsRow.passed}/${statsRow.total}` : "\u2014"
5262
+ };
5263
+ }
5046
5264
  function formatScenarioList(scenarios) {
5047
5265
  const lines = [];
5048
5266
  lines.push("");
@@ -5057,7 +5275,21 @@ function formatScenarioList(scenarios) {
5057
5275
  for (const s of scenarios) {
5058
5276
  const priorityColor = s.priority === "critical" ? chalk.red : s.priority === "high" ? chalk.yellow : s.priority === "medium" ? chalk.blue : chalk.dim;
5059
5277
  const tags = s.tags.length > 0 ? chalk.dim(` [${s.tags.join(", ")}]`) : "";
5060
- lines.push(` ${chalk.cyan(s.shortId)} ${s.name} ${priorityColor(s.priority)}${tags}`);
5278
+ let lastStatusIcon = chalk.dim("\u2014");
5279
+ let passRateStr = chalk.dim("\u2014");
5280
+ if (s.id) {
5281
+ const stats = getScenarioRunStats(s.id);
5282
+ if (stats.lastStatus === "passed")
5283
+ lastStatusIcon = chalk.green("\u2713");
5284
+ else if (stats.lastStatus === "failed")
5285
+ lastStatusIcon = chalk.red("\u2717");
5286
+ else if (stats.lastStatus === "error")
5287
+ lastStatusIcon = chalk.yellow("!");
5288
+ else if (stats.lastStatus === "skipped")
5289
+ lastStatusIcon = chalk.dim("~");
5290
+ passRateStr = stats.passRate === "\u2014" ? chalk.dim("\u2014") : chalk.dim(stats.passRate);
5291
+ }
5292
+ lines.push(` ${chalk.cyan(s.shortId)} ${s.name} ${priorityColor(s.priority)}${tags} ${lastStatusIcon} ${passRateStr}`);
5061
5293
  }
5062
5294
  lines.push("");
5063
5295
  return lines.join(`
@@ -5251,26 +5483,146 @@ function detectFramework(dir) {
5251
5483
  return null;
5252
5484
  }
5253
5485
  function getStarterScenarios(framework, projectId) {
5486
+ if (framework.name === "Next.js") {
5487
+ const scenarios2 = [
5488
+ {
5489
+ name: "Homepage loads",
5490
+ 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.",
5491
+ tags: ["smoke"],
5492
+ priority: "high",
5493
+ projectId
5494
+ },
5495
+ {
5496
+ name: "404 page works",
5497
+ description: "Navigate to a non-existent URL (e.g. /this-page-does-not-exist) and verify the Next.js 404 page renders correctly.",
5498
+ tags: ["smoke"],
5499
+ priority: "medium",
5500
+ projectId
5501
+ },
5502
+ {
5503
+ name: "Navigation links work",
5504
+ description: "Click through the main navigation links and verify each page loads without errors. Check that client-side routing is working correctly.",
5505
+ tags: ["smoke"],
5506
+ priority: "medium",
5507
+ projectId
5508
+ }
5509
+ ];
5510
+ if (framework.features.includes("hasAuth")) {
5511
+ scenarios2.push({
5512
+ name: "Login flow",
5513
+ description: "Navigate to the login page, enter valid credentials, and verify successful authentication and redirect.",
5514
+ tags: ["auth"],
5515
+ priority: "critical",
5516
+ projectId
5517
+ }, {
5518
+ name: "Protected route redirect",
5519
+ description: "Try to access a protected route without authentication and verify you are redirected to the login page.",
5520
+ tags: ["auth"],
5521
+ priority: "high",
5522
+ projectId
5523
+ });
5524
+ }
5525
+ if (framework.features.includes("hasForms")) {
5526
+ scenarios2.push({
5527
+ name: "Form validation",
5528
+ description: "Submit forms with empty/invalid data and verify validation errors appear correctly.",
5529
+ tags: ["forms"],
5530
+ priority: "medium",
5531
+ projectId
5532
+ });
5533
+ }
5534
+ return scenarios2;
5535
+ }
5536
+ if (framework.name === "Vite" || framework.name === "SvelteKit") {
5537
+ const scenarios2 = [
5538
+ {
5539
+ name: "Homepage loads",
5540
+ description: "Navigate to the homepage and verify it loads correctly with no console errors.",
5541
+ tags: ["smoke"],
5542
+ priority: "high",
5543
+ projectId
5544
+ },
5545
+ {
5546
+ name: "Mobile viewport check",
5547
+ description: "Set the viewport to 375x812 (iPhone) and verify the homepage renders correctly without horizontal scrolling or layout issues.",
5548
+ tags: ["responsive"],
5549
+ priority: "medium",
5550
+ projectId
5551
+ },
5552
+ {
5553
+ name: "No console errors",
5554
+ description: "Navigate through the app and verify there are no JavaScript errors or warnings in the browser console.",
5555
+ tags: ["smoke"],
5556
+ priority: "high",
5557
+ projectId
5558
+ }
5559
+ ];
5560
+ if (framework.features.includes("hasAuth")) {
5561
+ scenarios2.push({
5562
+ name: "Login flow",
5563
+ description: "Navigate to the login page, enter valid credentials, and verify successful authentication.",
5564
+ tags: ["auth"],
5565
+ priority: "critical",
5566
+ projectId
5567
+ });
5568
+ }
5569
+ return scenarios2;
5570
+ }
5571
+ if (framework.name === "Nuxt") {
5572
+ const scenarios2 = [
5573
+ {
5574
+ name: "Homepage loads",
5575
+ description: "Navigate to the homepage and verify it loads correctly. Check that the main heading and content are visible.",
5576
+ tags: ["smoke"],
5577
+ priority: "high",
5578
+ projectId
5579
+ },
5580
+ {
5581
+ name: "Navigation works",
5582
+ description: "Click through main navigation links and verify each page loads without errors.",
5583
+ tags: ["smoke"],
5584
+ priority: "medium",
5585
+ projectId
5586
+ },
5587
+ {
5588
+ name: "Mobile viewport check",
5589
+ description: "Set the viewport to 375x812 and verify the homepage renders correctly on mobile.",
5590
+ tags: ["responsive"],
5591
+ priority: "medium",
5592
+ projectId
5593
+ }
5594
+ ];
5595
+ if (framework.features.includes("hasAuth")) {
5596
+ scenarios2.push({
5597
+ name: "Login flow",
5598
+ description: "Navigate to the login page, enter valid credentials, and verify successful authentication.",
5599
+ tags: ["auth"],
5600
+ priority: "critical",
5601
+ projectId
5602
+ });
5603
+ }
5604
+ return scenarios2;
5605
+ }
5254
5606
  const scenarios = [
5255
5607
  {
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.",
5608
+ name: "Homepage loads",
5609
+ 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
5610
  tags: ["smoke"],
5259
5611
  priority: "high",
5260
5612
  projectId
5261
5613
  },
5262
5614
  {
5263
- name: "Navigation works",
5264
- description: "Click through main navigation links and verify each page loads without errors.",
5615
+ name: "Form submit works",
5616
+ description: "Find the main form on the page, fill it in with valid test data, submit it, and verify the success state.",
5265
5617
  tags: ["smoke"],
5266
5618
  priority: "medium",
5267
5619
  projectId
5268
5620
  },
5269
5621
  {
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",
5622
+ name: "Mobile viewport check",
5623
+ description: "Set the viewport to 375x812 (iPhone) and verify the homepage renders correctly without horizontal scrolling or layout issues.",
5624
+ tags: ["responsive"],
5625
+ priority: "medium",
5274
5626
  projectId
5275
5627
  }
5276
5628
  ];
@@ -6051,6 +6403,15 @@ function getPeriodDays(period) {
6051
6403
  return 30;
6052
6404
  }
6053
6405
  }
6406
+ function loadBudgetConfig() {
6407
+ const config = loadConfig();
6408
+ const budget = config.budget;
6409
+ return {
6410
+ maxPerRunCents: budget?.maxPerRunCents ?? 50,
6411
+ maxPerDayCents: budget?.maxPerDayCents ?? 500,
6412
+ warnAtPercent: budget?.warnAtPercent ?? 0.8
6413
+ };
6414
+ }
6054
6415
  function getCostSummary(options) {
6055
6416
  const db2 = getDatabase();
6056
6417
  const period = options?.period ?? "month";
@@ -6118,6 +6479,77 @@ function getCostSummary(options) {
6118
6479
  estimatedMonthlyCents
6119
6480
  };
6120
6481
  }
6482
+ function getCostsByScenario(options) {
6483
+ const db2 = getDatabase();
6484
+ const period = options?.period ?? "month";
6485
+ const projectId = options?.projectId;
6486
+ const dateFilter = getDateFilter(period);
6487
+ const projectFilter = projectId ? "AND ru.project_id = ?" : "";
6488
+ const projectParams = projectId ? [projectId] : [];
6489
+ const rows = db2.query(`SELECT
6490
+ r.scenario_id,
6491
+ COALESCE(s.name, r.scenario_id) as name,
6492
+ COUNT(DISTINCT r.run_id) as run_count,
6493
+ COALESCE(SUM(r.cost_cents), 0) as total_cost_cents
6494
+ FROM results r
6495
+ JOIN runs ru ON r.run_id = ru.id
6496
+ LEFT JOIN scenarios s ON r.scenario_id = s.id
6497
+ WHERE 1=1 ${dateFilter} ${projectFilter}
6498
+ GROUP BY r.scenario_id
6499
+ ORDER BY total_cost_cents DESC`).all(...projectParams);
6500
+ return rows.map((row) => ({
6501
+ scenarioId: row.scenario_id,
6502
+ name: row.name,
6503
+ runCount: row.run_count,
6504
+ totalCostCents: row.total_cost_cents,
6505
+ avgCostPerRunCents: row.run_count > 0 ? row.total_cost_cents / row.run_count : 0
6506
+ }));
6507
+ }
6508
+ function formatCostsByScenarioTerminal(rows, period) {
6509
+ const lines = [];
6510
+ lines.push("");
6511
+ lines.push(chalk4.bold(` Cost by Scenario (${period})`));
6512
+ lines.push("");
6513
+ if (rows.length === 0) {
6514
+ lines.push(chalk4.dim(" No cost data found."));
6515
+ lines.push("");
6516
+ return lines.join(`
6517
+ `);
6518
+ }
6519
+ lines.push(` ${"Scenario".padEnd(40)} ${"Runs".padEnd(8)} ${"Total Cost".padEnd(14)} Avg/Run`);
6520
+ lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(8)} ${"\u2500".repeat(14)} ${"\u2500".repeat(10)}`);
6521
+ for (const row of rows) {
6522
+ const label = row.name.length > 38 ? row.name.slice(0, 35) + "..." : row.name;
6523
+ lines.push(` ${label.padEnd(40)} ${String(row.runCount).padEnd(8)} ${formatDollars(row.totalCostCents).padEnd(14)} ${formatDollars(row.avgCostPerRunCents)}`);
6524
+ }
6525
+ lines.push("");
6526
+ return lines.join(`
6527
+ `);
6528
+ }
6529
+ function checkBudget(estimatedCostCents) {
6530
+ const budget = loadBudgetConfig();
6531
+ if (estimatedCostCents > budget.maxPerRunCents) {
6532
+ return {
6533
+ allowed: false,
6534
+ warning: `Estimated cost (${formatDollars(estimatedCostCents)}) exceeds per-run limit (${formatDollars(budget.maxPerRunCents)})`
6535
+ };
6536
+ }
6537
+ const todaySummary = getCostSummary({ period: "day" });
6538
+ const projectedDaily = todaySummary.totalCostCents + estimatedCostCents;
6539
+ if (projectedDaily > budget.maxPerDayCents) {
6540
+ return {
6541
+ allowed: false,
6542
+ warning: `Daily spending (${formatDollars(todaySummary.totalCostCents)}) + this run (${formatDollars(estimatedCostCents)}) would exceed daily limit (${formatDollars(budget.maxPerDayCents)})`
6543
+ };
6544
+ }
6545
+ if (projectedDaily > budget.maxPerDayCents * budget.warnAtPercent) {
6546
+ return {
6547
+ allowed: true,
6548
+ warning: `Approaching daily limit: ${formatDollars(projectedDaily)} of ${formatDollars(budget.maxPerDayCents)} (${Math.round(projectedDaily / budget.maxPerDayCents * 100)}%)`
6549
+ };
6550
+ }
6551
+ return { allowed: true };
6552
+ }
6121
6553
  function formatDollars(cents) {
6122
6554
  return `$${(cents / 100).toFixed(2)}`;
6123
6555
  }
@@ -6148,12 +6580,13 @@ function formatCostsTerminal(summary) {
6148
6580
  }
6149
6581
  if (summary.byScenario.length > 0) {
6150
6582
  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)}`);
6583
+ lines.push(chalk4.bold(" Scenarios by Cost (most expensive first)"));
6584
+ lines.push(` ${"Scenario".padEnd(40)} ${"Total Cost".padEnd(12)} ${"Avg/Run".padEnd(12)} ${"Runs".padEnd(6)} Tokens`);
6585
+ lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(12)} ${"\u2500".repeat(12)} ${"\u2500".repeat(6)} ${"\u2500".repeat(10)}`);
6154
6586
  for (const s of summary.byScenario) {
6155
6587
  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}`);
6588
+ const avgPerRun = s.runs > 0 ? s.costCents / s.runs : 0;
6589
+ lines.push(` ${label.padEnd(40)} ${formatDollars(s.costCents).padEnd(12)} ${formatDollars(avgPerRun).padEnd(12)} ${String(s.runs).padEnd(6)} ${formatTokens(s.tokens)}`);
6157
6590
  }
6158
6591
  }
6159
6592
  lines.push("");
@@ -6163,6 +6596,17 @@ function formatCostsTerminal(summary) {
6163
6596
  function formatCostsJSON(summary) {
6164
6597
  return JSON.stringify(summary, null, 2);
6165
6598
  }
6599
+ function formatCostsCsv(summary) {
6600
+ const lines = [];
6601
+ lines.push("scenario,runs,total_cost_cents,avg_cost_cents,tokens");
6602
+ for (const s of summary.byScenario) {
6603
+ const avgCostCents = s.runs > 0 ? s.costCents / s.runs : 0;
6604
+ const name = s.name.includes(",") ? `"${s.name.replace(/"/g, '""')}"` : s.name;
6605
+ lines.push(`${name},${s.runs},${s.costCents},${avgCostCents.toFixed(2)},${s.tokens}`);
6606
+ }
6607
+ return lines.join(`
6608
+ `);
6609
+ }
6166
6610
 
6167
6611
  // src/db/schedules.ts
6168
6612
  init_database();
@@ -6505,6 +6949,241 @@ function parseAssertionString(str) {
6505
6949
 
6506
6950
  // src/cli/index.tsx
6507
6951
  import { existsSync as existsSync8, mkdirSync as mkdirSync4 } from "fs";
6952
+ import { jsxDEV } from "react/jsx-dev-runtime";
6953
+ var PRIORITIES = ["low", "medium", "high", "critical"];
6954
+ function AddForm({ onComplete }) {
6955
+ const { exit } = useApp();
6956
+ const [state, setState] = useState({
6957
+ name: "",
6958
+ url: "",
6959
+ description: "",
6960
+ priority: "medium",
6961
+ tags: "",
6962
+ field: "name",
6963
+ buffer: ""
6964
+ });
6965
+ useInput((input, key) => {
6966
+ if (key.escape) {
6967
+ onComplete(null);
6968
+ exit();
6969
+ return;
6970
+ }
6971
+ if (key.return) {
6972
+ if (state.field === "name") {
6973
+ const val = state.buffer.trim();
6974
+ if (!val)
6975
+ return;
6976
+ setState((s) => ({ ...s, name: val, buffer: "", field: "url" }));
6977
+ } else if (state.field === "url") {
6978
+ setState((s) => ({ ...s, url: s.buffer.trim(), buffer: "", field: "description" }));
6979
+ } else if (state.field === "description") {
6980
+ setState((s) => ({ ...s, description: s.buffer.trim(), buffer: "", field: "priority" }));
6981
+ } else if (state.field === "priority") {
6982
+ setState((s) => ({ ...s, buffer: "", field: "tags" }));
6983
+ } else if (state.field === "tags") {
6984
+ setState((s) => ({ ...s, tags: s.buffer.trim(), buffer: "", field: "confirm" }));
6985
+ } else if (state.field === "confirm") {
6986
+ onComplete(state);
6987
+ exit();
6988
+ }
6989
+ return;
6990
+ }
6991
+ if (key.backspace || key.delete) {
6992
+ if (state.field === "priority")
6993
+ return;
6994
+ setState((s) => ({ ...s, buffer: s.buffer.slice(0, -1) }));
6995
+ return;
6996
+ }
6997
+ if (state.field === "priority") {
6998
+ if (key.leftArrow || key.rightArrow) {
6999
+ const idx = PRIORITIES.indexOf(state.priority);
7000
+ const next = key.rightArrow ? PRIORITIES[(idx + 1) % PRIORITIES.length] : PRIORITIES[(idx - 1 + PRIORITIES.length) % PRIORITIES.length];
7001
+ setState((s) => ({ ...s, priority: next }));
7002
+ }
7003
+ return;
7004
+ }
7005
+ if (!key.ctrl && !key.meta && input) {
7006
+ setState((s) => ({ ...s, buffer: s.buffer + input }));
7007
+ }
7008
+ });
7009
+ const fieldLabel = (label, field, value, hint) => {
7010
+ const active = state.field === field;
7011
+ const displayValue = active ? state.buffer + (active ? "\u2588" : "") : value;
7012
+ return /* @__PURE__ */ jsxDEV(Box, {
7013
+ flexDirection: "row",
7014
+ gap: 1,
7015
+ children: [
7016
+ /* @__PURE__ */ jsxDEV(Text, {
7017
+ color: active ? "cyan" : "gray",
7018
+ children: active ? "\u2192" : " "
7019
+ }, undefined, false, undefined, this),
7020
+ /* @__PURE__ */ jsxDEV(Text, {
7021
+ color: active ? "white" : "gray",
7022
+ bold: active,
7023
+ children: [
7024
+ label,
7025
+ ":"
7026
+ ]
7027
+ }, undefined, true, undefined, this),
7028
+ /* @__PURE__ */ jsxDEV(Text, {
7029
+ color: active ? "white" : "gray",
7030
+ children: displayValue || (hint ? hint : "")
7031
+ }, undefined, false, undefined, this),
7032
+ !displayValue && hint && /* @__PURE__ */ jsxDEV(Text, {
7033
+ color: "gray",
7034
+ children: " "
7035
+ }, undefined, false, undefined, this)
7036
+ ]
7037
+ }, field, true, undefined, this);
7038
+ };
7039
+ return /* @__PURE__ */ jsxDEV(Box, {
7040
+ flexDirection: "column",
7041
+ gap: 0,
7042
+ paddingY: 1,
7043
+ children: [
7044
+ /* @__PURE__ */ jsxDEV(Text, {
7045
+ bold: true,
7046
+ color: "cyan",
7047
+ children: " New Test Scenario"
7048
+ }, undefined, false, undefined, this),
7049
+ /* @__PURE__ */ jsxDEV(Text, {
7050
+ color: "gray",
7051
+ 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"
7052
+ }, undefined, false, undefined, this),
7053
+ fieldLabel(" Name ", "name", state.name),
7054
+ fieldLabel(" URL ", "url", state.url, "(optional)"),
7055
+ fieldLabel(" Description", "description", state.description, "(optional)"),
7056
+ /* @__PURE__ */ jsxDEV(Box, {
7057
+ flexDirection: "row",
7058
+ gap: 1,
7059
+ children: [
7060
+ /* @__PURE__ */ jsxDEV(Text, {
7061
+ color: state.field === "priority" ? "cyan" : "gray",
7062
+ children: state.field === "priority" ? "\u2192" : " "
7063
+ }, undefined, false, undefined, this),
7064
+ /* @__PURE__ */ jsxDEV(Text, {
7065
+ color: state.field === "priority" ? "white" : "gray",
7066
+ bold: state.field === "priority",
7067
+ children: " Priority :"
7068
+ }, undefined, false, undefined, this),
7069
+ PRIORITIES.map((p) => /* @__PURE__ */ jsxDEV(Text, {
7070
+ color: p === state.priority ? "cyan" : "gray",
7071
+ bold: p === state.priority,
7072
+ children: p === state.priority ? `[${p}]` : ` ${p} `
7073
+ }, p, false, undefined, this)),
7074
+ state.field === "priority" && /* @__PURE__ */ jsxDEV(Text, {
7075
+ color: "gray",
7076
+ children: " \u2190 \u2192"
7077
+ }, undefined, false, undefined, this)
7078
+ ]
7079
+ }, undefined, true, undefined, this),
7080
+ fieldLabel(" Tags ", "tags", state.tags, "comma-separated, optional"),
7081
+ state.field === "confirm" && /* @__PURE__ */ jsxDEV(Box, {
7082
+ flexDirection: "column",
7083
+ marginTop: 1,
7084
+ children: [
7085
+ /* @__PURE__ */ jsxDEV(Text, {
7086
+ color: "gray",
7087
+ 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"
7088
+ }, undefined, false, undefined, this),
7089
+ /* @__PURE__ */ jsxDEV(Text, {
7090
+ bold: true,
7091
+ color: "white",
7092
+ children: " Preview:"
7093
+ }, undefined, false, undefined, this),
7094
+ /* @__PURE__ */ jsxDEV(Text, {
7095
+ color: "gray",
7096
+ children: [
7097
+ " name: ",
7098
+ /* @__PURE__ */ jsxDEV(Text, {
7099
+ color: "white",
7100
+ children: state.name
7101
+ }, undefined, false, undefined, this)
7102
+ ]
7103
+ }, undefined, true, undefined, this),
7104
+ state.url && /* @__PURE__ */ jsxDEV(Text, {
7105
+ color: "gray",
7106
+ children: [
7107
+ " url: ",
7108
+ /* @__PURE__ */ jsxDEV(Text, {
7109
+ color: "white",
7110
+ children: state.url
7111
+ }, undefined, false, undefined, this)
7112
+ ]
7113
+ }, undefined, true, undefined, this),
7114
+ state.description && /* @__PURE__ */ jsxDEV(Text, {
7115
+ color: "gray",
7116
+ children: [
7117
+ " description: ",
7118
+ /* @__PURE__ */ jsxDEV(Text, {
7119
+ color: "white",
7120
+ children: state.description
7121
+ }, undefined, false, undefined, this)
7122
+ ]
7123
+ }, undefined, true, undefined, this),
7124
+ /* @__PURE__ */ jsxDEV(Text, {
7125
+ color: "gray",
7126
+ children: [
7127
+ " priority: ",
7128
+ /* @__PURE__ */ jsxDEV(Text, {
7129
+ color: "cyan",
7130
+ children: state.priority
7131
+ }, undefined, false, undefined, this)
7132
+ ]
7133
+ }, undefined, true, undefined, this),
7134
+ state.tags && /* @__PURE__ */ jsxDEV(Text, {
7135
+ color: "gray",
7136
+ children: [
7137
+ " tags: ",
7138
+ /* @__PURE__ */ jsxDEV(Text, {
7139
+ color: "white",
7140
+ children: state.tags
7141
+ }, undefined, false, undefined, this)
7142
+ ]
7143
+ }, undefined, true, undefined, this),
7144
+ /* @__PURE__ */ jsxDEV(Text, {
7145
+ children: " "
7146
+ }, undefined, false, undefined, this),
7147
+ /* @__PURE__ */ jsxDEV(Text, {
7148
+ color: "green",
7149
+ children: " Press Enter to save, Escape to cancel"
7150
+ }, undefined, false, undefined, this)
7151
+ ]
7152
+ }, undefined, true, undefined, this),
7153
+ state.field !== "confirm" && /* @__PURE__ */ jsxDEV(Text, {
7154
+ color: "gray",
7155
+ dimColor: true,
7156
+ children: " Tab/Enter to advance \xB7 Escape to cancel"
7157
+ }, undefined, false, undefined, this)
7158
+ ]
7159
+ }, undefined, true, undefined, this);
7160
+ }
7161
+ async function runInteractiveAdd(projectId) {
7162
+ let savedResult = null;
7163
+ const { waitUntilExit } = render(React.createElement(AddForm, {
7164
+ onComplete: (data) => {
7165
+ savedResult = data;
7166
+ }
7167
+ }));
7168
+ await waitUntilExit();
7169
+ if (savedResult) {
7170
+ const result = savedResult;
7171
+ const tags = result.tags ? result.tags.split(",").map((t) => t.trim()).filter(Boolean) : [];
7172
+ const scenario = createScenario({
7173
+ name: result.name,
7174
+ description: result.description || result.name,
7175
+ steps: [],
7176
+ tags,
7177
+ priority: result.priority,
7178
+ projectId
7179
+ });
7180
+ log(chalk5.green(`
7181
+ Created scenario ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
7182
+ } else {
7183
+ log(chalk5.dim(`
7184
+ Cancelled.`));
7185
+ }
7186
+ }
6508
7187
  function formatToolInput(input) {
6509
7188
  const parts = [];
6510
7189
  for (const [key, value] of Object.entries(input)) {
@@ -6515,7 +7194,19 @@ function formatToolInput(input) {
6515
7194
  return parts.join(" ");
6516
7195
  }
6517
7196
  var program2 = new Command;
6518
- program2.name("testers").version("0.0.4").description("AI-powered browser testing CLI");
7197
+ var QUIET = false;
7198
+ var NO_COLOR = false;
7199
+ function log(...args) {
7200
+ if (QUIET)
7201
+ return;
7202
+ console.log(...args);
7203
+ }
7204
+ function logError(...args) {
7205
+ if (QUIET)
7206
+ return;
7207
+ console.error(...args);
7208
+ }
7209
+ 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
7210
  var CONFIG_DIR2 = join6(process.env["HOME"] ?? "~", ".testers");
6520
7211
  var CONFIG_PATH2 = join6(CONFIG_DIR2, "config.json");
6521
7212
  function getActiveProject() {
@@ -6530,7 +7221,7 @@ function getActiveProject() {
6530
7221
  function resolveProject(optProject) {
6531
7222
  return optProject ?? getActiveProject();
6532
7223
  }
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) => {
7224
+ 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
7225
  acc.push(val);
6535
7226
  return acc;
6536
7227
  }, []).option("-t, --tag <tag>", "Tag (repeatable)", (val, acc) => {
@@ -6539,18 +7230,28 @@ program2.command("add <name>").description("Create a new test scenario").option(
6539
7230
  }, []).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
7231
  acc.push(val);
6541
7232
  return acc;
6542
- }, []).action((name, opts) => {
7233
+ }, []).action(async (name, opts) => {
6543
7234
  try {
7235
+ const hasFlags = opts.description || opts.steps?.length || opts.tag?.length || opts.model || opts.path || opts.auth || opts.timeout || opts.template || opts.assert?.length;
7236
+ if (!name && !hasFlags) {
7237
+ const projectId2 = resolveProject(opts.project);
7238
+ await runInteractiveAdd(projectId2);
7239
+ return;
7240
+ }
7241
+ if (!name) {
7242
+ logError(chalk5.red("Error: scenario name is required"));
7243
+ process.exit(1);
7244
+ }
6544
7245
  if (opts.template) {
6545
7246
  const template = getTemplate(opts.template);
6546
7247
  if (!template) {
6547
- console.error(chalk5.red(`Unknown template: ${opts.template}. Available: ${listTemplateNames().join(", ")}`));
7248
+ logError(chalk5.red(`Unknown template: ${opts.template}. Available: ${listTemplateNames().join(", ")}`));
6548
7249
  process.exit(1);
6549
7250
  }
6550
7251
  const projectId2 = resolveProject(opts.project);
6551
7252
  for (const input of template) {
6552
7253
  const s = createScenario({ ...input, projectId: projectId2 });
6553
- console.log(chalk5.green(` Created ${s.shortId}: ${s.name}`));
7254
+ log(chalk5.green(` Created ${s.shortId}: ${s.name}`));
6554
7255
  }
6555
7256
  return;
6556
7257
  }
@@ -6569,117 +7270,169 @@ program2.command("add <name>").description("Create a new test scenario").option(
6569
7270
  assertions: assertions.length > 0 ? assertions : undefined,
6570
7271
  projectId
6571
7272
  });
6572
- console.log(chalk5.green(`Created scenario ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
7273
+ log(chalk5.green(`Created scenario ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
6573
7274
  } catch (error) {
6574
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7275
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6575
7276
  process.exit(1);
6576
7277
  }
6577
7278
  });
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) => {
7279
+ 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("--search <text>", "Filter by name or description (case-insensitive substring match)").option("--sort <field>", "Sort field: date, priority, name (default: date)").option("--asc", "Sort ascending instead of descending", false).option("-l, --limit <n>", "Limit results", "50").option("--offset <n>", "Skip first N results", "0").option("--json", "Output as JSON", false).action((opts) => {
6579
7280
  try {
6580
7281
  const scenarios = listScenarios({
6581
7282
  tags: opts.tag ? [opts.tag] : undefined,
6582
7283
  priority: opts.priority,
6583
7284
  projectId: opts.project,
6584
- limit: parseInt(opts.limit, 10)
7285
+ search: opts.search,
7286
+ sort: opts.sort,
7287
+ desc: !opts.asc,
7288
+ limit: parseInt(opts.limit, 10),
7289
+ offset: parseInt(opts.offset, 10) || undefined
6585
7290
  });
6586
- console.log(formatScenarioList(scenarios));
7291
+ if (opts.json) {
7292
+ log(JSON.stringify(scenarios, null, 2));
7293
+ } else {
7294
+ log(formatScenarioList(scenarios));
7295
+ }
6587
7296
  } catch (error) {
6588
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7297
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6589
7298
  process.exit(1);
6590
7299
  }
6591
7300
  });
6592
- program2.command("show <id>").description("Show scenario details").action((id) => {
7301
+ program2.command("show <id>").description("Show scenario details").option("--json", "Output as JSON", false).action((id, opts) => {
6593
7302
  try {
6594
7303
  const scenario = getScenario(id) ?? getScenarioByShortId(id);
6595
7304
  if (!scenario) {
6596
- console.error(chalk5.red(`Scenario not found: ${id}`));
7305
+ logError(chalk5.red(`Scenario not found: ${id}`));
6597
7306
  process.exit(1);
6598
7307
  }
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}`);
7308
+ if (opts.json) {
7309
+ log(JSON.stringify(scenario, null, 2));
7310
+ return;
7311
+ }
7312
+ log("");
7313
+ log(chalk5.bold(` Scenario ${scenario.shortId}`));
7314
+ log(` Name: ${scenario.name}`);
7315
+ log(` ID: ${chalk5.dim(scenario.id)}`);
7316
+ log(` Description: ${scenario.description}`);
7317
+ log(` Priority: ${scenario.priority}`);
7318
+ log(` Model: ${scenario.model ?? chalk5.dim("default")}`);
7319
+ log(` Tags: ${scenario.tags.length > 0 ? scenario.tags.join(", ") : chalk5.dim("none")}`);
7320
+ log(` Path: ${scenario.targetPath ?? chalk5.dim("none")}`);
7321
+ log(` Auth: ${scenario.requiresAuth ? "yes" : "no"}`);
7322
+ log(` Timeout: ${scenario.timeoutMs ? `${scenario.timeoutMs}ms` : chalk5.dim("default")}`);
7323
+ log(` Version: ${scenario.version}`);
7324
+ log(` Created: ${scenario.createdAt}`);
7325
+ log(` Updated: ${scenario.updatedAt}`);
6613
7326
  if (scenario.steps.length > 0) {
6614
- console.log("");
6615
- console.log(chalk5.bold(" Steps:"));
7327
+ log("");
7328
+ log(chalk5.bold(" Steps:"));
6616
7329
  for (let i = 0;i < scenario.steps.length; i++) {
6617
- console.log(` ${i + 1}. ${scenario.steps[i]}`);
7330
+ log(` ${i + 1}. ${scenario.steps[i]}`);
6618
7331
  }
6619
7332
  }
6620
- console.log("");
7333
+ log("");
6621
7334
  } catch (error) {
6622
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7335
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6623
7336
  process.exit(1);
6624
7337
  }
6625
7338
  });
6626
7339
  program2.command("update <id>").description("Update a scenario").option("-n, --name <name>", "New name").option("-d, --description <text>", "New description").option("-s, --steps <step>", "Replace steps (repeatable)", (val, acc) => {
6627
7340
  acc.push(val);
6628
7341
  return acc;
6629
- }, []).option("-t, --tag <tag>", "Replace tags (repeatable)", (val, acc) => {
7342
+ }, []).option("-t, --tag <tag>", "Replace all tags (repeatable)", (val, acc) => {
7343
+ acc.push(val);
7344
+ return acc;
7345
+ }, []).option("--tag-add <tag>", "Add a tag to existing tags (repeatable)", (val, acc) => {
7346
+ acc.push(val);
7347
+ return acc;
7348
+ }, []).option("--tag-remove <tag>", "Remove a tag from existing tags (repeatable)", (val, acc) => {
6630
7349
  acc.push(val);
6631
7350
  return acc;
6632
7351
  }, []).option("-p, --priority <level>", "New priority").option("-m, --model <model>", "New model").action((id, opts) => {
6633
7352
  try {
6634
7353
  const scenario = getScenario(id) ?? getScenarioByShortId(id);
6635
7354
  if (!scenario) {
6636
- console.error(chalk5.red(`Scenario not found: ${id}`));
7355
+ logError(chalk5.red(`Scenario not found: ${id}`));
6637
7356
  process.exit(1);
6638
7357
  }
7358
+ let newTags;
7359
+ if (opts.tag.length > 0) {
7360
+ newTags = opts.tag;
7361
+ } else if (opts.tagAdd.length > 0 || opts.tagRemove.length > 0) {
7362
+ const existing = new Set(scenario.tags);
7363
+ for (const t of opts.tagAdd)
7364
+ existing.add(t);
7365
+ for (const t of opts.tagRemove)
7366
+ existing.delete(t);
7367
+ newTags = [...existing];
7368
+ }
6639
7369
  const updated = updateScenario(scenario.id, {
6640
7370
  name: opts.name,
6641
7371
  description: opts.description,
6642
7372
  steps: opts.steps.length > 0 ? opts.steps : undefined,
6643
- tags: opts.tag.length > 0 ? opts.tag : undefined,
7373
+ tags: newTags,
6644
7374
  priority: opts.priority,
6645
7375
  model: opts.model
6646
7376
  }, scenario.version);
6647
- console.log(chalk5.green(`Updated scenario ${chalk5.bold(updated.shortId)}: ${updated.name}`));
7377
+ log(chalk5.green(`Updated scenario ${chalk5.bold(updated.shortId)}: ${updated.name}`));
7378
+ if (newTags !== undefined) {
7379
+ log(chalk5.dim(` Tags: [${updated.tags.join(", ")}]`));
7380
+ }
6648
7381
  } catch (error) {
6649
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7382
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6650
7383
  process.exit(1);
6651
7384
  }
6652
7385
  });
6653
- program2.command("delete <id>").description("Delete a scenario").action((id) => {
7386
+ program2.command("delete <id>").description("Delete a scenario").option("-y, --yes", "Skip confirmation prompt", false).action(async (id, opts) => {
6654
7387
  try {
6655
7388
  const scenario = getScenario(id) ?? getScenarioByShortId(id);
6656
7389
  if (!scenario) {
6657
- console.error(chalk5.red(`Scenario not found: ${id}`));
7390
+ logError(chalk5.red(`Scenario not found: ${id}`));
6658
7391
  process.exit(1);
6659
7392
  }
7393
+ if (!opts.yes) {
7394
+ process.stdout.write(chalk5.yellow(`Delete scenario ${scenario.shortId} "${scenario.name}"? [y/N] `));
7395
+ const answer = await new Promise((resolve2) => {
7396
+ let buf = "";
7397
+ process.stdin.setRawMode?.(true);
7398
+ process.stdin.resume();
7399
+ process.stdin.once("data", (chunk) => {
7400
+ buf = chunk.toString().trim().toLowerCase();
7401
+ process.stdin.setRawMode?.(false);
7402
+ process.stdin.pause();
7403
+ process.stdout.write(`
7404
+ `);
7405
+ resolve2(buf);
7406
+ });
7407
+ });
7408
+ if (answer !== "y" && answer !== "yes") {
7409
+ log(chalk5.dim("Cancelled."));
7410
+ return;
7411
+ }
7412
+ }
6660
7413
  const deleted = deleteScenario(scenario.id);
6661
7414
  if (deleted) {
6662
- console.log(chalk5.green(`Deleted scenario ${scenario.shortId}: ${scenario.name}`));
7415
+ log(chalk5.green(`Deleted scenario ${scenario.shortId}: ${scenario.name}`));
6663
7416
  } else {
6664
- console.error(chalk5.red(`Failed to delete scenario: ${id}`));
7417
+ logError(chalk5.red(`Failed to delete scenario: ${id}`));
6665
7418
  process.exit(1);
6666
7419
  }
6667
7420
  } catch (error) {
6668
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7421
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6669
7422
  process.exit(1);
6670
7423
  }
6671
7424
  });
6672
- program2.command("run [url] [description]").description("Run test scenarios against a URL").option("-t, --tag <tag>", "Filter by tag (repeatable)", (val, acc) => {
7425
+ 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
7426
  acc.push(val);
6674
7427
  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) => {
7428
+ }, []).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").option("--verbose", "Show per-step timing and full tool results", false).option("--watch-results", "When used with --background, poll and display live results table until run completes", false).option("--failed-only", "Only show failed/error scenarios in output (passed count shown as summary)", false).action(async (urlArg, description, opts) => {
6676
7429
  try {
6677
7430
  const projectId = resolveProject(opts.project);
6678
7431
  let url = urlArg;
6679
7432
  if (!url && opts.env) {
6680
7433
  const env = getEnvironment(opts.env);
6681
7434
  if (!env) {
6682
- console.error(chalk5.red(`Environment not found: ${opts.env}`));
7435
+ logError(chalk5.red(`Environment not found: ${opts.env}`));
6683
7436
  process.exit(1);
6684
7437
  }
6685
7438
  url = env.url;
@@ -6688,16 +7441,76 @@ program2.command("run [url] [description]").description("Run test scenarios agai
6688
7441
  const defaultEnv = getDefaultEnvironment();
6689
7442
  if (defaultEnv) {
6690
7443
  url = defaultEnv.url;
6691
- console.log(chalk5.dim(`Using default environment: ${defaultEnv.name} (${defaultEnv.url})`));
7444
+ log(chalk5.dim(`Using default environment: ${defaultEnv.name} (${defaultEnv.url})`));
6692
7445
  }
6693
7446
  }
6694
7447
  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>'."));
7448
+ logError(chalk5.red("No URL provided. Pass a URL argument, use --env <name>, or set a default environment with 'testers env use <name>'."));
6696
7449
  process.exit(1);
6697
7450
  }
7451
+ if (!opts.dryRun && !opts.background) {
7452
+ const budgetResult = checkBudget(0);
7453
+ if (budgetResult.warning) {
7454
+ log(chalk5.yellow(` \u26A0\uFE0F Budget warning: ${budgetResult.warning}`));
7455
+ if (!budgetResult.allowed) {
7456
+ if (!opts.yes) {
7457
+ log(chalk5.yellow(" Use --yes to run anyway, or check your budget config."));
7458
+ process.exit(1);
7459
+ }
7460
+ log(chalk5.yellow(" --yes passed, proceeding despite budget limit."));
7461
+ }
7462
+ }
7463
+ }
6698
7464
  if (opts.fromTodos) {
6699
7465
  const result = importFromTodos({ projectId });
6700
- console.log(chalk5.blue(`Imported ${result.imported} scenarios from todos (${result.skipped} skipped)`));
7466
+ log(chalk5.blue(`Imported ${result.imported} scenarios from todos (${result.skipped} skipped)`));
7467
+ }
7468
+ if (opts.dryRun) {
7469
+ const dryScenarios = listScenarios({
7470
+ tags: opts.tag.length > 0 ? opts.tag : undefined,
7471
+ projectId
7472
+ }).filter((s) => {
7473
+ if (opts.scenario && s.id !== opts.scenario && s.shortId !== opts.scenario)
7474
+ return false;
7475
+ if (opts.priority && s.priority !== opts.priority)
7476
+ return false;
7477
+ return true;
7478
+ });
7479
+ log("");
7480
+ log(chalk5.bold(" Dry Run \u2014 scenarios that would execute:"));
7481
+ log("");
7482
+ if (dryScenarios.length === 0) {
7483
+ log(chalk5.yellow(" No matching scenarios found."));
7484
+ } else {
7485
+ for (const s of dryScenarios) {
7486
+ const assertionErrors = [];
7487
+ for (const a of s.assertions ?? []) {
7488
+ try {
7489
+ parseAssertionString(a);
7490
+ } catch {
7491
+ assertionErrors.push(a);
7492
+ }
7493
+ }
7494
+ let authOk = true;
7495
+ if (s.authPreset) {
7496
+ const presets = listAuthPresets();
7497
+ authOk = presets.some((p) => p.name === s.authPreset);
7498
+ }
7499
+ const statusIcon = assertionErrors.length === 0 && authOk ? chalk5.green("\u2713") : chalk5.red("\u2717");
7500
+ log(` ${statusIcon} ${chalk5.cyan(s.shortId)} ${s.name} ${chalk5.dim(`[${s.tags.join(", ")}]`)}`);
7501
+ if (assertionErrors.length > 0) {
7502
+ log(chalk5.red(` Invalid assertions: ${assertionErrors.join(", ")}`));
7503
+ }
7504
+ if (!authOk) {
7505
+ log(chalk5.red(` Auth preset not found: ${s.authPreset}`));
7506
+ }
7507
+ }
7508
+ }
7509
+ log("");
7510
+ log(chalk5.dim(` URL: ${url}`));
7511
+ log(chalk5.dim(` Total: ${dryScenarios.length} scenarios`));
7512
+ log("");
7513
+ process.exit(0);
6701
7514
  }
6702
7515
  if (opts.background) {
6703
7516
  if (description) {
@@ -6715,52 +7528,106 @@ program2.command("run [url] [description]").description("Run test scenarios agai
6715
7528
  projectId,
6716
7529
  engine: opts.browser
6717
7530
  });
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)}`));
7531
+ log(chalk5.green(`Run started in background: ${chalk5.bold(runId.slice(0, 8))}`));
7532
+ log(chalk5.dim(` Scenarios: ${scenarioCount}`));
7533
+ log(chalk5.dim(` URL: ${url}`));
7534
+ if (opts.watchResults) {
7535
+ log(chalk5.dim(` Watching results (polling every 3s)...`));
7536
+ log("");
7537
+ const POLL_INTERVAL = 3000;
7538
+ const DONE_STATUSES = new Set(["passed", "failed", "cancelled"]);
7539
+ const renderTable = () => {
7540
+ const run2 = getRun(runId);
7541
+ if (!run2)
7542
+ return;
7543
+ const results2 = getResultsByRun(runId);
7544
+ const statusIcon = run2.status === "passed" ? chalk5.green("PASS") : run2.status === "failed" ? chalk5.red("FAIL") : chalk5.blue("RUN ");
7545
+ process.stdout.write(`\r ${statusIcon} ${run2.passed} passed ${run2.failed} failed ${run2.total - run2.passed - run2.failed} running (${results2.length}/${run2.total})
7546
+ `);
7547
+ for (const r of results2) {
7548
+ const scenario = getScenario(r.scenarioId);
7549
+ const name = scenario ? scenario.name : r.scenarioId.slice(0, 8);
7550
+ const icon = r.status === "passed" ? chalk5.green("\u2713") : r.status === "failed" ? chalk5.red("\u2717") : r.status === "error" ? chalk5.yellow("!") : chalk5.blue("\u2026");
7551
+ const dur = r.durationMs > 0 ? chalk5.dim(` ${(r.durationMs / 1000).toFixed(1)}s`) : "";
7552
+ process.stdout.write(` ${icon} ${name}${dur}
7553
+ `);
7554
+ }
7555
+ };
7556
+ await new Promise((resolve2) => {
7557
+ const poll = setInterval(() => {
7558
+ const run2 = getRun(runId);
7559
+ if (!run2)
7560
+ return;
7561
+ renderTable();
7562
+ if (DONE_STATUSES.has(run2.status)) {
7563
+ clearInterval(poll);
7564
+ resolve2();
7565
+ }
7566
+ }, POLL_INTERVAL);
7567
+ });
7568
+ const finalRun = getRun(runId);
7569
+ if (finalRun) {
7570
+ log("");
7571
+ const results2 = getResultsByRun(runId);
7572
+ log(formatTerminal(finalRun, results2));
7573
+ }
7574
+ process.exit(finalRun ? getExitCode(finalRun) : 0);
7575
+ }
7576
+ log(chalk5.dim(` Check progress: testers results ${runId.slice(0, 8)}`));
6722
7577
  process.exit(0);
6723
7578
  }
6724
7579
  if (!opts.json && !opts.output) {
7580
+ const verbose = !!opts.verbose;
6725
7581
  onRunEvent((event) => {
6726
7582
  switch (event.type) {
6727
7583
  case "scenario:start":
6728
- console.log(chalk5.blue(` [start] ${event.scenarioName ?? event.scenarioId}`));
7584
+ if (event.retryAttempt) {
7585
+ log(chalk5.yellow(` [retry] Retrying scenario ${event.scenarioName ?? event.scenarioId} (attempt ${event.retryAttempt}/${event.maxRetries})...`));
7586
+ } else {
7587
+ log(chalk5.blue(` [start] ${event.scenarioName ?? event.scenarioId}`));
7588
+ }
7589
+ break;
7590
+ case "scenario:timeout_warning": {
7591
+ const elapsedS = ((event.elapsedMs ?? 0) / 1000).toFixed(0);
7592
+ const totalS = ((event.timeoutMs ?? 0) / 1000).toFixed(0);
7593
+ log(chalk5.yellow(` \u26A0\uFE0F Scenario '${event.scenarioName}' at 80% timeout (${elapsedS}s/${totalS}s) \u2014 still running`));
6729
7594
  break;
7595
+ }
6730
7596
  case "step:thinking":
6731
7597
  if (event.thinking) {
6732
7598
  const preview = event.thinking.length > 120 ? event.thinking.slice(0, 120) + "..." : event.thinking;
6733
- console.log(chalk5.dim(` [think] ${preview}`));
7599
+ log(chalk5.dim(` [think] ${preview}`));
6734
7600
  }
6735
7601
  break;
6736
7602
  case "step:tool_call":
6737
- console.log(chalk5.cyan(` [step ${event.stepNumber}] ${event.toolName}${event.toolInput ? ` ${formatToolInput(event.toolInput)}` : ""}`));
7603
+ log(chalk5.cyan(` [step ${event.stepNumber}] ${event.toolName}${event.toolInput ? ` ${formatToolInput(event.toolInput)}` : ""}`));
6738
7604
  break;
6739
7605
  case "step:tool_result":
6740
7606
  if (event.toolName === "report_result") {
6741
- console.log(chalk5.bold(` [result] ${event.toolResult}`));
7607
+ log(chalk5.bold(` [result] ${event.toolResult}`));
6742
7608
  } else {
7609
+ const durationStr = verbose && event.stepDurationMs !== undefined ? chalk5.dim(`[${(event.stepDurationMs / 1000).toFixed(1)}s] `) : "";
6743
7610
  const resultPreview = (event.toolResult ?? "").length > 100 ? (event.toolResult ?? "").slice(0, 100) + "..." : event.toolResult ?? "";
6744
- console.log(chalk5.dim(` [done] ${resultPreview}`));
7611
+ log(chalk5.dim(` [done] ${durationStr}${resultPreview}`));
6745
7612
  }
6746
7613
  break;
6747
7614
  case "screenshot:captured":
6748
- console.log(chalk5.dim(` [screenshot] ${event.screenshotPath}`));
7615
+ log(chalk5.dim(` [screenshot] ${event.screenshotPath}`));
6749
7616
  break;
6750
7617
  case "scenario:pass":
6751
- console.log(chalk5.green(` [PASS] ${event.scenarioName}`));
7618
+ log(chalk5.green(` [PASS] ${event.scenarioName}`));
6752
7619
  break;
6753
7620
  case "scenario:fail":
6754
- console.log(chalk5.red(` [FAIL] ${event.scenarioName}`));
7621
+ log(chalk5.red(` [FAIL] ${event.scenarioName}`));
6755
7622
  break;
6756
7623
  case "scenario:error":
6757
- console.log(chalk5.yellow(` [ERR] ${event.scenarioName}: ${event.error}`));
7624
+ log(chalk5.yellow(` [ERR] ${event.scenarioName}: ${event.error}`));
6758
7625
  break;
6759
7626
  }
6760
7627
  });
6761
- console.log("");
6762
- console.log(chalk5.bold(` Running tests against ${url}`));
6763
- console.log("");
7628
+ log("");
7629
+ log(chalk5.bold(` Running tests against ${url}`));
7630
+ log("");
6764
7631
  }
6765
7632
  if (description) {
6766
7633
  const scenario = createScenario({
@@ -6776,6 +7643,7 @@ program2.command("run [url] [description]").description("Run test scenarios agai
6776
7643
  headed: opts.headed,
6777
7644
  parallel: parseInt(opts.parallel, 10),
6778
7645
  timeout: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
7646
+ retry: parseInt(opts.retry ?? "0", 10),
6779
7647
  projectId,
6780
7648
  engine: opts.browser
6781
7649
  });
@@ -6783,16 +7651,22 @@ program2.command("run [url] [description]").description("Run test scenarios agai
6783
7651
  const jsonOutput = formatJSON(run2, results2);
6784
7652
  if (opts.output) {
6785
7653
  writeFileSync3(opts.output, jsonOutput, "utf-8");
6786
- console.log(chalk5.green(`Results written to ${opts.output}`));
7654
+ log(chalk5.green(`Results written to ${opts.output}`));
6787
7655
  }
6788
7656
  if (opts.json) {
6789
- console.log(jsonOutput);
7657
+ log(jsonOutput);
6790
7658
  }
6791
7659
  } else {
6792
- console.log(formatTerminal(run2, results2));
7660
+ log(formatTerminal(run2, results2, { failedOnly: opts.failedOnly }));
6793
7661
  }
6794
7662
  process.exit(getExitCode(run2));
6795
7663
  }
7664
+ const noFilters = !opts.scenario && opts.tag.length === 0 && !opts.priority;
7665
+ if (noFilters && !opts.json && !opts.output) {
7666
+ const allScenarios = listScenarios({ projectId });
7667
+ log(chalk5.bold(` Running all ${allScenarios.length} scenarios...`));
7668
+ log("");
7669
+ }
6796
7670
  const { run, results } = await runByFilter({
6797
7671
  url,
6798
7672
  tags: opts.tag.length > 0 ? opts.tag : undefined,
@@ -6802,6 +7676,7 @@ program2.command("run [url] [description]").description("Run test scenarios agai
6802
7676
  headed: opts.headed,
6803
7677
  parallel: parseInt(opts.parallel, 10),
6804
7678
  timeout: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
7679
+ retry: parseInt(opts.retry ?? "0", 10),
6805
7680
  projectId,
6806
7681
  engine: opts.browser
6807
7682
  });
@@ -6809,43 +7684,54 @@ program2.command("run [url] [description]").description("Run test scenarios agai
6809
7684
  const jsonOutput = formatJSON(run, results);
6810
7685
  if (opts.output) {
6811
7686
  writeFileSync3(opts.output, jsonOutput, "utf-8");
6812
- console.log(chalk5.green(`Results written to ${opts.output}`));
7687
+ log(chalk5.green(`Results written to ${opts.output}`));
6813
7688
  }
6814
7689
  if (opts.json) {
6815
- console.log(jsonOutput);
7690
+ log(jsonOutput);
6816
7691
  }
6817
7692
  } else {
6818
- console.log(formatTerminal(run, results));
7693
+ log(formatTerminal(run, results, { failedOnly: opts.failedOnly }));
6819
7694
  }
6820
7695
  process.exit(getExitCode(run));
6821
7696
  } catch (error) {
6822
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7697
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6823
7698
  process.exit(1);
6824
7699
  }
6825
7700
  });
6826
- program2.command("runs").description("List past test runs").option("--status <status>", "Filter by status").option("-l, --limit <n>", "Limit results", "20").action((opts) => {
7701
+ program2.command("runs").description("List past test runs").option("--status <status>", "Filter by status").option("--sort <field>", "Sort field: date, duration, cost (default: date)").option("--asc", "Sort ascending instead of descending", false).option("-l, --limit <n>", "Limit results", "20").option("--offset <n>", "Skip first N results", "0").option("--json", "Output as JSON", false).action((opts) => {
6827
7702
  try {
6828
7703
  const runs = listRuns({
6829
7704
  status: opts.status,
6830
- limit: parseInt(opts.limit, 10)
7705
+ sort: opts.sort,
7706
+ desc: !opts.asc,
7707
+ limit: parseInt(opts.limit, 10),
7708
+ offset: parseInt(opts.offset, 10) || undefined
6831
7709
  });
6832
- console.log(formatRunList(runs));
7710
+ if (opts.json) {
7711
+ log(JSON.stringify(runs, null, 2));
7712
+ } else {
7713
+ log(formatRunList(runs));
7714
+ }
6833
7715
  } catch (error) {
6834
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7716
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6835
7717
  process.exit(1);
6836
7718
  }
6837
7719
  });
6838
- program2.command("results <run-id>").description("Show results for a test run").action((runId) => {
7720
+ program2.command("results <run-id>").description("Show results for a test run").option("--json", "Output as JSON", false).action((runId, opts) => {
6839
7721
  try {
6840
7722
  const run = getRun(runId);
6841
7723
  if (!run) {
6842
- console.error(chalk5.red(`Run not found: ${runId}`));
7724
+ logError(chalk5.red(`Run not found: ${runId}`));
6843
7725
  process.exit(1);
6844
7726
  }
6845
7727
  const results = getResultsByRun(run.id);
6846
- console.log(formatTerminal(run, results));
7728
+ if (opts.json) {
7729
+ log(formatJSON(run, results));
7730
+ } else {
7731
+ log(formatTerminal(run, results));
7732
+ }
6847
7733
  } catch (error) {
6848
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7734
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6849
7735
  process.exit(1);
6850
7736
  }
6851
7737
  });
@@ -6855,43 +7741,43 @@ program2.command("screenshots <id>").description("List screenshots for a run or
6855
7741
  if (run) {
6856
7742
  const results = getResultsByRun(run.id);
6857
7743
  let total = 0;
6858
- console.log("");
6859
- console.log(chalk5.bold(` Screenshots for run ${run.id.slice(0, 8)}`));
6860
- console.log("");
7744
+ log("");
7745
+ log(chalk5.bold(` Screenshots for run ${run.id.slice(0, 8)}`));
7746
+ log("");
6861
7747
  for (const result of results) {
6862
7748
  const screenshots2 = listScreenshots(result.id);
6863
7749
  if (screenshots2.length > 0) {
6864
7750
  const scenario = getScenario(result.scenarioId);
6865
7751
  const label = scenario ? `${scenario.shortId}: ${scenario.name}` : result.scenarioId.slice(0, 8);
6866
- console.log(chalk5.bold(` ${label}`));
7752
+ log(chalk5.bold(` ${label}`));
6867
7753
  for (const ss of screenshots2) {
6868
- console.log(` ${chalk5.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk5.dim(ss.filePath)}`);
7754
+ log(` ${chalk5.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk5.dim(ss.filePath)}`);
6869
7755
  total++;
6870
7756
  }
6871
- console.log("");
7757
+ log("");
6872
7758
  }
6873
7759
  }
6874
7760
  if (total === 0) {
6875
- console.log(chalk5.dim(" No screenshots found."));
6876
- console.log("");
7761
+ log(chalk5.dim(" No screenshots found."));
7762
+ log("");
6877
7763
  }
6878
7764
  return;
6879
7765
  }
6880
7766
  const screenshots = listScreenshots(id);
6881
7767
  if (screenshots.length > 0) {
6882
- console.log("");
6883
- console.log(chalk5.bold(` Screenshots for result ${id.slice(0, 8)}`));
6884
- console.log("");
7768
+ log("");
7769
+ log(chalk5.bold(` Screenshots for result ${id.slice(0, 8)}`));
7770
+ log("");
6885
7771
  for (const ss of screenshots) {
6886
- console.log(` ${chalk5.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk5.dim(ss.filePath)}`);
7772
+ log(` ${chalk5.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk5.dim(ss.filePath)}`);
6887
7773
  }
6888
- console.log("");
7774
+ log("");
6889
7775
  return;
6890
7776
  }
6891
- console.error(chalk5.red(`No screenshots found for: ${id}`));
7777
+ logError(chalk5.red(`No screenshots found for: ${id}`));
6892
7778
  process.exit(1);
6893
7779
  } catch (error) {
6894
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7780
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6895
7781
  process.exit(1);
6896
7782
  }
6897
7783
  });
@@ -6900,7 +7786,7 @@ program2.command("import <dir>").description("Import markdown test files as scen
6900
7786
  const absDir = resolve(dir);
6901
7787
  const files = readdirSync(absDir).filter((f) => f.endsWith(".md"));
6902
7788
  if (files.length === 0) {
6903
- console.log(chalk5.dim("No .md files found in directory."));
7789
+ log(chalk5.dim("No .md files found in directory."));
6904
7790
  return;
6905
7791
  }
6906
7792
  let imported = 0;
@@ -6930,22 +7816,87 @@ program2.command("import <dir>").description("Import markdown test files as scen
6930
7816
  description: descriptionLines.join(" ") || name,
6931
7817
  steps
6932
7818
  });
6933
- console.log(chalk5.green(` Imported ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
7819
+ log(chalk5.green(` Imported ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
6934
7820
  imported++;
6935
7821
  }
6936
- console.log("");
6937
- console.log(chalk5.green(`Imported ${imported} scenario(s) from ${absDir}`));
7822
+ log("");
7823
+ log(chalk5.green(`Imported ${imported} scenario(s) from ${absDir}`));
6938
7824
  } catch (error) {
6939
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7825
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7826
+ process.exit(1);
7827
+ }
7828
+ });
7829
+ program2.command("export [format]").description("Export scenarios as JSON (default) or markdown files").option("-o, --output <path>", "Output file (JSON) or directory (markdown)").option("-t, --tag <tag>", "Filter by tag").option("-p, --priority <level>", "Filter by priority").option("--project <id>", "Filter by project ID").action((format, opts) => {
7830
+ try {
7831
+ const fmt = (format ?? "json").toLowerCase();
7832
+ if (fmt !== "json" && fmt !== "markdown") {
7833
+ logError(chalk5.red(`Unknown format: ${fmt}. Supported: json, markdown`));
7834
+ process.exit(1);
7835
+ }
7836
+ const projectId = resolveProject(opts.project);
7837
+ const scenarios = listScenarios({
7838
+ tags: opts.tag ? [opts.tag] : undefined,
7839
+ priority: opts.priority,
7840
+ projectId
7841
+ });
7842
+ if (scenarios.length === 0) {
7843
+ log(chalk5.dim("No scenarios found to export."));
7844
+ return;
7845
+ }
7846
+ if (fmt === "json") {
7847
+ const outputPath = opts.output ?? "testers-export.json";
7848
+ const data = JSON.stringify(scenarios, null, 2);
7849
+ writeFileSync3(outputPath, data, "utf-8");
7850
+ log(chalk5.green(`Exported ${scenarios.length} scenario(s) to ${resolve(outputPath)}`));
7851
+ return;
7852
+ }
7853
+ const outputDir = opts.output ?? ".";
7854
+ if (!existsSync8(outputDir)) {
7855
+ mkdirSync4(outputDir, { recursive: true });
7856
+ }
7857
+ for (const s of scenarios) {
7858
+ const lines = [];
7859
+ lines.push(`# ${s.name}`);
7860
+ lines.push("");
7861
+ if (s.description && s.description !== s.name) {
7862
+ lines.push(s.description);
7863
+ lines.push("");
7864
+ }
7865
+ if (s.tags.length > 0) {
7866
+ lines.push(`**Tags:** ${s.tags.join(", ")}`);
7867
+ }
7868
+ lines.push(`**Priority:** ${s.priority}`);
7869
+ if (s.targetPath) {
7870
+ lines.push(`**Path:** ${s.targetPath}`);
7871
+ }
7872
+ lines.push("");
7873
+ if (s.steps.length > 0) {
7874
+ lines.push("## Steps");
7875
+ lines.push("");
7876
+ for (const step of s.steps) {
7877
+ lines.push(`- [ ] ${step}`);
7878
+ }
7879
+ lines.push("");
7880
+ }
7881
+ const safeFilename = s.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 80);
7882
+ const filePath = join6(outputDir, `${s.shortId}-${safeFilename}.md`);
7883
+ writeFileSync3(filePath, lines.join(`
7884
+ `), "utf-8");
7885
+ log(chalk5.dim(` ${s.shortId}: ${s.name} \u2192 ${filePath}`));
7886
+ }
7887
+ log(chalk5.green(`
7888
+ Exported ${scenarios.length} scenario(s) as markdown to ${resolve(outputDir)}`));
7889
+ } catch (error) {
7890
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6940
7891
  process.exit(1);
6941
7892
  }
6942
7893
  });
6943
7894
  program2.command("config").description("Show current configuration").action(() => {
6944
7895
  try {
6945
7896
  const config = loadConfig();
6946
- console.log(JSON.stringify(config, null, 2));
7897
+ log(JSON.stringify(config, null, 2));
6947
7898
  } catch (error) {
6948
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7899
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6949
7900
  process.exit(1);
6950
7901
  }
6951
7902
  });
@@ -6954,33 +7905,33 @@ program2.command("status").description("Show database and auth status").action((
6954
7905
  const config = loadConfig();
6955
7906
  const hasApiKey = !!config.anthropicApiKey || !!process.env["ANTHROPIC_API_KEY"];
6956
7907
  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("");
7908
+ log("");
7909
+ log(chalk5.bold(" Open Testers Status"));
7910
+ log("");
7911
+ log(` ANTHROPIC_API_KEY: ${hasApiKey ? chalk5.green("set") : chalk5.red("not set")}`);
7912
+ log(` Database: ${dbPath}`);
7913
+ log(` Default model: ${config.defaultModel}`);
7914
+ log(` Screenshots dir: ${config.screenshots.dir}`);
7915
+ log("");
6965
7916
  } catch (error) {
6966
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7917
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6967
7918
  process.exit(1);
6968
7919
  }
6969
7920
  });
6970
7921
  program2.command("install-browser").description("Install browser engine").option("--engine <engine>", "Engine to install: playwright, lightpanda, or all", "playwright").action(async (opts) => {
6971
7922
  try {
6972
7923
  if (opts.engine === "all" || opts.engine === "playwright") {
6973
- console.log(chalk5.blue("Installing Playwright Chromium..."));
7924
+ log(chalk5.blue("Installing Playwright Chromium..."));
6974
7925
  await installBrowser("playwright");
6975
- console.log(chalk5.green("Playwright Chromium installed."));
7926
+ log(chalk5.green("Playwright Chromium installed."));
6976
7927
  }
6977
7928
  if (opts.engine === "all" || opts.engine === "lightpanda") {
6978
- console.log(chalk5.blue("Installing Lightpanda..."));
7929
+ log(chalk5.blue("Installing Lightpanda..."));
6979
7930
  await installBrowser("lightpanda");
6980
- console.log(chalk5.green("Lightpanda installed."));
7931
+ log(chalk5.green("Lightpanda installed."));
6981
7932
  }
6982
7933
  } catch (error) {
6983
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7934
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6984
7935
  process.exit(1);
6985
7936
  }
6986
7937
  });
@@ -6992,9 +7943,9 @@ projectCmd.command("create <name>").description("Create a new project").option("
6992
7943
  path: opts.path,
6993
7944
  description: opts.description
6994
7945
  });
6995
- console.log(chalk5.green(`Created project ${chalk5.bold(project.name)} (${project.id})`));
7946
+ log(chalk5.green(`Created project ${chalk5.bold(project.name)} (${project.id})`));
6996
7947
  } catch (error) {
6997
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7948
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6998
7949
  process.exit(1);
6999
7950
  }
7000
7951
  });
@@ -7002,20 +7953,20 @@ projectCmd.command("list").description("List all projects").action(() => {
7002
7953
  try {
7003
7954
  const projects = listProjects();
7004
7955
  if (projects.length === 0) {
7005
- console.log(chalk5.dim("No projects found."));
7956
+ log(chalk5.dim("No projects found."));
7006
7957
  return;
7007
7958
  }
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)}`);
7959
+ log("");
7960
+ log(chalk5.bold(" Projects"));
7961
+ log("");
7962
+ log(` ${"ID".padEnd(38)} ${"Name".padEnd(24)} ${"Path".padEnd(30)} Created`);
7963
+ log(` ${"\u2500".repeat(38)} ${"\u2500".repeat(24)} ${"\u2500".repeat(30)} ${"\u2500".repeat(20)}`);
7013
7964
  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}`);
7965
+ log(` ${p.id.padEnd(38)} ${p.name.padEnd(24)} ${(p.path ?? chalk5.dim("\u2014")).toString().padEnd(30)} ${p.createdAt}`);
7015
7966
  }
7016
- console.log("");
7967
+ log("");
7017
7968
  } catch (error) {
7018
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7969
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7019
7970
  process.exit(1);
7020
7971
  }
7021
7972
  });
@@ -7023,19 +7974,19 @@ projectCmd.command("show <id>").description("Show project details").action((id)
7023
7974
  try {
7024
7975
  const project = getProject(id);
7025
7976
  if (!project) {
7026
- console.error(chalk5.red(`Project not found: ${id}`));
7977
+ logError(chalk5.red(`Project not found: ${id}`));
7027
7978
  process.exit(1);
7028
7979
  }
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("");
7980
+ log("");
7981
+ log(chalk5.bold(` Project: ${project.name}`));
7982
+ log(` ID: ${project.id}`);
7983
+ log(` Path: ${project.path ?? chalk5.dim("none")}`);
7984
+ log(` Description: ${project.description ?? chalk5.dim("none")}`);
7985
+ log(` Created: ${project.createdAt}`);
7986
+ log(` Updated: ${project.updatedAt}`);
7987
+ log("");
7037
7988
  } catch (error) {
7038
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7989
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7039
7990
  process.exit(1);
7040
7991
  }
7041
7992
  });
@@ -7053,9 +8004,9 @@ projectCmd.command("use <name>").description("Set active project (find or create
7053
8004
  }
7054
8005
  config.activeProject = project.id;
7055
8006
  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})`));
8007
+ log(chalk5.green(`Active project set to ${chalk5.bold(project.name)} (${project.id})`));
7057
8008
  } catch (error) {
7058
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8009
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7059
8010
  process.exit(1);
7060
8011
  }
7061
8012
  });
@@ -7080,40 +8031,44 @@ scheduleCmd.command("create <name>").description("Create a new schedule").requir
7080
8031
  timeoutMs: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
7081
8032
  projectId
7082
8033
  });
7083
- console.log(chalk5.green(`Created schedule ${chalk5.bold(schedule.name)} (${schedule.id})`));
8034
+ log(chalk5.green(`Created schedule ${chalk5.bold(schedule.name)} (${schedule.id})`));
7084
8035
  if (schedule.nextRunAt) {
7085
- console.log(chalk5.dim(` Next run at: ${schedule.nextRunAt}`));
8036
+ log(chalk5.dim(` Next run at: ${schedule.nextRunAt}`));
7086
8037
  }
7087
8038
  } catch (error) {
7088
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8039
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7089
8040
  process.exit(1);
7090
8041
  }
7091
8042
  });
7092
- scheduleCmd.command("list").description("List schedules").option("--project <id>", "Filter by project ID").option("--enabled", "Show only enabled schedules").action((opts) => {
8043
+ 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
8044
  try {
7094
8045
  const projectId = resolveProject(opts.project);
7095
8046
  const schedules = listSchedules({
7096
8047
  projectId,
7097
8048
  enabled: opts.enabled ? true : undefined
7098
8049
  });
8050
+ if (opts.json) {
8051
+ log(JSON.stringify(schedules, null, 2));
8052
+ return;
8053
+ }
7099
8054
  if (schedules.length === 0) {
7100
- console.log(chalk5.dim("No schedules found."));
8055
+ log(chalk5.dim("No schedules found."));
7101
8056
  return;
7102
8057
  }
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)}`);
8058
+ log("");
8059
+ log(chalk5.bold(" Schedules"));
8060
+ log("");
8061
+ log(` ${"Name".padEnd(20)} ${"Cron".padEnd(18)} ${"URL".padEnd(30)} ${"Enabled".padEnd(9)} ${"Next Run".padEnd(22)} Last Run`);
8062
+ log(` ${"\u2500".repeat(20)} ${"\u2500".repeat(18)} ${"\u2500".repeat(30)} ${"\u2500".repeat(9)} ${"\u2500".repeat(22)} ${"\u2500".repeat(22)}`);
7108
8063
  for (const s of schedules) {
7109
8064
  const enabled = s.enabled ? chalk5.green("yes") : chalk5.red("no");
7110
8065
  const nextRun = s.nextRunAt ?? chalk5.dim("\u2014");
7111
8066
  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}`);
8067
+ log(` ${s.name.padEnd(20)} ${s.cronExpression.padEnd(18)} ${s.url.padEnd(30)} ${enabled.toString().padEnd(9)} ${nextRun.toString().padEnd(22)} ${lastRun}`);
7113
8068
  }
7114
- console.log("");
8069
+ log("");
7115
8070
  } catch (error) {
7116
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8071
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7117
8072
  process.exit(1);
7118
8073
  }
7119
8074
  });
@@ -7121,47 +8076,47 @@ scheduleCmd.command("show <id>").description("Show schedule details").action((id
7121
8076
  try {
7122
8077
  const schedule = getSchedule(id);
7123
8078
  if (!schedule) {
7124
- console.error(chalk5.red(`Schedule not found: ${id}`));
8079
+ logError(chalk5.red(`Schedule not found: ${id}`));
7125
8080
  process.exit(1);
7126
8081
  }
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("");
8082
+ log("");
8083
+ log(chalk5.bold(` Schedule: ${schedule.name}`));
8084
+ log(` ID: ${schedule.id}`);
8085
+ log(` Cron: ${schedule.cronExpression}`);
8086
+ log(` URL: ${schedule.url}`);
8087
+ log(` Enabled: ${schedule.enabled ? chalk5.green("yes") : chalk5.red("no")}`);
8088
+ log(` Model: ${schedule.model ?? chalk5.dim("default")}`);
8089
+ log(` Headed: ${schedule.headed ? "yes" : "no"}`);
8090
+ log(` Parallel: ${schedule.parallel}`);
8091
+ log(` Timeout: ${schedule.timeoutMs ? `${schedule.timeoutMs}ms` : chalk5.dim("default")}`);
8092
+ log(` Project: ${schedule.projectId ?? chalk5.dim("none")}`);
8093
+ log(` Filter: ${JSON.stringify(schedule.scenarioFilter)}`);
8094
+ log(` Next run: ${schedule.nextRunAt ?? chalk5.dim("not scheduled")}`);
8095
+ log(` Last run: ${schedule.lastRunAt ?? chalk5.dim("never")}`);
8096
+ log(` Last run ID: ${schedule.lastRunId ?? chalk5.dim("none")}`);
8097
+ log(` Created: ${schedule.createdAt}`);
8098
+ log(` Updated: ${schedule.updatedAt}`);
8099
+ log("");
7145
8100
  } catch (error) {
7146
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8101
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7147
8102
  process.exit(1);
7148
8103
  }
7149
8104
  });
7150
8105
  scheduleCmd.command("enable <id>").description("Enable a schedule").action((id) => {
7151
8106
  try {
7152
8107
  const schedule = updateSchedule(id, { enabled: true });
7153
- console.log(chalk5.green(`Enabled schedule ${chalk5.bold(schedule.name)}`));
8108
+ log(chalk5.green(`Enabled schedule ${chalk5.bold(schedule.name)}`));
7154
8109
  } catch (error) {
7155
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8110
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7156
8111
  process.exit(1);
7157
8112
  }
7158
8113
  });
7159
8114
  scheduleCmd.command("disable <id>").description("Disable a schedule").action((id) => {
7160
8115
  try {
7161
8116
  const schedule = updateSchedule(id, { enabled: false });
7162
- console.log(chalk5.green(`Disabled schedule ${chalk5.bold(schedule.name)}`));
8117
+ log(chalk5.green(`Disabled schedule ${chalk5.bold(schedule.name)}`));
7163
8118
  } catch (error) {
7164
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8119
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7165
8120
  process.exit(1);
7166
8121
  }
7167
8122
  });
@@ -7169,13 +8124,13 @@ scheduleCmd.command("delete <id>").description("Delete a schedule").action((id)
7169
8124
  try {
7170
8125
  const deleted = deleteSchedule(id);
7171
8126
  if (deleted) {
7172
- console.log(chalk5.green(`Deleted schedule: ${id}`));
8127
+ log(chalk5.green(`Deleted schedule: ${id}`));
7173
8128
  } else {
7174
- console.error(chalk5.red(`Schedule not found: ${id}`));
8129
+ logError(chalk5.red(`Schedule not found: ${id}`));
7175
8130
  process.exit(1);
7176
8131
  }
7177
8132
  } catch (error) {
7178
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8133
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7179
8134
  process.exit(1);
7180
8135
  }
7181
8136
  });
@@ -7183,11 +8138,11 @@ scheduleCmd.command("run <id>").description("Manually trigger a schedule").optio
7183
8138
  try {
7184
8139
  const schedule = getSchedule(id);
7185
8140
  if (!schedule) {
7186
- console.error(chalk5.red(`Schedule not found: ${id}`));
8141
+ logError(chalk5.red(`Schedule not found: ${id}`));
7187
8142
  process.exit(1);
7188
8143
  return;
7189
8144
  }
7190
- console.log(chalk5.blue(`Running schedule ${chalk5.bold(schedule.name)} against ${schedule.url}...`));
8145
+ log(chalk5.blue(`Running schedule ${chalk5.bold(schedule.name)} against ${schedule.url}...`));
7191
8146
  const { run, results } = await runByFilter({
7192
8147
  url: schedule.url,
7193
8148
  tags: schedule.scenarioFilter.tags,
@@ -7200,21 +8155,21 @@ scheduleCmd.command("run <id>").description("Manually trigger a schedule").optio
7200
8155
  projectId: schedule.projectId ?? undefined
7201
8156
  });
7202
8157
  if (opts.json) {
7203
- console.log(formatJSON(run, results));
8158
+ log(formatJSON(run, results));
7204
8159
  } else {
7205
- console.log(formatTerminal(run, results));
8160
+ log(formatTerminal(run, results));
7206
8161
  }
7207
8162
  process.exit(getExitCode(run));
7208
8163
  } catch (error) {
7209
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8164
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7210
8165
  process.exit(1);
7211
8166
  }
7212
8167
  });
7213
8168
  program2.command("daemon").description("Start the scheduler daemon").option("--interval <seconds>", "Check interval in seconds", "60").action(async (opts) => {
7214
8169
  try {
7215
8170
  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`));
8171
+ log(chalk5.blue("Scheduler daemon started. Press Ctrl+C to stop."));
8172
+ log(chalk5.dim(` Check interval: ${opts.interval}s`));
7218
8173
  let running = true;
7219
8174
  const checkAndRun = async () => {
7220
8175
  while (running) {
@@ -7223,7 +8178,7 @@ program2.command("daemon").description("Start the scheduler daemon").option("--i
7223
8178
  const now2 = new Date().toISOString();
7224
8179
  for (const schedule of schedules) {
7225
8180
  if (schedule.nextRunAt && schedule.nextRunAt <= now2) {
7226
- console.log(chalk5.blue(`[${new Date().toISOString()}] Triggering schedule: ${schedule.name}`));
8181
+ log(chalk5.blue(`[${new Date().toISOString()}] Triggering schedule: ${schedule.name}`));
7227
8182
  try {
7228
8183
  const { run } = await runByFilter({
7229
8184
  url: schedule.url,
@@ -7237,60 +8192,60 @@ program2.command("daemon").description("Start the scheduler daemon").option("--i
7237
8192
  projectId: schedule.projectId ?? undefined
7238
8193
  });
7239
8194
  const statusColor = run.status === "passed" ? chalk5.green : chalk5.red;
7240
- console.log(` ${statusColor(run.status)} \u2014 ${run.passed}/${run.total} passed`);
8195
+ log(` ${statusColor(run.status)} \u2014 ${run.passed}/${run.total} passed`);
7241
8196
  updateSchedule(schedule.id, {});
7242
8197
  } catch (err) {
7243
- console.error(chalk5.red(` Error running schedule ${schedule.name}: ${err instanceof Error ? err.message : String(err)}`));
8198
+ logError(chalk5.red(` Error running schedule ${schedule.name}: ${err instanceof Error ? err.message : String(err)}`));
7244
8199
  }
7245
8200
  }
7246
8201
  }
7247
8202
  } catch (err) {
7248
- console.error(chalk5.red(`Daemon error: ${err instanceof Error ? err.message : String(err)}`));
8203
+ logError(chalk5.red(`Daemon error: ${err instanceof Error ? err.message : String(err)}`));
7249
8204
  }
7250
8205
  await new Promise((resolve2) => setTimeout(resolve2, intervalMs));
7251
8206
  }
7252
8207
  };
7253
8208
  process.on("SIGINT", () => {
7254
- console.log(chalk5.yellow(`
8209
+ log(chalk5.yellow(`
7255
8210
  Shutting down scheduler daemon...`));
7256
8211
  running = false;
7257
8212
  process.exit(0);
7258
8213
  });
7259
8214
  process.on("SIGTERM", () => {
7260
- console.log(chalk5.yellow(`
8215
+ log(chalk5.yellow(`
7261
8216
  Shutting down scheduler daemon...`));
7262
8217
  running = false;
7263
8218
  process.exit(0);
7264
8219
  });
7265
8220
  await checkAndRun();
7266
8221
  } catch (error) {
7267
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8222
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7268
8223
  process.exit(1);
7269
8224
  }
7270
8225
  });
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) => {
8226
+ 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
8227
  try {
7273
- const { project, scenarios, framework } = initProject({
8228
+ const { project, scenarios, framework, url } = initProject({
7274
8229
  name: opts.name,
7275
8230
  url: opts.url,
7276
8231
  path: opts.path
7277
8232
  });
7278
- console.log("");
7279
- console.log(chalk5.bold(" Project initialized!"));
7280
- console.log("");
8233
+ log("");
8234
+ log(chalk5.bold(" Project initialized!"));
8235
+ log("");
7281
8236
  if (framework) {
7282
- console.log(` Framework: ${chalk5.cyan(framework.name)}`);
8237
+ log(` Framework: ${chalk5.cyan(framework.name)}`);
7283
8238
  if (framework.features.length > 0) {
7284
- console.log(` Features: ${chalk5.dim(framework.features.join(", "))}`);
8239
+ log(` Features: ${chalk5.dim(framework.features.join(", "))}`);
7285
8240
  }
7286
8241
  } else {
7287
- console.log(` Framework: ${chalk5.dim("not detected")}`);
8242
+ log(` Framework: ${chalk5.dim("not detected")}`);
7288
8243
  }
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("");
8244
+ log(` Project: ${chalk5.green(project.name)} ${chalk5.dim(`(${project.id})`)}`);
8245
+ log(` Scenarios: ${chalk5.green(String(scenarios.length))} starter scenarios created`);
8246
+ log("");
7292
8247
  for (const s of scenarios) {
7293
- console.log(` ${chalk5.dim(s.shortId)} ${s.name} ${chalk5.dim(`[${s.tags.join(", ")}]`)}`);
8248
+ log(` ${chalk5.dim(s.shortId)} ${s.name} ${chalk5.dim(`[${s.tags.join(", ")}]`)}`);
7294
8249
  }
7295
8250
  if (opts.ci === "github") {
7296
8251
  const workflowDir = join6(process.cwd(), ".github", "workflows");
@@ -7299,18 +8254,51 @@ program2.command("init").description("Initialize a new testing project").option(
7299
8254
  }
7300
8255
  const workflowPath = join6(workflowDir, "testers.yml");
7301
8256
  writeFileSync3(workflowPath, generateGitHubActionsWorkflow(), "utf-8");
7302
- console.log(` CI: ${chalk5.green("GitHub Actions workflow written to .github/workflows/testers.yml")}`);
8257
+ log(` CI: ${chalk5.green("GitHub Actions workflow written to .github/workflows/testers.yml")}`);
7303
8258
  } 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("");
8259
+ log(chalk5.yellow(` Unknown CI provider: ${opts.ci}. Supported: github`));
8260
+ }
8261
+ log("");
8262
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
8263
+ const ask = (q) => new Promise((resolve2) => rl.question(q, resolve2));
8264
+ try {
8265
+ const envAnswer = await ask(" Would you like to configure environments? [y/N] ");
8266
+ if (envAnswer.trim().toLowerCase() === "y") {
8267
+ const envName = await ask(" Environment name (default: staging): ");
8268
+ const envUrl = await ask(` Base URL (default: ${url}): `);
8269
+ const resolvedEnvName = envName.trim() || "staging";
8270
+ const resolvedEnvUrl = envUrl.trim() || url;
8271
+ createEnvironment({ name: resolvedEnvName, url: resolvedEnvUrl, projectId: project.id, isDefault: true });
8272
+ log(chalk5.green(` \u2713 Environment '${resolvedEnvName}' created (${resolvedEnvUrl})`));
8273
+ log("");
8274
+ }
8275
+ const scenarioAnswer = await ask(" Would you like to create your first test scenario? [y/N] ");
8276
+ if (scenarioAnswer.trim().toLowerCase() === "y") {
8277
+ const scenarioName = await ask(" Scenario name: ");
8278
+ const scenarioUrl = await ask(` URL to test (default: ${url}): `);
8279
+ const resolvedScenarioName = scenarioName.trim() || "My first scenario";
8280
+ const resolvedScenarioUrl = scenarioUrl.trim() || url;
8281
+ const newScenario = createScenario({
8282
+ name: resolvedScenarioName,
8283
+ description: `Navigate to ${resolvedScenarioUrl} and verify it loads correctly.`,
8284
+ projectId: project.id,
8285
+ targetPath: resolvedScenarioUrl,
8286
+ tags: ["smoke"],
8287
+ priority: "high"
8288
+ });
8289
+ log(chalk5.green(` \u2713 Scenario '${newScenario.name}' created ${chalk5.dim(`(${newScenario.shortId})`)}`));
8290
+ log("");
8291
+ }
8292
+ } finally {
8293
+ rl.close();
8294
+ }
8295
+ log(chalk5.bold(" Next steps:"));
8296
+ log(` 1. Start your dev server`);
8297
+ log(` 2. Run ${chalk5.cyan("testers run <url>")} to execute tests`);
8298
+ log(` 3. Add more scenarios with ${chalk5.cyan("testers add <name>")}`);
8299
+ log("");
7312
8300
  } catch (error) {
7313
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8301
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7314
8302
  process.exit(1);
7315
8303
  }
7316
8304
  });
@@ -7318,16 +8306,16 @@ program2.command("replay <run-id>").description("Re-run all scenarios from a pre
7318
8306
  try {
7319
8307
  const originalRun = getRun(runId);
7320
8308
  if (!originalRun) {
7321
- console.error(chalk5.red(`Run not found: ${runId}`));
8309
+ logError(chalk5.red(`Run not found: ${runId}`));
7322
8310
  process.exit(1);
7323
8311
  }
7324
8312
  const originalResults = getResultsByRun(originalRun.id);
7325
8313
  const scenarioIds = originalResults.map((r) => r.scenarioId);
7326
8314
  if (scenarioIds.length === 0) {
7327
- console.log(chalk5.dim("No scenarios to replay."));
8315
+ log(chalk5.dim("No scenarios to replay."));
7328
8316
  return;
7329
8317
  }
7330
- console.log(chalk5.blue(`Replaying ${scenarioIds.length} scenarios from run ${originalRun.id.slice(0, 8)}...`));
8318
+ log(chalk5.blue(`Replaying ${scenarioIds.length} scenarios from run ${originalRun.id.slice(0, 8)}...`));
7331
8319
  const { run, results } = await runByFilter({
7332
8320
  url: opts.url ?? originalRun.url,
7333
8321
  scenarioIds,
@@ -7336,13 +8324,13 @@ program2.command("replay <run-id>").description("Re-run all scenarios from a pre
7336
8324
  parallel: parseInt(opts.parallel, 10)
7337
8325
  });
7338
8326
  if (opts.json) {
7339
- console.log(formatJSON(run, results));
8327
+ log(formatJSON(run, results));
7340
8328
  } else {
7341
- console.log(formatTerminal(run, results));
8329
+ log(formatTerminal(run, results));
7342
8330
  }
7343
8331
  process.exit(getExitCode(run));
7344
8332
  } catch (error) {
7345
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8333
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7346
8334
  process.exit(1);
7347
8335
  }
7348
8336
  });
@@ -7350,16 +8338,16 @@ program2.command("retry <run-id>").description("Re-run only failed scenarios fro
7350
8338
  try {
7351
8339
  const originalRun = getRun(runId);
7352
8340
  if (!originalRun) {
7353
- console.error(chalk5.red(`Run not found: ${runId}`));
8341
+ logError(chalk5.red(`Run not found: ${runId}`));
7354
8342
  process.exit(1);
7355
8343
  }
7356
8344
  const originalResults = getResultsByRun(originalRun.id);
7357
8345
  const failedScenarioIds = originalResults.filter((r) => r.status === "failed" || r.status === "error").map((r) => r.scenarioId);
7358
8346
  if (failedScenarioIds.length === 0) {
7359
- console.log(chalk5.green("No failed scenarios to retry. All passed!"));
8347
+ log(chalk5.green("No failed scenarios to retry. All passed!"));
7360
8348
  return;
7361
8349
  }
7362
- console.log(chalk5.blue(`Retrying ${failedScenarioIds.length} failed scenarios from run ${originalRun.id.slice(0, 8)}...`));
8350
+ log(chalk5.blue(`Retrying ${failedScenarioIds.length} failed scenarios from run ${originalRun.id.slice(0, 8)}...`));
7363
8351
  const { run, results } = await runByFilter({
7364
8352
  url: opts.url ?? originalRun.url,
7365
8353
  scenarioIds: failedScenarioIds,
@@ -7368,35 +8356,35 @@ program2.command("retry <run-id>").description("Re-run only failed scenarios fro
7368
8356
  parallel: parseInt(opts.parallel, 10)
7369
8357
  });
7370
8358
  if (!opts.json) {
7371
- console.log("");
7372
- console.log(chalk5.bold(" Comparison with original run:"));
8359
+ log("");
8360
+ log(chalk5.bold(" Comparison with original run:"));
7373
8361
  for (const result of results) {
7374
8362
  const original = originalResults.find((r) => r.scenarioId === result.scenarioId);
7375
8363
  if (original) {
7376
8364
  const changed = original.status !== result.status;
7377
8365
  const arrow = changed ? chalk5.yellow(`${original.status} \u2192 ${result.status}`) : chalk5.dim(`${result.status} (unchanged)`);
7378
8366
  const icon = result.status === "passed" ? chalk5.green("\u2713") : chalk5.red("\u2717");
7379
- console.log(` ${icon} ${result.scenarioId.slice(0, 8)}: ${arrow}`);
8367
+ log(` ${icon} ${result.scenarioId.slice(0, 8)}: ${arrow}`);
7380
8368
  }
7381
8369
  }
7382
- console.log("");
8370
+ log("");
7383
8371
  }
7384
8372
  if (opts.json) {
7385
- console.log(formatJSON(run, results));
8373
+ log(formatJSON(run, results));
7386
8374
  } else {
7387
- console.log(formatTerminal(run, results));
8375
+ log(formatTerminal(run, results));
7388
8376
  }
7389
8377
  process.exit(getExitCode(run));
7390
8378
  } catch (error) {
7391
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8379
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7392
8380
  process.exit(1);
7393
8381
  }
7394
8382
  });
7395
8383
  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
8384
  try {
7397
8385
  const projectId = resolveProject(opts.project);
7398
- console.log(chalk5.blue(`Running smoke test against ${chalk5.bold(url)}...`));
7399
- console.log("");
8386
+ log(chalk5.blue(`Running smoke test against ${chalk5.bold(url)}...`));
8387
+ log("");
7400
8388
  const smokeResult = await runSmoke({
7401
8389
  url,
7402
8390
  model: opts.model,
@@ -7405,19 +8393,19 @@ program2.command("smoke <url>").description("Run autonomous smoke test").option(
7405
8393
  projectId
7406
8394
  });
7407
8395
  if (opts.json) {
7408
- console.log(JSON.stringify({
8396
+ log(JSON.stringify({
7409
8397
  run: smokeResult.run,
7410
8398
  result: smokeResult.result,
7411
8399
  pagesVisited: smokeResult.pagesVisited,
7412
8400
  issues: smokeResult.issuesFound
7413
8401
  }, null, 2));
7414
8402
  } else {
7415
- console.log(formatSmokeReport(smokeResult));
8403
+ log(formatSmokeReport(smokeResult));
7416
8404
  }
7417
8405
  const hasCritical = smokeResult.issuesFound.some((i) => i.severity === "critical" || i.severity === "high");
7418
8406
  process.exit(hasCritical ? 1 : 0);
7419
8407
  } catch (error) {
7420
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8408
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7421
8409
  process.exit(1);
7422
8410
  }
7423
8411
  });
@@ -7425,27 +8413,27 @@ program2.command("diff <run1> <run2>").description("Compare two test runs").opti
7425
8413
  try {
7426
8414
  const diff = diffRuns(run1, run2);
7427
8415
  if (opts.json) {
7428
- console.log(formatDiffJSON(diff));
8416
+ log(formatDiffJSON(diff));
7429
8417
  } else {
7430
- console.log(formatDiffTerminal(diff));
8418
+ log(formatDiffTerminal(diff));
7431
8419
  }
7432
8420
  const threshold = parseFloat(opts.threshold);
7433
8421
  const visualResults = compareRunScreenshots(run2, run1, threshold);
7434
8422
  if (visualResults.length > 0) {
7435
8423
  if (opts.json) {
7436
- console.log(JSON.stringify({ visualDiff: visualResults }, null, 2));
8424
+ log(JSON.stringify({ visualDiff: visualResults }, null, 2));
7437
8425
  } else {
7438
- console.log(formatVisualDiffTerminal(visualResults, threshold));
8426
+ log(formatVisualDiffTerminal(visualResults, threshold));
7439
8427
  }
7440
8428
  }
7441
8429
  const hasVisualRegressions = visualResults.some((r) => r.isRegression);
7442
8430
  process.exit(diff.regressions.length > 0 || hasVisualRegressions ? 1 : 0);
7443
8431
  } catch (error) {
7444
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8432
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7445
8433
  process.exit(1);
7446
8434
  }
7447
8435
  });
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) => {
8436
+ 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
8437
  try {
7450
8438
  let html;
7451
8439
  if (opts.latest || !runId) {
@@ -7454,9 +8442,14 @@ program2.command("report [run-id]").description("Generate HTML test report").opt
7454
8442
  html = generateHtmlReport(runId);
7455
8443
  }
7456
8444
  writeFileSync3(opts.output, html, "utf-8");
7457
- console.log(chalk5.green(`Report generated: ${opts.output}`));
8445
+ const absPath = resolve(opts.output);
8446
+ log(chalk5.green(`Report generated: ${absPath}`));
8447
+ if (opts.open) {
8448
+ const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
8449
+ Bun.spawn([openCmd, absPath]);
8450
+ }
7458
8451
  } catch (error) {
7459
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8452
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7460
8453
  process.exit(1);
7461
8454
  }
7462
8455
  });
@@ -7469,9 +8462,9 @@ authCmd.command("add <name>").description("Create an auth preset").requiredOptio
7469
8462
  password: opts.password,
7470
8463
  loginPath: opts.loginPath
7471
8464
  });
7472
- console.log(chalk5.green(`Created auth preset ${chalk5.bold(preset.name)} (${preset.email})`));
8465
+ log(chalk5.green(`Created auth preset ${chalk5.bold(preset.name)} (${preset.email})`));
7473
8466
  } catch (error) {
7474
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8467
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7475
8468
  process.exit(1);
7476
8469
  }
7477
8470
  });
@@ -7479,20 +8472,20 @@ authCmd.command("list").description("List auth presets").action(() => {
7479
8472
  try {
7480
8473
  const presets = listAuthPresets();
7481
8474
  if (presets.length === 0) {
7482
- console.log(chalk5.dim("No auth presets found."));
8475
+ log(chalk5.dim("No auth presets found."));
7483
8476
  return;
7484
8477
  }
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)}`);
8478
+ log("");
8479
+ log(chalk5.bold(" Auth Presets"));
8480
+ log("");
8481
+ log(` ${"Name".padEnd(20)} ${"Email".padEnd(30)} ${"Login Path".padEnd(15)} Created`);
8482
+ log(` ${"\u2500".repeat(20)} ${"\u2500".repeat(30)} ${"\u2500".repeat(15)} ${"\u2500".repeat(22)}`);
7490
8483
  for (const p of presets) {
7491
- console.log(` ${p.name.padEnd(20)} ${p.email.padEnd(30)} ${p.loginPath.padEnd(15)} ${p.createdAt}`);
8484
+ log(` ${p.name.padEnd(20)} ${p.email.padEnd(30)} ${p.loginPath.padEnd(15)} ${p.createdAt}`);
7492
8485
  }
7493
- console.log("");
8486
+ log("");
7494
8487
  } catch (error) {
7495
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8488
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7496
8489
  process.exit(1);
7497
8490
  }
7498
8491
  });
@@ -7500,26 +8493,39 @@ authCmd.command("delete <name>").description("Delete an auth preset").action((na
7500
8493
  try {
7501
8494
  const deleted = deleteAuthPreset(name);
7502
8495
  if (deleted) {
7503
- console.log(chalk5.green(`Deleted auth preset: ${name}`));
8496
+ log(chalk5.green(`Deleted auth preset: ${name}`));
7504
8497
  } else {
7505
- console.error(chalk5.red(`Auth preset not found: ${name}`));
8498
+ logError(chalk5.red(`Auth preset not found: ${name}`));
7506
8499
  process.exit(1);
7507
8500
  }
7508
8501
  } catch (error) {
7509
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8502
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7510
8503
  process.exit(1);
7511
8504
  }
7512
8505
  });
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) => {
8506
+ program2.command("costs").description("Show cost tracking and budget status").option("--project <id>", "Project ID").option("--period <period>", "Time period: day, week, month, all (default: month)", "month").option("--by-scenario", "Group cost breakdown by scenario, sorted by total cost", false).option("--json", "JSON output", false).option("--csv", "CSV output", false).action((opts) => {
7514
8507
  try {
7515
- const summary = getCostSummary({ projectId: resolveProject(opts.project), period: opts.period });
7516
- if (opts.json) {
7517
- console.log(formatCostsJSON(summary));
8508
+ const projectId = resolveProject(opts.project);
8509
+ const period = opts.period;
8510
+ if (opts.byScenario) {
8511
+ const rows = getCostsByScenario({ projectId, period });
8512
+ if (opts.json) {
8513
+ log(JSON.stringify(rows, null, 2));
8514
+ } else {
8515
+ log(formatCostsByScenarioTerminal(rows, period));
8516
+ }
8517
+ return;
8518
+ }
8519
+ const summary = getCostSummary({ projectId, period });
8520
+ if (opts.csv) {
8521
+ log(formatCostsCsv(summary));
8522
+ } else if (opts.json) {
8523
+ log(formatCostsJSON(summary));
7518
8524
  } else {
7519
- console.log(formatCostsTerminal(summary));
8525
+ log(formatCostsTerminal(summary));
7520
8526
  }
7521
8527
  } catch (error) {
7522
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8528
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7523
8529
  process.exit(1);
7524
8530
  }
7525
8531
  });
@@ -7527,18 +8533,18 @@ program2.command("chain <scenario-id>").description("Add a dependency to a scena
7527
8533
  try {
7528
8534
  const scenario = getScenario(scenarioId) ?? getScenarioByShortId(scenarioId);
7529
8535
  if (!scenario) {
7530
- console.error(chalk5.red(`Scenario not found: ${scenarioId}`));
8536
+ logError(chalk5.red(`Scenario not found: ${scenarioId}`));
7531
8537
  process.exit(1);
7532
8538
  }
7533
8539
  const dep = getScenario(opts.dependsOn) ?? getScenarioByShortId(opts.dependsOn);
7534
8540
  if (!dep) {
7535
- console.error(chalk5.red(`Dependency scenario not found: ${opts.dependsOn}`));
8541
+ logError(chalk5.red(`Dependency scenario not found: ${opts.dependsOn}`));
7536
8542
  process.exit(1);
7537
8543
  }
7538
8544
  addDependency(scenario.id, dep.id);
7539
- console.log(chalk5.green(`${scenario.shortId} now depends on ${dep.shortId}`));
8545
+ log(chalk5.green(`${scenario.shortId} now depends on ${dep.shortId}`));
7540
8546
  } catch (error) {
7541
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8547
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7542
8548
  process.exit(1);
7543
8549
  }
7544
8550
  });
@@ -7546,18 +8552,18 @@ program2.command("unchain <scenario-id>").description("Remove a dependency from
7546
8552
  try {
7547
8553
  const scenario = getScenario(scenarioId) ?? getScenarioByShortId(scenarioId);
7548
8554
  if (!scenario) {
7549
- console.error(chalk5.red(`Scenario not found: ${scenarioId}`));
8555
+ logError(chalk5.red(`Scenario not found: ${scenarioId}`));
7550
8556
  process.exit(1);
7551
8557
  }
7552
8558
  const dep = getScenario(opts.from) ?? getScenarioByShortId(opts.from);
7553
8559
  if (!dep) {
7554
- console.error(chalk5.red(`Dependency not found: ${opts.from}`));
8560
+ logError(chalk5.red(`Dependency not found: ${opts.from}`));
7555
8561
  process.exit(1);
7556
8562
  }
7557
8563
  removeDependency(scenario.id, dep.id);
7558
- console.log(chalk5.green(`Removed dependency: ${scenario.shortId} no longer depends on ${dep.shortId}`));
8564
+ log(chalk5.green(`Removed dependency: ${scenario.shortId} no longer depends on ${dep.shortId}`));
7559
8565
  } catch (error) {
7560
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8566
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7561
8567
  process.exit(1);
7562
8568
  }
7563
8569
  });
@@ -7565,34 +8571,34 @@ program2.command("deps <scenario-id>").description("Show dependencies for a scen
7565
8571
  try {
7566
8572
  const scenario = getScenario(scenarioId) ?? getScenarioByShortId(scenarioId);
7567
8573
  if (!scenario) {
7568
- console.error(chalk5.red(`Scenario not found: ${scenarioId}`));
8574
+ logError(chalk5.red(`Scenario not found: ${scenarioId}`));
7569
8575
  process.exit(1);
7570
8576
  }
7571
8577
  const deps = getDependencies(scenario.id);
7572
8578
  const dependents = getDependents(scenario.id);
7573
- console.log("");
7574
- console.log(chalk5.bold(` Dependencies for ${scenario.shortId}: ${scenario.name}`));
7575
- console.log("");
8579
+ log("");
8580
+ log(chalk5.bold(` Dependencies for ${scenario.shortId}: ${scenario.name}`));
8581
+ log("");
7576
8582
  if (deps.length > 0) {
7577
- console.log(chalk5.dim(" Depends on:"));
8583
+ log(chalk5.dim(" Depends on:"));
7578
8584
  for (const depId of deps) {
7579
8585
  const s = getScenario(depId);
7580
- console.log(` \u2192 ${s ? `${s.shortId}: ${s.name}` : depId.slice(0, 8)}`);
8586
+ log(` \u2192 ${s ? `${s.shortId}: ${s.name}` : depId.slice(0, 8)}`);
7581
8587
  }
7582
8588
  } else {
7583
- console.log(chalk5.dim(" No dependencies"));
8589
+ log(chalk5.dim(" No dependencies"));
7584
8590
  }
7585
8591
  if (dependents.length > 0) {
7586
- console.log("");
7587
- console.log(chalk5.dim(" Required by:"));
8592
+ log("");
8593
+ log(chalk5.dim(" Required by:"));
7588
8594
  for (const depId of dependents) {
7589
8595
  const s = getScenario(depId);
7590
- console.log(` \u2190 ${s ? `${s.shortId}: ${s.name}` : depId.slice(0, 8)}`);
8596
+ log(` \u2190 ${s ? `${s.shortId}: ${s.name}` : depId.slice(0, 8)}`);
7591
8597
  }
7592
8598
  }
7593
- console.log("");
8599
+ log("");
7594
8600
  } catch (error) {
7595
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8601
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7596
8602
  process.exit(1);
7597
8603
  }
7598
8604
  });
@@ -7602,7 +8608,7 @@ flowCmd.command("create <name>").description("Create a flow from scenario IDs").
7602
8608
  const ids = opts.chain.split(",").map((id) => {
7603
8609
  const s = getScenario(id.trim()) ?? getScenarioByShortId(id.trim());
7604
8610
  if (!s) {
7605
- console.error(chalk5.red(`Scenario not found: ${id.trim()}`));
8611
+ logError(chalk5.red(`Scenario not found: ${id.trim()}`));
7606
8612
  process.exit(1);
7607
8613
  }
7608
8614
  return s.id;
@@ -7613,49 +8619,49 @@ flowCmd.command("create <name>").description("Create a flow from scenario IDs").
7613
8619
  } catch {}
7614
8620
  }
7615
8621
  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)`));
8622
+ log(chalk5.green(`Flow created: ${flow.id.slice(0, 8)} \u2014 ${flow.name} (${ids.length} scenarios)`));
7617
8623
  } catch (error) {
7618
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8624
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7619
8625
  process.exit(1);
7620
8626
  }
7621
8627
  });
7622
8628
  flowCmd.command("list").description("List all flows").option("--project <id>", "Project ID").action((opts) => {
7623
8629
  const flows = listFlows(resolveProject(opts.project) ?? undefined);
7624
8630
  if (flows.length === 0) {
7625
- console.log(chalk5.dim(`
8631
+ log(chalk5.dim(`
7626
8632
  No flows found.
7627
8633
  `));
7628
8634
  return;
7629
8635
  }
7630
- console.log("");
7631
- console.log(chalk5.bold(" Flows"));
7632
- console.log("");
8636
+ log("");
8637
+ log(chalk5.bold(" Flows"));
8638
+ log("");
7633
8639
  for (const f of flows) {
7634
- console.log(` ${chalk5.dim(f.id.slice(0, 8))} ${f.name} ${chalk5.dim(`(${f.scenarioIds.length} scenarios)`)}`);
8640
+ log(` ${chalk5.dim(f.id.slice(0, 8))} ${f.name} ${chalk5.dim(`(${f.scenarioIds.length} scenarios)`)}`);
7635
8641
  }
7636
- console.log("");
8642
+ log("");
7637
8643
  });
7638
8644
  flowCmd.command("show <id>").description("Show flow details").action((id) => {
7639
8645
  const flow = getFlow(id);
7640
8646
  if (!flow) {
7641
- console.error(chalk5.red(`Flow not found: ${id}`));
8647
+ logError(chalk5.red(`Flow not found: ${id}`));
7642
8648
  process.exit(1);
7643
8649
  }
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):`);
8650
+ log("");
8651
+ log(chalk5.bold(` Flow: ${flow.name}`));
8652
+ log(` ID: ${chalk5.dim(flow.id)}`);
8653
+ log(` Scenarios (in order):`);
7648
8654
  for (let i = 0;i < flow.scenarioIds.length; i++) {
7649
8655
  const s = getScenario(flow.scenarioIds[i]);
7650
- console.log(` ${i + 1}. ${s ? `${s.shortId}: ${s.name}` : flow.scenarioIds[i].slice(0, 8)}`);
8656
+ log(` ${i + 1}. ${s ? `${s.shortId}: ${s.name}` : flow.scenarioIds[i].slice(0, 8)}`);
7651
8657
  }
7652
- console.log("");
8658
+ log("");
7653
8659
  });
7654
8660
  flowCmd.command("delete <id>").description("Delete a flow").action((id) => {
7655
8661
  if (deleteFlow(id))
7656
- console.log(chalk5.green("Flow deleted."));
8662
+ log(chalk5.green("Flow deleted."));
7657
8663
  else {
7658
- console.error(chalk5.red("Flow not found."));
8664
+ logError(chalk5.red("Flow not found."));
7659
8665
  process.exit(1);
7660
8666
  }
7661
8667
  });
@@ -7663,14 +8669,14 @@ flowCmd.command("run <id>").description("Run a flow (scenarios in dependency ord
7663
8669
  try {
7664
8670
  const flow = getFlow(id);
7665
8671
  if (!flow) {
7666
- console.error(chalk5.red(`Flow not found: ${id}`));
8672
+ logError(chalk5.red(`Flow not found: ${id}`));
7667
8673
  process.exit(1);
7668
8674
  }
7669
8675
  if (!opts.url) {
7670
- console.error(chalk5.red("--url is required for flow run"));
8676
+ logError(chalk5.red("--url is required for flow run"));
7671
8677
  process.exit(1);
7672
8678
  }
7673
- console.log(chalk5.blue(`Running flow: ${flow.name} (${flow.scenarioIds.length} scenarios)`));
8679
+ log(chalk5.blue(`Running flow: ${flow.name} (${flow.scenarioIds.length} scenarios)`));
7674
8680
  const { run, results } = await runByFilter({
7675
8681
  url: opts.url,
7676
8682
  scenarioIds: flow.scenarioIds,
@@ -7679,12 +8685,12 @@ flowCmd.command("run <id>").description("Run a flow (scenarios in dependency ord
7679
8685
  parallel: 1
7680
8686
  });
7681
8687
  if (opts.json)
7682
- console.log(formatJSON(run, results));
8688
+ log(formatJSON(run, results));
7683
8689
  else
7684
- console.log(formatTerminal(run, results));
8690
+ log(formatTerminal(run, results));
7685
8691
  process.exit(getExitCode(run));
7686
8692
  } catch (error) {
7687
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8693
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7688
8694
  process.exit(1);
7689
8695
  }
7690
8696
  });
@@ -7698,9 +8704,9 @@ envCmd.command("add <name>").description("Add a named environment").requiredOpti
7698
8704
  projectId: opts.project,
7699
8705
  isDefault: opts.default
7700
8706
  });
7701
- console.log(chalk5.green(`Environment added: ${env.name} \u2192 ${env.url}${env.isDefault ? " (default)" : ""}`));
8707
+ log(chalk5.green(`Environment added: ${env.name} \u2192 ${env.url}${env.isDefault ? " (default)" : ""}`));
7702
8708
  } catch (error) {
7703
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8709
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7704
8710
  process.exit(1);
7705
8711
  }
7706
8712
  });
@@ -7708,16 +8714,16 @@ envCmd.command("list").description("List all environments").option("--project <i
7708
8714
  try {
7709
8715
  const envs = listEnvironments(opts.project);
7710
8716
  if (envs.length === 0) {
7711
- console.log(chalk5.dim("No environments configured. Add one with: testers env add <name> --url <url>"));
8717
+ log(chalk5.dim("No environments configured. Add one with: testers env add <name> --url <url>"));
7712
8718
  return;
7713
8719
  }
7714
8720
  for (const env of envs) {
7715
8721
  const marker = env.isDefault ? chalk5.green(" \u2605 default") : "";
7716
8722
  const auth = env.authPresetName ? chalk5.dim(` (auth: ${env.authPresetName})`) : "";
7717
- console.log(` ${chalk5.bold(env.name)} ${env.url}${auth}${marker}`);
8723
+ log(` ${chalk5.bold(env.name)} ${env.url}${auth}${marker}`);
7718
8724
  }
7719
8725
  } catch (error) {
7720
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8726
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7721
8727
  process.exit(1);
7722
8728
  }
7723
8729
  });
@@ -7725,9 +8731,9 @@ envCmd.command("use <name>").description("Set an environment as the default").ac
7725
8731
  try {
7726
8732
  setDefaultEnvironment(name);
7727
8733
  const env = getEnvironment(name);
7728
- console.log(chalk5.green(`Default environment set: ${env.name} \u2192 ${env.url}`));
8734
+ log(chalk5.green(`Default environment set: ${env.name} \u2192 ${env.url}`));
7729
8735
  } catch (error) {
7730
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8736
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7731
8737
  process.exit(1);
7732
8738
  }
7733
8739
  });
@@ -7735,13 +8741,13 @@ envCmd.command("delete <name>").description("Delete an environment").action((nam
7735
8741
  try {
7736
8742
  const deleted = deleteEnvironment(name);
7737
8743
  if (deleted) {
7738
- console.log(chalk5.green(`Environment deleted: ${name}`));
8744
+ log(chalk5.green(`Environment deleted: ${name}`));
7739
8745
  } else {
7740
- console.error(chalk5.red(`Environment not found: ${name}`));
8746
+ logError(chalk5.red(`Environment not found: ${name}`));
7741
8747
  process.exit(1);
7742
8748
  }
7743
8749
  } catch (error) {
7744
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8750
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7745
8751
  process.exit(1);
7746
8752
  }
7747
8753
  });
@@ -7749,9 +8755,9 @@ program2.command("baseline <run-id>").description("Set a run as the visual basel
7749
8755
  try {
7750
8756
  setBaseline(runId);
7751
8757
  const run = getRun(runId);
7752
- console.log(chalk5.green(`Baseline set: ${chalk5.bold(runId.slice(0, 8))}${run ? ` (${run.status}, ${run.total} scenarios)` : ""}`));
8758
+ log(chalk5.green(`Baseline set: ${chalk5.bold(runId.slice(0, 8))}${run ? ` (${run.status}, ${run.total} scenarios)` : ""}`));
7753
8759
  } catch (error) {
7754
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8760
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7755
8761
  process.exit(1);
7756
8762
  }
7757
8763
  });
@@ -7759,29 +8765,99 @@ program2.command("import-api <spec>").description("Import test scenarios from an
7759
8765
  try {
7760
8766
  const { importFromOpenAPI: importFromOpenAPI2 } = await Promise.resolve().then(() => (init_openapi_import(), exports_openapi_import));
7761
8767
  const { imported, scenarios } = importFromOpenAPI2(spec, resolveProject(opts.project) ?? undefined);
7762
- console.log(chalk5.green(`
8768
+ log(chalk5.green(`
7763
8769
  Imported ${imported} scenarios from API spec:`));
7764
8770
  for (const s of scenarios) {
7765
- console.log(` ${chalk5.cyan(s.shortId)} ${s.name} ${chalk5.dim(`[${s.tags.join(", ")}]`)}`);
8771
+ log(` ${chalk5.cyan(s.shortId)} ${s.name} ${chalk5.dim(`[${s.tags.join(", ")}]`)}`);
7766
8772
  }
7767
- console.log("");
8773
+ log("");
7768
8774
  } catch (error) {
7769
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8775
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7770
8776
  process.exit(1);
7771
8777
  }
7772
8778
  });
7773
8779
  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
8780
  try {
7775
8781
  const { recordAndSave: recordAndSave2 } = await Promise.resolve().then(() => (init_recorder(), exports_recorder));
7776
- console.log(chalk5.blue("Opening browser for recording..."));
8782
+ log(chalk5.blue("Opening browser for recording..."));
7777
8783
  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`));
8784
+ log("");
8785
+ log(chalk5.green(`Recording saved as scenario ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
8786
+ log(chalk5.dim(` ${recording.actions.length} actions recorded in ${(recording.duration / 1000).toFixed(0)}s`));
8787
+ log(chalk5.dim(` ${scenario.steps.length} steps generated`));
8788
+ } catch (error) {
8789
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8790
+ process.exit(1);
8791
+ }
8792
+ });
8793
+ program2.command("doctor").description("Check system setup and configuration").action(async () => {
8794
+ let allPassed = true;
8795
+ const hasApiKey = Boolean(process.env["ANTHROPIC_API_KEY"]);
8796
+ if (hasApiKey) {
8797
+ log(chalk5.green("\u2713") + " ANTHROPIC_API_KEY is set");
8798
+ } else {
8799
+ log(chalk5.red("\u2717") + " ANTHROPIC_API_KEY is not set (required for AI-powered tests)");
8800
+ allPassed = false;
8801
+ }
8802
+ const dbPath = join6(process.env["HOME"] ?? "~", ".testers", "testers.db");
8803
+ try {
8804
+ const { Database: Database3 } = await import("bun:sqlite");
8805
+ const db2 = new Database3(dbPath, { create: true });
8806
+ db2.close();
8807
+ log(chalk5.green("\u2713") + ` Database accessible: ${dbPath}`);
8808
+ } catch (err) {
8809
+ log(chalk5.red("\u2717") + ` Database not accessible at ${dbPath}: ${err instanceof Error ? err.message : String(err)}`);
8810
+ allPassed = false;
8811
+ }
8812
+ try {
8813
+ const { chromium: chromium4 } = await import("playwright");
8814
+ const execPath = chromium4.executablePath();
8815
+ const { existsSync: fsExists } = await import("fs");
8816
+ if (fsExists(execPath)) {
8817
+ log(chalk5.green("\u2713") + " Playwright chromium is installed");
8818
+ } else {
8819
+ log(chalk5.red("\u2717") + ` Playwright chromium executable not found at ${execPath}. Run: testers install`);
8820
+ allPassed = false;
8821
+ }
8822
+ } catch {
8823
+ log(chalk5.red("\u2717") + " Playwright is not installed. Run: testers install");
8824
+ allPassed = false;
8825
+ }
8826
+ if (!allPassed) {
8827
+ process.exit(1);
8828
+ }
8829
+ });
8830
+ 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) => {
8831
+ try {
8832
+ const port = parseInt(opts.port, 10);
8833
+ const url = `http://localhost:${port}`;
8834
+ const serverBin = join6(resolve(process.execPath, ".."), "..", "dist", "server", "index.js");
8835
+ const { join: pathJoin, resolve: pathResolve, dirname: dirname2 } = await import("path");
8836
+ const { fileURLToPath } = await import("url");
8837
+ const serverPath = pathJoin(dirname2(fileURLToPath(import.meta.url)), "..", "server", "index.js");
8838
+ const proc = Bun.spawn(["bun", "run", serverPath], {
8839
+ env: { ...process.env, TESTERS_PORT: String(port) },
8840
+ stdout: "inherit",
8841
+ stderr: "inherit"
8842
+ });
8843
+ log(chalk5.green(`Open Testers dashboard starting at ${url}`));
8844
+ if (opts.open !== false) {
8845
+ await new Promise((r) => setTimeout(r, 1500));
8846
+ const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
8847
+ Bun.spawn([openCmd, url]);
8848
+ }
8849
+ await proc.exited;
7782
8850
  } catch (error) {
7783
- console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
8851
+ logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7784
8852
  process.exit(1);
7785
8853
  }
7786
8854
  });
8855
+ program2.hook("preAction", () => {
8856
+ const opts = program2.opts();
8857
+ QUIET = opts.quiet === true;
8858
+ NO_COLOR = opts.color === false || process.env["FORCE_COLOR"] === "0";
8859
+ if (NO_COLOR) {
8860
+ process.env["FORCE_COLOR"] = "0";
8861
+ }
8862
+ });
7787
8863
  program2.parse();