@hasna/testers 0.0.7 → 0.0.10

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