@hasna/testers 0.0.6 → 0.0.8

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
@@ -2090,6 +2090,7 @@ function scenarioFromRow(row) {
2090
2090
  requiresAuth: row.requires_auth === 1,
2091
2091
  authConfig: row.auth_config ? JSON.parse(row.auth_config) : null,
2092
2092
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
2093
+ assertions: JSON.parse(row.assertions || "[]"),
2093
2094
  version: row.version,
2094
2095
  createdAt: row.created_at,
2095
2096
  updatedAt: row.updated_at
@@ -2109,7 +2110,8 @@ function runFromRow(row) {
2109
2110
  failed: row.failed,
2110
2111
  startedAt: row.started_at,
2111
2112
  finishedAt: row.finished_at,
2112
- metadata: row.metadata ? JSON.parse(row.metadata) : null
2113
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
2114
+ isBaseline: row.is_baseline === 1
2113
2115
  };
2114
2116
  }
2115
2117
  function resultFromRow(row) {
@@ -2464,10 +2466,199 @@ var init_database = __esm(() => {
2464
2466
  CREATE INDEX IF NOT EXISTS idx_deps_scenario ON scenario_dependencies(scenario_id);
2465
2467
  CREATE INDEX IF NOT EXISTS idx_deps_depends ON scenario_dependencies(depends_on);
2466
2468
  CREATE INDEX IF NOT EXISTS idx_flows_project ON flows(project_id);
2469
+ `,
2470
+ `
2471
+ ALTER TABLE scenarios ADD COLUMN assertions TEXT DEFAULT '[]';
2472
+ `,
2473
+ `
2474
+ CREATE TABLE IF NOT EXISTS environments (
2475
+ id TEXT PRIMARY KEY,
2476
+ name TEXT NOT NULL UNIQUE,
2477
+ url TEXT NOT NULL,
2478
+ auth_preset_name TEXT,
2479
+ project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
2480
+ is_default INTEGER NOT NULL DEFAULT 0,
2481
+ metadata TEXT DEFAULT '{}',
2482
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
2483
+ );
2484
+ `,
2485
+ `
2486
+ ALTER TABLE runs ADD COLUMN is_baseline INTEGER NOT NULL DEFAULT 0;
2467
2487
  `
2468
2488
  ];
2469
2489
  });
2470
2490
 
2491
+ // src/db/scenarios.ts
2492
+ function nextShortId(projectId) {
2493
+ const db2 = getDatabase();
2494
+ if (projectId) {
2495
+ const project = db2.query("SELECT scenario_prefix, scenario_counter FROM projects WHERE id = ?").get(projectId);
2496
+ if (project) {
2497
+ const next = project.scenario_counter + 1;
2498
+ db2.query("UPDATE projects SET scenario_counter = ? WHERE id = ?").run(next, projectId);
2499
+ return `${project.scenario_prefix}-${next}`;
2500
+ }
2501
+ }
2502
+ return shortUuid();
2503
+ }
2504
+ function createScenario(input) {
2505
+ const db2 = getDatabase();
2506
+ const id = uuid();
2507
+ const short_id = nextShortId(input.projectId);
2508
+ const timestamp = now();
2509
+ db2.query(`
2510
+ INSERT INTO scenarios (id, short_id, project_id, name, description, steps, tags, priority, model, timeout_ms, target_path, requires_auth, auth_config, metadata, assertions, version, created_at, updated_at)
2511
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
2512
+ `).run(id, short_id, input.projectId ?? null, input.name, input.description, JSON.stringify(input.steps ?? []), JSON.stringify(input.tags ?? []), input.priority ?? "medium", input.model ?? null, input.timeoutMs ?? null, input.targetPath ?? null, input.requiresAuth ? 1 : 0, input.authConfig ? JSON.stringify(input.authConfig) : null, input.metadata ? JSON.stringify(input.metadata) : null, JSON.stringify(input.assertions ?? []), timestamp, timestamp);
2513
+ return getScenario(id);
2514
+ }
2515
+ function getScenario(id) {
2516
+ const db2 = getDatabase();
2517
+ let row = db2.query("SELECT * FROM scenarios WHERE id = ?").get(id);
2518
+ if (row)
2519
+ return scenarioFromRow(row);
2520
+ row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(id);
2521
+ if (row)
2522
+ return scenarioFromRow(row);
2523
+ const fullId = resolvePartialId("scenarios", id);
2524
+ if (fullId) {
2525
+ row = db2.query("SELECT * FROM scenarios WHERE id = ?").get(fullId);
2526
+ if (row)
2527
+ return scenarioFromRow(row);
2528
+ }
2529
+ return null;
2530
+ }
2531
+ function getScenarioByShortId(shortId) {
2532
+ const db2 = getDatabase();
2533
+ const row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(shortId);
2534
+ return row ? scenarioFromRow(row) : null;
2535
+ }
2536
+ function listScenarios(filter) {
2537
+ const db2 = getDatabase();
2538
+ const conditions = [];
2539
+ const params = [];
2540
+ if (filter?.projectId) {
2541
+ conditions.push("project_id = ?");
2542
+ params.push(filter.projectId);
2543
+ }
2544
+ if (filter?.tags && filter.tags.length > 0) {
2545
+ for (const tag of filter.tags) {
2546
+ conditions.push("tags LIKE ?");
2547
+ params.push(`%"${tag}"%`);
2548
+ }
2549
+ }
2550
+ if (filter?.priority) {
2551
+ conditions.push("priority = ?");
2552
+ params.push(filter.priority);
2553
+ }
2554
+ if (filter?.search) {
2555
+ conditions.push("(name LIKE ? OR description LIKE ?)");
2556
+ const term = `%${filter.search}%`;
2557
+ params.push(term, term);
2558
+ }
2559
+ let sql = "SELECT * FROM scenarios";
2560
+ if (conditions.length > 0) {
2561
+ sql += " WHERE " + conditions.join(" AND ");
2562
+ }
2563
+ sql += " ORDER BY created_at DESC";
2564
+ if (filter?.limit) {
2565
+ sql += " LIMIT ?";
2566
+ params.push(filter.limit);
2567
+ }
2568
+ if (filter?.offset) {
2569
+ sql += " OFFSET ?";
2570
+ params.push(filter.offset);
2571
+ }
2572
+ const rows = db2.query(sql).all(...params);
2573
+ return rows.map(scenarioFromRow);
2574
+ }
2575
+ function updateScenario(id, input, version) {
2576
+ const db2 = getDatabase();
2577
+ const existing = getScenario(id);
2578
+ if (!existing) {
2579
+ throw new Error(`Scenario not found: ${id}`);
2580
+ }
2581
+ if (existing.version !== version) {
2582
+ throw new VersionConflictError("scenario", existing.id);
2583
+ }
2584
+ const sets = [];
2585
+ const params = [];
2586
+ if (input.name !== undefined) {
2587
+ sets.push("name = ?");
2588
+ params.push(input.name);
2589
+ }
2590
+ if (input.description !== undefined) {
2591
+ sets.push("description = ?");
2592
+ params.push(input.description);
2593
+ }
2594
+ if (input.steps !== undefined) {
2595
+ sets.push("steps = ?");
2596
+ params.push(JSON.stringify(input.steps));
2597
+ }
2598
+ if (input.tags !== undefined) {
2599
+ sets.push("tags = ?");
2600
+ params.push(JSON.stringify(input.tags));
2601
+ }
2602
+ if (input.priority !== undefined) {
2603
+ sets.push("priority = ?");
2604
+ params.push(input.priority);
2605
+ }
2606
+ if (input.model !== undefined) {
2607
+ sets.push("model = ?");
2608
+ params.push(input.model);
2609
+ }
2610
+ if (input.timeoutMs !== undefined) {
2611
+ sets.push("timeout_ms = ?");
2612
+ params.push(input.timeoutMs);
2613
+ }
2614
+ if (input.targetPath !== undefined) {
2615
+ sets.push("target_path = ?");
2616
+ params.push(input.targetPath);
2617
+ }
2618
+ if (input.requiresAuth !== undefined) {
2619
+ sets.push("requires_auth = ?");
2620
+ params.push(input.requiresAuth ? 1 : 0);
2621
+ }
2622
+ if (input.authConfig !== undefined) {
2623
+ sets.push("auth_config = ?");
2624
+ params.push(JSON.stringify(input.authConfig));
2625
+ }
2626
+ if (input.metadata !== undefined) {
2627
+ sets.push("metadata = ?");
2628
+ params.push(JSON.stringify(input.metadata));
2629
+ }
2630
+ if (input.assertions !== undefined) {
2631
+ sets.push("assertions = ?");
2632
+ params.push(JSON.stringify(input.assertions));
2633
+ }
2634
+ if (sets.length === 0) {
2635
+ return existing;
2636
+ }
2637
+ sets.push("version = ?");
2638
+ params.push(version + 1);
2639
+ sets.push("updated_at = ?");
2640
+ params.push(now());
2641
+ params.push(existing.id);
2642
+ params.push(version);
2643
+ const result = db2.query(`UPDATE scenarios SET ${sets.join(", ")} WHERE id = ? AND version = ?`).run(...params);
2644
+ if (result.changes === 0) {
2645
+ throw new VersionConflictError("scenario", existing.id);
2646
+ }
2647
+ return getScenario(existing.id);
2648
+ }
2649
+ function deleteScenario(id) {
2650
+ const db2 = getDatabase();
2651
+ const scenario = getScenario(id);
2652
+ if (!scenario)
2653
+ return false;
2654
+ const result = db2.query("DELETE FROM scenarios WHERE id = ?").run(scenario.id);
2655
+ return result.changes > 0;
2656
+ }
2657
+ var init_scenarios = __esm(() => {
2658
+ init_types();
2659
+ init_database();
2660
+ });
2661
+
2471
2662
  // src/db/runs.ts
2472
2663
  var exports_runs = {};
2473
2664
  __export(exports_runs, {
@@ -2580,6 +2771,10 @@ function updateRun(id, updates) {
2580
2771
  sets.push("metadata = ?");
2581
2772
  params.push(updates.metadata);
2582
2773
  }
2774
+ if (updates.is_baseline !== undefined) {
2775
+ sets.push("is_baseline = ?");
2776
+ params.push(updates.is_baseline);
2777
+ }
2583
2778
  if (sets.length === 0) {
2584
2779
  return existing;
2585
2780
  }
@@ -2600,6 +2795,168 @@ var init_runs = __esm(() => {
2600
2795
  init_database();
2601
2796
  });
2602
2797
 
2798
+ // src/lib/browser-lightpanda.ts
2799
+ var exports_browser_lightpanda = {};
2800
+ __export(exports_browser_lightpanda, {
2801
+ startLightpandaServer: () => startLightpandaServer,
2802
+ launchLightpanda: () => launchLightpanda,
2803
+ isLightpandaAvailable: () => isLightpandaAvailable,
2804
+ installLightpanda: () => installLightpanda,
2805
+ getLightpandaPage: () => getLightpandaPage,
2806
+ closeLightpanda: () => closeLightpanda
2807
+ });
2808
+ import { chromium } from "playwright";
2809
+ import { spawn } from "child_process";
2810
+ function isLightpandaAvailable() {
2811
+ try {
2812
+ const possiblePaths = [
2813
+ `${process.env["HOME"]}/.cache/lightpanda-node/lightpanda`,
2814
+ process.env["LIGHTPANDA_EXECUTABLE_PATH"]
2815
+ ];
2816
+ for (const p of possiblePaths) {
2817
+ if (p) {
2818
+ try {
2819
+ const { existsSync: existsSync2 } = __require("fs");
2820
+ if (existsSync2(p))
2821
+ return true;
2822
+ } catch {
2823
+ continue;
2824
+ }
2825
+ }
2826
+ }
2827
+ const { execSync } = __require("child_process");
2828
+ execSync("lightpanda --version", { stdio: "ignore", timeout: 5000 });
2829
+ return true;
2830
+ } catch {
2831
+ return false;
2832
+ }
2833
+ }
2834
+ function findLightpandaBinary() {
2835
+ const envPath = process.env["LIGHTPANDA_EXECUTABLE_PATH"];
2836
+ if (envPath)
2837
+ return envPath;
2838
+ const cachePath = `${process.env["HOME"]}/.cache/lightpanda-node/lightpanda`;
2839
+ try {
2840
+ const { existsSync: existsSync2 } = __require("fs");
2841
+ if (existsSync2(cachePath))
2842
+ return cachePath;
2843
+ } catch {}
2844
+ return "lightpanda";
2845
+ }
2846
+ async function startLightpandaServer(port) {
2847
+ const binary = findLightpandaBinary();
2848
+ const cdpPort = port ?? 9222 + Math.floor(Math.random() * 1000);
2849
+ return new Promise((resolve, reject) => {
2850
+ const proc = spawn(binary, ["serve", "--port", String(cdpPort)], {
2851
+ stdio: ["ignore", "pipe", "pipe"]
2852
+ });
2853
+ let resolved = false;
2854
+ const timeout = setTimeout(() => {
2855
+ if (!resolved) {
2856
+ resolved = true;
2857
+ resolve({
2858
+ process: proc,
2859
+ wsEndpoint: `ws://127.0.0.1:${cdpPort}`
2860
+ });
2861
+ }
2862
+ }, 5000);
2863
+ proc.stdout?.on("data", (data) => {
2864
+ const output = data.toString();
2865
+ if (output.includes("127.0.0.1") || output.includes("listening") || output.includes("DevTools")) {
2866
+ if (!resolved) {
2867
+ resolved = true;
2868
+ clearTimeout(timeout);
2869
+ resolve({
2870
+ process: proc,
2871
+ wsEndpoint: `ws://127.0.0.1:${cdpPort}`
2872
+ });
2873
+ }
2874
+ }
2875
+ });
2876
+ proc.stderr?.on("data", (data) => {
2877
+ const output = data.toString();
2878
+ if (output.includes("127.0.0.1") || output.includes("listening")) {
2879
+ if (!resolved) {
2880
+ resolved = true;
2881
+ clearTimeout(timeout);
2882
+ resolve({
2883
+ process: proc,
2884
+ wsEndpoint: `ws://127.0.0.1:${cdpPort}`
2885
+ });
2886
+ }
2887
+ }
2888
+ });
2889
+ proc.on("error", (err) => {
2890
+ clearTimeout(timeout);
2891
+ if (!resolved) {
2892
+ resolved = true;
2893
+ reject(new BrowserError(`Failed to start Lightpanda: ${err.message}. ` + `Install it with: bun install @lightpanda/browser`));
2894
+ }
2895
+ });
2896
+ proc.on("exit", (code) => {
2897
+ if (!resolved) {
2898
+ resolved = true;
2899
+ clearTimeout(timeout);
2900
+ reject(new BrowserError(`Lightpanda exited with code ${code}. ` + `Install it with: bun install @lightpanda/browser`));
2901
+ }
2902
+ });
2903
+ });
2904
+ }
2905
+ async function launchLightpanda(_options) {
2906
+ try {
2907
+ const { process: proc, wsEndpoint } = await startLightpandaServer();
2908
+ lightpandaProcess = proc;
2909
+ const browser = await chromium.connectOverCDP(wsEndpoint);
2910
+ return browser;
2911
+ } catch (error) {
2912
+ const message = error instanceof Error ? error.message : String(error);
2913
+ throw new BrowserError(`Failed to launch Lightpanda: ${message}`);
2914
+ }
2915
+ }
2916
+ async function getLightpandaPage(browser, options) {
2917
+ try {
2918
+ const contexts = browser.contexts();
2919
+ const context = contexts.length > 0 ? contexts[0] : await browser.newContext({
2920
+ viewport: options?.viewport ?? { width: 1280, height: 720 },
2921
+ userAgent: options?.userAgent,
2922
+ locale: options?.locale
2923
+ });
2924
+ const page = await context.newPage();
2925
+ return page;
2926
+ } catch (error) {
2927
+ const message = error instanceof Error ? error.message : String(error);
2928
+ throw new BrowserError(`Failed to create Lightpanda page: ${message}`);
2929
+ }
2930
+ }
2931
+ async function closeLightpanda(browser) {
2932
+ try {
2933
+ await browser.close();
2934
+ } catch {}
2935
+ if (lightpandaProcess) {
2936
+ try {
2937
+ lightpandaProcess.kill("SIGTERM");
2938
+ lightpandaProcess = null;
2939
+ } catch {}
2940
+ }
2941
+ }
2942
+ async function installLightpanda() {
2943
+ const { execSync } = __require("child_process");
2944
+ try {
2945
+ execSync("bun install @lightpanda/browser", {
2946
+ stdio: "inherit",
2947
+ cwd: process.env["HOME"]
2948
+ });
2949
+ } catch (error) {
2950
+ const message = error instanceof Error ? error.message : String(error);
2951
+ throw new BrowserError(`Failed to install Lightpanda: ${message}
2952
+ ` + `Try manually: bun install @lightpanda/browser`);
2953
+ }
2954
+ }
2955
+ var lightpandaProcess = null;
2956
+ var init_browser_lightpanda = __esm(() => {
2957
+ init_types();
2958
+ });
2959
+
2603
2960
  // src/db/flows.ts
2604
2961
  var exports_flows = {};
2605
2962
  __export(exports_flows, {
@@ -2751,194 +3108,287 @@ var init_flows = __esm(() => {
2751
3108
  init_types();
2752
3109
  });
2753
3110
 
2754
- // node_modules/commander/esm.mjs
2755
- var import__ = __toESM(require_commander(), 1);
2756
- var {
2757
- program,
2758
- createCommand,
2759
- createArgument,
2760
- createOption,
2761
- CommanderError,
2762
- InvalidArgumentError,
2763
- InvalidOptionArgumentError,
2764
- Command,
2765
- Argument,
2766
- Option,
2767
- Help
2768
- } = import__.default;
2769
-
2770
- // src/cli/index.tsx
2771
- import chalk4 from "chalk";
2772
- import { readFileSync as readFileSync4, readdirSync, writeFileSync as writeFileSync3 } from "fs";
2773
- import { join as join6, resolve } from "path";
2774
-
2775
- // src/db/scenarios.ts
2776
- init_types();
2777
- init_database();
2778
- function nextShortId(projectId) {
2779
- const db2 = getDatabase();
2780
- if (projectId) {
2781
- const project = db2.query("SELECT scenario_prefix, scenario_counter FROM projects WHERE id = ?").get(projectId);
2782
- if (project) {
2783
- const next = project.scenario_counter + 1;
2784
- db2.query("UPDATE projects SET scenario_counter = ? WHERE id = ?").run(next, projectId);
2785
- return `${project.scenario_prefix}-${next}`;
3111
+ // src/lib/openapi-import.ts
3112
+ var exports_openapi_import = {};
3113
+ __export(exports_openapi_import, {
3114
+ parseOpenAPISpec: () => parseOpenAPISpec,
3115
+ importFromOpenAPI: () => importFromOpenAPI
3116
+ });
3117
+ import { readFileSync as readFileSync5 } from "fs";
3118
+ function parseSpec(content) {
3119
+ try {
3120
+ return JSON.parse(content);
3121
+ } catch {
3122
+ throw new Error("Only JSON specs are supported. Convert YAML to JSON first: `cat spec.yaml | python -c 'import sys,yaml,json; json.dump(yaml.safe_load(sys.stdin),sys.stdout)' > spec.json`");
3123
+ }
3124
+ }
3125
+ function methodPriority(method) {
3126
+ switch (method.toUpperCase()) {
3127
+ case "GET":
3128
+ return "medium";
3129
+ case "POST":
3130
+ return "high";
3131
+ case "PUT":
3132
+ return "high";
3133
+ case "DELETE":
3134
+ return "critical";
3135
+ case "PATCH":
3136
+ return "medium";
3137
+ default:
3138
+ return "low";
3139
+ }
3140
+ }
3141
+ function parseOpenAPISpec(filePathOrUrl) {
3142
+ let content;
3143
+ if (filePathOrUrl.startsWith("http")) {
3144
+ throw new Error("URL fetching not supported yet. Download the spec file first.");
3145
+ }
3146
+ content = readFileSync5(filePathOrUrl, "utf-8");
3147
+ const spec = parseSpec(content);
3148
+ const isOpenAPI3 = !!spec.openapi;
3149
+ const isSwagger2 = !!spec.swagger;
3150
+ if (!isOpenAPI3 && !isSwagger2) {
3151
+ throw new Error("Not a valid OpenAPI 3.x or Swagger 2.0 spec");
3152
+ }
3153
+ const scenarios = [];
3154
+ const paths = spec.paths ?? {};
3155
+ for (const [path, methods] of Object.entries(paths)) {
3156
+ for (const [method, operation] of Object.entries(methods)) {
3157
+ if (["get", "post", "put", "delete", "patch"].indexOf(method.toLowerCase()) === -1)
3158
+ continue;
3159
+ const op = operation;
3160
+ const name = op.summary ?? op.operationId ?? `${method.toUpperCase()} ${path}`;
3161
+ const tags = op.tags ?? [];
3162
+ const requiresAuth = !!(op.security?.length ?? spec.security?.length);
3163
+ const steps = [];
3164
+ steps.push(`Navigate to the API endpoint: ${method.toUpperCase()} ${path}`);
3165
+ if (op.parameters?.length) {
3166
+ const required = op.parameters.filter((p) => p.required);
3167
+ if (required.length > 0) {
3168
+ steps.push(`Fill required parameters: ${required.map((p) => p.name).join(", ")}`);
3169
+ }
3170
+ }
3171
+ if (["post", "put", "patch"].includes(method.toLowerCase())) {
3172
+ steps.push("Fill the request body with valid test data");
3173
+ }
3174
+ steps.push("Submit the request");
3175
+ const responses = op.responses ?? {};
3176
+ const successCodes = Object.keys(responses).filter((c) => c.startsWith("2"));
3177
+ if (successCodes.length > 0) {
3178
+ steps.push(`Verify response status is ${successCodes.join(" or ")}`);
3179
+ } else {
3180
+ steps.push("Verify the response is successful");
3181
+ }
3182
+ const description = [
3183
+ op.description ?? `Test the ${method.toUpperCase()} ${path} endpoint.`,
3184
+ requiresAuth ? "This endpoint requires authentication." : ""
3185
+ ].filter(Boolean).join(" ");
3186
+ scenarios.push({
3187
+ name,
3188
+ description,
3189
+ steps,
3190
+ tags: [...tags, "api", method.toLowerCase()],
3191
+ priority: methodPriority(method),
3192
+ targetPath: path,
3193
+ requiresAuth
3194
+ });
2786
3195
  }
2787
3196
  }
2788
- return shortUuid();
2789
- }
2790
- function createScenario(input) {
2791
- const db2 = getDatabase();
2792
- const id = uuid();
2793
- const short_id = nextShortId(input.projectId);
2794
- const timestamp = now();
2795
- db2.query(`
2796
- INSERT INTO scenarios (id, short_id, project_id, name, description, steps, tags, priority, model, timeout_ms, target_path, requires_auth, auth_config, metadata, version, created_at, updated_at)
2797
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
2798
- `).run(id, short_id, input.projectId ?? null, input.name, input.description, JSON.stringify(input.steps ?? []), JSON.stringify(input.tags ?? []), input.priority ?? "medium", input.model ?? null, input.timeoutMs ?? null, input.targetPath ?? null, input.requiresAuth ? 1 : 0, input.authConfig ? JSON.stringify(input.authConfig) : null, input.metadata ? JSON.stringify(input.metadata) : null, timestamp, timestamp);
2799
- return getScenario(id);
2800
- }
2801
- function getScenario(id) {
2802
- const db2 = getDatabase();
2803
- let row = db2.query("SELECT * FROM scenarios WHERE id = ?").get(id);
2804
- if (row)
2805
- return scenarioFromRow(row);
2806
- row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(id);
2807
- if (row)
2808
- return scenarioFromRow(row);
2809
- const fullId = resolvePartialId("scenarios", id);
2810
- if (fullId) {
2811
- row = db2.query("SELECT * FROM scenarios WHERE id = ?").get(fullId);
2812
- if (row)
2813
- return scenarioFromRow(row);
2814
- }
2815
- return null;
3197
+ return scenarios;
2816
3198
  }
2817
- function getScenarioByShortId(shortId) {
2818
- const db2 = getDatabase();
2819
- const row = db2.query("SELECT * FROM scenarios WHERE short_id = ?").get(shortId);
2820
- return row ? scenarioFromRow(row) : null;
3199
+ function importFromOpenAPI(filePathOrUrl, projectId) {
3200
+ const inputs = parseOpenAPISpec(filePathOrUrl);
3201
+ const scenarios = inputs.map((input) => createScenario({ ...input, projectId }));
3202
+ return { imported: scenarios.length, scenarios };
2821
3203
  }
2822
- function listScenarios(filter) {
2823
- const db2 = getDatabase();
2824
- const conditions = [];
2825
- const params = [];
2826
- if (filter?.projectId) {
2827
- conditions.push("project_id = ?");
2828
- params.push(filter.projectId);
2829
- }
2830
- if (filter?.tags && filter.tags.length > 0) {
2831
- for (const tag of filter.tags) {
2832
- conditions.push("tags LIKE ?");
2833
- params.push(`%"${tag}"%`);
3204
+ var init_openapi_import = __esm(() => {
3205
+ init_scenarios();
3206
+ });
3207
+
3208
+ // src/lib/recorder.ts
3209
+ var exports_recorder = {};
3210
+ __export(exports_recorder, {
3211
+ recordSession: () => recordSession,
3212
+ recordAndSave: () => recordAndSave,
3213
+ actionsToScenarioInput: () => actionsToScenarioInput
3214
+ });
3215
+ import { chromium as chromium3 } from "playwright";
3216
+ async function recordSession(url, options) {
3217
+ const browser = await chromium3.launch({ headless: false });
3218
+ const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
3219
+ const page = await context.newPage();
3220
+ const actions = [];
3221
+ const startTime = Date.now();
3222
+ const timeout = options?.timeout ?? 300000;
3223
+ page.on("framenavigated", (frame) => {
3224
+ if (frame === page.mainFrame()) {
3225
+ actions.push({ type: "navigate", url: frame.url(), timestamp: Date.now() - startTime });
2834
3226
  }
2835
- }
2836
- if (filter?.priority) {
2837
- conditions.push("priority = ?");
2838
- params.push(filter.priority);
2839
- }
2840
- if (filter?.search) {
2841
- conditions.push("(name LIKE ? OR description LIKE ?)");
2842
- const term = `%${filter.search}%`;
2843
- params.push(term, term);
2844
- }
2845
- let sql = "SELECT * FROM scenarios";
2846
- if (conditions.length > 0) {
2847
- sql += " WHERE " + conditions.join(" AND ");
2848
- }
2849
- sql += " ORDER BY created_at DESC";
2850
- if (filter?.limit) {
2851
- sql += " LIMIT ?";
2852
- params.push(filter.limit);
2853
- }
2854
- if (filter?.offset) {
2855
- sql += " OFFSET ?";
2856
- params.push(filter.offset);
2857
- }
2858
- const rows = db2.query(sql).all(...params);
2859
- return rows.map(scenarioFromRow);
3227
+ });
3228
+ await page.addInitScript(() => {
3229
+ document.addEventListener("click", (e) => {
3230
+ const target = e.target;
3231
+ const selector = buildSelector(target);
3232
+ window.postMessage({ __testers_action: "click", selector }, "*");
3233
+ }, true);
3234
+ document.addEventListener("input", (e) => {
3235
+ const target = e.target;
3236
+ const selector = buildSelector(target);
3237
+ window.postMessage({ __testers_action: "fill", selector, value: target.value }, "*");
3238
+ }, true);
3239
+ document.addEventListener("change", (e) => {
3240
+ const target = e.target;
3241
+ if (target.tagName === "SELECT") {
3242
+ const selector = buildSelector(target);
3243
+ window.postMessage({ __testers_action: "select", selector, value: target.value }, "*");
3244
+ }
3245
+ }, true);
3246
+ document.addEventListener("keydown", (e) => {
3247
+ if (["Enter", "Tab", "Escape"].includes(e.key)) {
3248
+ window.postMessage({ __testers_action: "press", key: e.key }, "*");
3249
+ }
3250
+ }, true);
3251
+ function buildSelector(el) {
3252
+ if (el.id)
3253
+ return `#${el.id}`;
3254
+ if (el.getAttribute("data-testid"))
3255
+ return `[data-testid="${el.getAttribute("data-testid")}"]`;
3256
+ if (el.getAttribute("name"))
3257
+ return `${el.tagName.toLowerCase()}[name="${el.getAttribute("name")}"]`;
3258
+ if (el.getAttribute("aria-label"))
3259
+ return `[aria-label="${el.getAttribute("aria-label")}"]`;
3260
+ if (el.className && typeof el.className === "string") {
3261
+ const classes = el.className.trim().split(/\s+/).slice(0, 2).join(".");
3262
+ if (classes)
3263
+ return `${el.tagName.toLowerCase()}.${classes}`;
3264
+ }
3265
+ const text = el.textContent?.trim().slice(0, 30);
3266
+ if (text)
3267
+ return `text="${text}"`;
3268
+ return el.tagName.toLowerCase();
3269
+ }
3270
+ });
3271
+ const pollInterval = setInterval(async () => {
3272
+ try {
3273
+ const newActions = await page.evaluate(() => {
3274
+ const collected = window.__testers_collected ?? [];
3275
+ window.__testers_collected = [];
3276
+ return collected;
3277
+ });
3278
+ for (const a of newActions) {
3279
+ actions.push({
3280
+ type: a["type"],
3281
+ selector: a["selector"],
3282
+ value: a["value"],
3283
+ key: a["key"],
3284
+ timestamp: Date.now() - startTime
3285
+ });
3286
+ }
3287
+ } catch {}
3288
+ }, 500);
3289
+ await page.exposeFunction("__testersRecord", (action) => {
3290
+ actions.push({ ...action, timestamp: Date.now() - startTime });
3291
+ });
3292
+ await page.addInitScript(() => {
3293
+ window.addEventListener("message", (e) => {
3294
+ if (e.data?.__testers_action) {
3295
+ const { __testers_action, ...rest } = e.data;
3296
+ window.__testersRecord({ type: __testers_action, ...rest });
3297
+ }
3298
+ });
3299
+ });
3300
+ await page.goto(url);
3301
+ actions.push({ type: "navigate", url, timestamp: 0 });
3302
+ console.log(`
3303
+ Recording started. Interact with the browser.`);
3304
+ console.log(` Close the browser window or wait ${timeout / 1000}s to stop.
3305
+ `);
3306
+ await Promise.race([
3307
+ page.waitForEvent("close").catch(() => {}),
3308
+ context.waitForEvent("close").catch(() => {}),
3309
+ new Promise((resolve) => setTimeout(resolve, timeout))
3310
+ ]);
3311
+ clearInterval(pollInterval);
3312
+ try {
3313
+ await browser.close();
3314
+ } catch {}
3315
+ return {
3316
+ actions,
3317
+ url,
3318
+ duration: Date.now() - startTime
3319
+ };
2860
3320
  }
2861
- function updateScenario(id, input, version) {
2862
- const db2 = getDatabase();
2863
- const existing = getScenario(id);
2864
- if (!existing) {
2865
- throw new Error(`Scenario not found: ${id}`);
2866
- }
2867
- if (existing.version !== version) {
2868
- throw new VersionConflictError("scenario", existing.id);
2869
- }
2870
- const sets = [];
2871
- const params = [];
2872
- if (input.name !== undefined) {
2873
- sets.push("name = ?");
2874
- params.push(input.name);
2875
- }
2876
- if (input.description !== undefined) {
2877
- sets.push("description = ?");
2878
- params.push(input.description);
2879
- }
2880
- if (input.steps !== undefined) {
2881
- sets.push("steps = ?");
2882
- params.push(JSON.stringify(input.steps));
2883
- }
2884
- if (input.tags !== undefined) {
2885
- sets.push("tags = ?");
2886
- params.push(JSON.stringify(input.tags));
2887
- }
2888
- if (input.priority !== undefined) {
2889
- sets.push("priority = ?");
2890
- params.push(input.priority);
2891
- }
2892
- if (input.model !== undefined) {
2893
- sets.push("model = ?");
2894
- params.push(input.model);
2895
- }
2896
- if (input.timeoutMs !== undefined) {
2897
- sets.push("timeout_ms = ?");
2898
- params.push(input.timeoutMs);
2899
- }
2900
- if (input.targetPath !== undefined) {
2901
- sets.push("target_path = ?");
2902
- params.push(input.targetPath);
2903
- }
2904
- if (input.requiresAuth !== undefined) {
2905
- sets.push("requires_auth = ?");
2906
- params.push(input.requiresAuth ? 1 : 0);
2907
- }
2908
- if (input.authConfig !== undefined) {
2909
- sets.push("auth_config = ?");
2910
- params.push(JSON.stringify(input.authConfig));
2911
- }
2912
- if (input.metadata !== undefined) {
2913
- sets.push("metadata = ?");
2914
- params.push(JSON.stringify(input.metadata));
2915
- }
2916
- if (sets.length === 0) {
2917
- return existing;
2918
- }
2919
- sets.push("version = ?");
2920
- params.push(version + 1);
2921
- sets.push("updated_at = ?");
2922
- params.push(now());
2923
- params.push(existing.id);
2924
- params.push(version);
2925
- const result = db2.query(`UPDATE scenarios SET ${sets.join(", ")} WHERE id = ? AND version = ?`).run(...params);
2926
- if (result.changes === 0) {
2927
- throw new VersionConflictError("scenario", existing.id);
3321
+ function actionsToScenarioInput(recording, name, projectId) {
3322
+ const steps = [];
3323
+ const seenFills = new Map;
3324
+ for (const action of recording.actions) {
3325
+ switch (action.type) {
3326
+ case "navigate":
3327
+ if (action.url)
3328
+ steps.push(`Navigate to ${action.url}`);
3329
+ break;
3330
+ case "click":
3331
+ if (action.selector)
3332
+ steps.push(`Click ${action.selector}`);
3333
+ break;
3334
+ case "fill":
3335
+ if (action.selector && action.value) {
3336
+ seenFills.set(action.selector, action.value);
3337
+ }
3338
+ break;
3339
+ case "select":
3340
+ if (action.selector && action.value)
3341
+ steps.push(`Select "${action.value}" in ${action.selector}`);
3342
+ break;
3343
+ case "press":
3344
+ if (action.key)
3345
+ steps.push(`Press ${action.key}`);
3346
+ break;
3347
+ }
2928
3348
  }
2929
- return getScenario(existing.id);
3349
+ for (const [selector, value] of seenFills) {
3350
+ steps.push(`Fill ${selector} with "${value}"`);
3351
+ }
3352
+ return {
3353
+ name,
3354
+ description: `Recorded session on ${recording.url} (${(recording.duration / 1000).toFixed(0)}s, ${recording.actions.length} actions)`,
3355
+ steps,
3356
+ tags: ["recorded"],
3357
+ projectId
3358
+ };
2930
3359
  }
2931
- function deleteScenario(id) {
2932
- const db2 = getDatabase();
2933
- const scenario = getScenario(id);
2934
- if (!scenario)
2935
- return false;
2936
- const result = db2.query("DELETE FROM scenarios WHERE id = ?").run(scenario.id);
2937
- return result.changes > 0;
3360
+ async function recordAndSave(url, name, projectId) {
3361
+ const recording = await recordSession(url);
3362
+ const input = actionsToScenarioInput(recording, name, projectId);
3363
+ const scenario = createScenario(input);
3364
+ return { recording, scenario };
2938
3365
  }
3366
+ var init_recorder = __esm(() => {
3367
+ init_scenarios();
3368
+ });
3369
+
3370
+ // node_modules/commander/esm.mjs
3371
+ var import__ = __toESM(require_commander(), 1);
3372
+ var {
3373
+ program,
3374
+ createCommand,
3375
+ createArgument,
3376
+ createOption,
3377
+ CommanderError,
3378
+ InvalidArgumentError,
3379
+ InvalidOptionArgumentError,
3380
+ Command,
3381
+ Argument,
3382
+ Option,
3383
+ Help
3384
+ } = import__.default;
2939
3385
 
2940
3386
  // src/cli/index.tsx
3387
+ init_scenarios();
2941
3388
  init_runs();
3389
+ import chalk5 from "chalk";
3390
+ import { readFileSync as readFileSync6, readdirSync, writeFileSync as writeFileSync3 } from "fs";
3391
+ import { join as join6, resolve } from "path";
2942
3392
 
2943
3393
  // src/db/results.ts
2944
3394
  init_types();
@@ -3044,17 +3494,26 @@ function listScreenshots(resultId) {
3044
3494
 
3045
3495
  // src/lib/runner.ts
3046
3496
  init_runs();
3497
+ init_scenarios();
3047
3498
 
3048
3499
  // src/lib/browser.ts
3049
3500
  init_types();
3050
- import { chromium } from "playwright";
3501
+ import { chromium as chromium2 } from "playwright";
3051
3502
  import { execSync } from "child_process";
3052
3503
  var DEFAULT_VIEWPORT = { width: 1280, height: 720 };
3053
3504
  async function launchBrowser(options) {
3505
+ const engine = options?.engine ?? process.env["TESTERS_BROWSER_ENGINE"] ?? "playwright";
3506
+ if (engine === "lightpanda") {
3507
+ const { launchLightpanda: launchLightpanda2, isLightpandaAvailable: isLightpandaAvailable2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
3508
+ if (!isLightpandaAvailable2()) {
3509
+ throw new BrowserError("Lightpanda not installed. Run: testers install-browser --engine lightpanda");
3510
+ }
3511
+ return launchLightpanda2({ viewport: options?.viewport });
3512
+ }
3054
3513
  const headless = options?.headless ?? true;
3055
3514
  const viewport = options?.viewport ?? DEFAULT_VIEWPORT;
3056
3515
  try {
3057
- const browser = await chromium.launch({
3516
+ const browser = await chromium2.launch({
3058
3517
  headless,
3059
3518
  args: [
3060
3519
  `--window-size=${viewport.width},${viewport.height}`
@@ -3067,6 +3526,11 @@ async function launchBrowser(options) {
3067
3526
  }
3068
3527
  }
3069
3528
  async function getPage(browser, options) {
3529
+ const engine = options?.engine ?? "playwright";
3530
+ if (engine === "lightpanda") {
3531
+ const { getLightpandaPage: getLightpandaPage2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
3532
+ return getLightpandaPage2(browser, options);
3533
+ }
3070
3534
  const viewport = options?.viewport ?? DEFAULT_VIEWPORT;
3071
3535
  try {
3072
3536
  const context = await browser.newContext({
@@ -3081,7 +3545,11 @@ async function getPage(browser, options) {
3081
3545
  throw new BrowserError(`Failed to create page: ${message}`);
3082
3546
  }
3083
3547
  }
3084
- async function closeBrowser(browser) {
3548
+ async function closeBrowser(browser, engine) {
3549
+ if (engine === "lightpanda") {
3550
+ const { closeLightpanda: closeLightpanda2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
3551
+ return closeLightpanda2(browser);
3552
+ }
3085
3553
  try {
3086
3554
  await browser.close();
3087
3555
  } catch (error) {
@@ -3089,7 +3557,11 @@ async function closeBrowser(browser) {
3089
3557
  throw new BrowserError(`Failed to close browser: ${message}`);
3090
3558
  }
3091
3559
  }
3092
- async function installBrowser() {
3560
+ async function installBrowser(engine) {
3561
+ if (engine === "lightpanda") {
3562
+ const { installLightpanda: installLightpanda2 } = await Promise.resolve().then(() => (init_browser_lightpanda(), exports_browser_lightpanda));
3563
+ return installLightpanda2();
3564
+ }
3093
3565
  try {
3094
3566
  execSync("bunx playwright install chromium", {
3095
3567
  stdio: "inherit"
@@ -4025,6 +4497,104 @@ function loadConfig() {
4025
4497
  return config;
4026
4498
  }
4027
4499
 
4500
+ // src/lib/webhooks.ts
4501
+ init_database();
4502
+ function fromRow(row) {
4503
+ return {
4504
+ id: row.id,
4505
+ url: row.url,
4506
+ events: JSON.parse(row.events),
4507
+ projectId: row.project_id,
4508
+ secret: row.secret,
4509
+ active: row.active === 1,
4510
+ createdAt: row.created_at
4511
+ };
4512
+ }
4513
+ function listWebhooks(projectId) {
4514
+ const db2 = getDatabase();
4515
+ let query = "SELECT * FROM webhooks WHERE active = 1";
4516
+ const params = [];
4517
+ if (projectId) {
4518
+ query += " AND (project_id = ? OR project_id IS NULL)";
4519
+ params.push(projectId);
4520
+ }
4521
+ query += " ORDER BY created_at DESC";
4522
+ const rows = db2.query(query).all(...params);
4523
+ return rows.map(fromRow);
4524
+ }
4525
+ function signPayload(body, secret) {
4526
+ const encoder = new TextEncoder;
4527
+ const key = encoder.encode(secret);
4528
+ const data = encoder.encode(body);
4529
+ let hash = 0;
4530
+ for (let i = 0;i < data.length; i++) {
4531
+ hash = (hash << 5) - hash + data[i] + (key[i % key.length] ?? 0) | 0;
4532
+ }
4533
+ return `sha256=${Math.abs(hash).toString(16).padStart(16, "0")}`;
4534
+ }
4535
+ function formatSlackPayload(payload) {
4536
+ const status = payload.run.status === "passed" ? ":white_check_mark:" : ":x:";
4537
+ const color = payload.run.status === "passed" ? "#22c55e" : "#ef4444";
4538
+ return {
4539
+ attachments: [
4540
+ {
4541
+ color,
4542
+ blocks: [
4543
+ {
4544
+ type: "section",
4545
+ text: {
4546
+ type: "mrkdwn",
4547
+ text: `${status} *Test Run ${payload.run.status.toUpperCase()}*
4548
+ ` + `URL: ${payload.run.url}
4549
+ ` + `Results: ${payload.run.passed}/${payload.run.total} passed` + (payload.run.failed > 0 ? ` (${payload.run.failed} failed)` : "") + (payload.schedule ? `
4550
+ Schedule: ${payload.schedule.name}` : "")
4551
+ }
4552
+ }
4553
+ ]
4554
+ }
4555
+ ]
4556
+ };
4557
+ }
4558
+ async function dispatchWebhooks(event, run, schedule) {
4559
+ const webhooks = listWebhooks(run.projectId ?? undefined);
4560
+ const payload = {
4561
+ event,
4562
+ run: {
4563
+ id: run.id,
4564
+ url: run.url,
4565
+ status: run.status,
4566
+ passed: run.passed,
4567
+ failed: run.failed,
4568
+ total: run.total
4569
+ },
4570
+ schedule,
4571
+ timestamp: new Date().toISOString()
4572
+ };
4573
+ for (const webhook of webhooks) {
4574
+ if (!webhook.events.includes(event) && !webhook.events.includes("*"))
4575
+ continue;
4576
+ const isSlack = webhook.url.includes("hooks.slack.com");
4577
+ const body = isSlack ? JSON.stringify(formatSlackPayload(payload)) : JSON.stringify(payload);
4578
+ const headers = {
4579
+ "Content-Type": "application/json"
4580
+ };
4581
+ if (webhook.secret) {
4582
+ headers["X-Testers-Signature"] = signPayload(body, webhook.secret);
4583
+ }
4584
+ try {
4585
+ const response = await fetch(webhook.url, {
4586
+ method: "POST",
4587
+ headers,
4588
+ body
4589
+ });
4590
+ if (!response.ok) {
4591
+ await new Promise((r) => setTimeout(r, 5000));
4592
+ await fetch(webhook.url, { method: "POST", headers, body });
4593
+ }
4594
+ } catch {}
4595
+ }
4596
+ }
4597
+
4028
4598
  // src/lib/runner.ts
4029
4599
  var eventHandler = null;
4030
4600
  function onRunEvent(handler) {
@@ -4034,6 +4604,20 @@ function emit(event) {
4034
4604
  if (eventHandler)
4035
4605
  eventHandler(event);
4036
4606
  }
4607
+ function withTimeout(promise, ms, label) {
4608
+ return new Promise((resolve, reject) => {
4609
+ const timer = setTimeout(() => {
4610
+ reject(new Error(`Scenario timeout after ${ms}ms: ${label}`));
4611
+ }, ms);
4612
+ promise.then((val) => {
4613
+ clearTimeout(timer);
4614
+ resolve(val);
4615
+ }, (err) => {
4616
+ clearTimeout(timer);
4617
+ reject(err);
4618
+ });
4619
+ });
4620
+ }
4037
4621
  async function runSingleScenario(scenario, runId, options) {
4038
4622
  const config = loadConfig();
4039
4623
  const model = resolveModel(options.model ?? scenario.model ?? config.defaultModel);
@@ -4051,13 +4635,14 @@ async function runSingleScenario(scenario, runId, options) {
4051
4635
  let browser = null;
4052
4636
  let page = null;
4053
4637
  try {
4054
- browser = await launchBrowser({ headless: !(options.headed ?? false) });
4638
+ browser = await launchBrowser({ headless: !(options.headed ?? false), engine: options.engine });
4055
4639
  page = await getPage(browser, {
4056
4640
  viewport: config.browser.viewport
4057
4641
  });
4058
4642
  const targetUrl = scenario.targetPath ? `${options.url.replace(/\/$/, "")}${scenario.targetPath}` : options.url;
4059
- await page.goto(targetUrl, { timeout: options.timeout ?? config.browser.timeout });
4060
- const agentResult = await runAgentLoop({
4643
+ const scenarioTimeout = scenario.timeoutMs ?? options.timeout ?? config.browser.timeout ?? 60000;
4644
+ await page.goto(targetUrl, { timeout: Math.min(scenarioTimeout, 30000) });
4645
+ const agentResult = await withTimeout(runAgentLoop({
4061
4646
  client,
4062
4647
  page,
4063
4648
  scenario,
@@ -4078,7 +4663,7 @@ async function runSingleScenario(scenario, runId, options) {
4078
4663
  stepNumber: stepEvent.stepNumber
4079
4664
  });
4080
4665
  }
4081
- });
4666
+ }), scenarioTimeout, scenario.name);
4082
4667
  for (const ss of agentResult.screenshots) {
4083
4668
  createScreenshot({
4084
4669
  resultId: result.id,
@@ -4115,7 +4700,7 @@ async function runSingleScenario(scenario, runId, options) {
4115
4700
  return updatedResult;
4116
4701
  } finally {
4117
4702
  if (browser)
4118
- await closeBrowser(browser);
4703
+ await closeBrowser(browser, options.engine);
4119
4704
  }
4120
4705
  }
4121
4706
  async function runBatch(scenarios, options) {
@@ -4210,6 +4795,8 @@ async function runBatch(scenarios, options) {
4210
4795
  finished_at: new Date().toISOString()
4211
4796
  });
4212
4797
  emit({ type: "run:complete", runId: run.id });
4798
+ const eventType = finalRun.status === "failed" ? "failed" : "completed";
4799
+ dispatchWebhooks(eventType, finalRun).catch(() => {});
4213
4800
  return { run: finalRun, results };
4214
4801
  }
4215
4802
  async function runByFilter(options) {
@@ -4295,6 +4882,9 @@ function startRunAsync(options) {
4295
4882
  finished_at: new Date().toISOString()
4296
4883
  });
4297
4884
  emit({ type: "run:complete", runId: run.id });
4885
+ const asyncRun = getRun(run.id);
4886
+ if (asyncRun)
4887
+ dispatchWebhooks(asyncRun.status === "failed" ? "failed" : "completed", asyncRun).catch(() => {});
4298
4888
  } catch (error) {
4299
4889
  const errorMsg = error instanceof Error ? error.message : String(error);
4300
4890
  updateRun(run.id, {
@@ -4302,6 +4892,9 @@ function startRunAsync(options) {
4302
4892
  finished_at: new Date().toISOString()
4303
4893
  });
4304
4894
  emit({ type: "run:complete", runId: run.id, error: errorMsg });
4895
+ const failedRun = getRun(run.id);
4896
+ if (failedRun)
4897
+ dispatchWebhooks("failed", failedRun).catch(() => {});
4305
4898
  }
4306
4899
  })();
4307
4900
  return { runId: run.id, scenarioCount: scenarios.length };
@@ -4318,6 +4911,7 @@ function estimateCost(model, tokens) {
4318
4911
 
4319
4912
  // src/lib/reporter.ts
4320
4913
  import chalk from "chalk";
4914
+ init_scenarios();
4321
4915
  function formatTerminal(run, results) {
4322
4916
  const lines = [];
4323
4917
  lines.push("");
@@ -4471,11 +5065,12 @@ function formatScenarioList(scenarios) {
4471
5065
  }
4472
5066
 
4473
5067
  // src/lib/todos-connector.ts
5068
+ init_scenarios();
5069
+ init_types();
4474
5070
  import { Database as Database2 } from "bun:sqlite";
4475
5071
  import { existsSync as existsSync4 } from "fs";
4476
5072
  import { join as join4 } from "path";
4477
5073
  import { homedir as homedir4 } from "os";
4478
- init_types();
4479
5074
  function resolveTodosDbPath() {
4480
5075
  const envPath = process.env["TODOS_DB_PATH"];
4481
5076
  if (envPath)
@@ -4573,6 +5168,7 @@ function importFromTodos(options = {}) {
4573
5168
  }
4574
5169
 
4575
5170
  // src/lib/init.ts
5171
+ init_scenarios();
4576
5172
  import { existsSync as existsSync5, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
4577
5173
  import { join as join5, basename } from "path";
4578
5174
  import { homedir as homedir5 } from "os";
@@ -4730,6 +5326,7 @@ function initProject(options) {
4730
5326
  }
4731
5327
 
4732
5328
  // src/lib/smoke.ts
5329
+ init_scenarios();
4733
5330
  init_runs();
4734
5331
  var SMOKE_DESCRIPTION = `You are performing an autonomous smoke test of this web application. Your job is to explore as many pages as possible and find issues. Follow these instructions:
4735
5332
 
@@ -4951,6 +5548,7 @@ function formatSmokeReport(result) {
4951
5548
  // src/lib/diff.ts
4952
5549
  init_runs();
4953
5550
  import chalk2 from "chalk";
5551
+ init_scenarios();
4954
5552
  function diffRuns(runId1, runId2) {
4955
5553
  const run1 = getRun(runId1);
4956
5554
  if (!run1) {
@@ -5096,14 +5694,145 @@ function formatDiffJSON(diff) {
5096
5694
  return JSON.stringify(diff, null, 2);
5097
5695
  }
5098
5696
 
5697
+ // src/lib/visual-diff.ts
5698
+ import { readFileSync as readFileSync3, existsSync as existsSync6 } from "fs";
5699
+ import chalk3 from "chalk";
5700
+ init_runs();
5701
+ init_scenarios();
5702
+ init_database();
5703
+ var DEFAULT_THRESHOLD = 0.1;
5704
+ function setBaseline(runId) {
5705
+ const run = getRun(runId);
5706
+ if (!run) {
5707
+ throw new Error(`Run not found: ${runId}`);
5708
+ }
5709
+ const db2 = getDatabase();
5710
+ if (run.projectId) {
5711
+ db2.query("UPDATE runs SET is_baseline = 0 WHERE project_id = ? AND is_baseline = 1").run(run.projectId);
5712
+ } else {
5713
+ db2.query("UPDATE runs SET is_baseline = 0 WHERE project_id IS NULL AND is_baseline = 1").run();
5714
+ }
5715
+ updateRun(run.id, { is_baseline: 1 });
5716
+ }
5717
+ function compareImages(image1Path, image2Path) {
5718
+ if (!existsSync6(image1Path)) {
5719
+ throw new Error(`Baseline image not found: ${image1Path}`);
5720
+ }
5721
+ if (!existsSync6(image2Path)) {
5722
+ throw new Error(`Current image not found: ${image2Path}`);
5723
+ }
5724
+ const buf1 = readFileSync3(image1Path);
5725
+ const buf2 = readFileSync3(image2Path);
5726
+ if (buf1.equals(buf2)) {
5727
+ const estimatedPixels = Math.max(1, Math.floor(buf1.length / 4));
5728
+ return { diffPercent: 0, diffPixels: 0, totalPixels: estimatedPixels };
5729
+ }
5730
+ if (buf1.length !== buf2.length) {
5731
+ const maxLen = Math.max(buf1.length, buf2.length);
5732
+ const estimatedPixels = Math.max(1, Math.floor(maxLen / 4));
5733
+ return { diffPercent: 100, diffPixels: estimatedPixels, totalPixels: estimatedPixels };
5734
+ }
5735
+ let diffBytes = 0;
5736
+ for (let i = 0;i < buf1.length; i++) {
5737
+ if (buf1[i] !== buf2[i]) {
5738
+ diffBytes++;
5739
+ }
5740
+ }
5741
+ const totalPixels = Math.max(1, Math.floor(buf1.length / 4));
5742
+ const diffPixels = Math.max(1, Math.floor(diffBytes / 4));
5743
+ const diffPercent = parseFloat((diffBytes / buf1.length * 100).toFixed(4));
5744
+ return { diffPercent, diffPixels, totalPixels };
5745
+ }
5746
+ function compareRunScreenshots(runId, baselineRunId, threshold = DEFAULT_THRESHOLD) {
5747
+ const run = getRun(runId);
5748
+ if (!run)
5749
+ throw new Error(`Run not found: ${runId}`);
5750
+ const baselineRun = getRun(baselineRunId);
5751
+ if (!baselineRun)
5752
+ throw new Error(`Baseline run not found: ${baselineRunId}`);
5753
+ const currentResults = getResultsByRun(run.id);
5754
+ const baselineResults = getResultsByRun(baselineRun.id);
5755
+ const baselineMap = new Map;
5756
+ for (const result of baselineResults) {
5757
+ const screenshots = listScreenshots(result.id);
5758
+ for (const ss of screenshots) {
5759
+ const key = `${result.scenarioId}:${ss.stepNumber}`;
5760
+ baselineMap.set(key, { path: ss.filePath, action: ss.action });
5761
+ }
5762
+ }
5763
+ const results = [];
5764
+ for (const result of currentResults) {
5765
+ const screenshots = listScreenshots(result.id);
5766
+ for (const ss of screenshots) {
5767
+ const key = `${result.scenarioId}:${ss.stepNumber}`;
5768
+ const baseline = baselineMap.get(key);
5769
+ if (!baseline)
5770
+ continue;
5771
+ if (!existsSync6(baseline.path) || !existsSync6(ss.filePath))
5772
+ continue;
5773
+ try {
5774
+ const comparison = compareImages(baseline.path, ss.filePath);
5775
+ results.push({
5776
+ scenarioId: result.scenarioId,
5777
+ stepNumber: ss.stepNumber,
5778
+ action: ss.action,
5779
+ baselinePath: baseline.path,
5780
+ currentPath: ss.filePath,
5781
+ diffPercent: comparison.diffPercent,
5782
+ isRegression: comparison.diffPercent > threshold
5783
+ });
5784
+ } catch {}
5785
+ }
5786
+ }
5787
+ return results;
5788
+ }
5789
+ function formatVisualDiffTerminal(results, threshold = DEFAULT_THRESHOLD) {
5790
+ if (results.length === 0) {
5791
+ return chalk3.dim(`
5792
+ No screenshot comparisons found.
5793
+ `);
5794
+ }
5795
+ const lines = [];
5796
+ lines.push("");
5797
+ lines.push(chalk3.bold(" Visual Regression Summary"));
5798
+ lines.push("");
5799
+ const regressions = results.filter((r) => r.diffPercent >= threshold);
5800
+ const passed = results.filter((r) => r.diffPercent < threshold);
5801
+ if (regressions.length > 0) {
5802
+ lines.push(chalk3.red.bold(` Regressions (${regressions.length}):`));
5803
+ for (const r of regressions) {
5804
+ const scenario = getScenario(r.scenarioId);
5805
+ const label = scenario ? `${scenario.shortId}: ${scenario.name}` : r.scenarioId.slice(0, 8);
5806
+ const pct = chalk3.red(`${r.diffPercent.toFixed(2)}%`);
5807
+ lines.push(` ${chalk3.red("!")} ${label} step ${r.stepNumber} (${r.action}) \u2014 ${pct} diff`);
5808
+ }
5809
+ lines.push("");
5810
+ }
5811
+ if (passed.length > 0) {
5812
+ lines.push(chalk3.green.bold(` Passed (${passed.length}):`));
5813
+ for (const r of passed) {
5814
+ const scenario = getScenario(r.scenarioId);
5815
+ const label = scenario ? `${scenario.shortId}: ${scenario.name}` : r.scenarioId.slice(0, 8);
5816
+ const pct = chalk3.green(`${r.diffPercent.toFixed(2)}%`);
5817
+ lines.push(` ${chalk3.green("\u2713")} ${label} step ${r.stepNumber} (${r.action}) \u2014 ${pct} diff`);
5818
+ }
5819
+ lines.push("");
5820
+ }
5821
+ lines.push(chalk3.bold(` Visual Summary: ${regressions.length} regressions, ${passed.length} passed (threshold: ${threshold}%)`));
5822
+ lines.push("");
5823
+ return lines.join(`
5824
+ `);
5825
+ }
5826
+
5099
5827
  // src/lib/report.ts
5100
5828
  init_runs();
5101
- import { readFileSync as readFileSync3, existsSync as existsSync6 } from "fs";
5829
+ import { readFileSync as readFileSync4, existsSync as existsSync7 } from "fs";
5830
+ init_scenarios();
5102
5831
  function imageToBase64(filePath) {
5103
- if (!filePath || !existsSync6(filePath))
5832
+ if (!filePath || !existsSync7(filePath))
5104
5833
  return "";
5105
5834
  try {
5106
- const buffer = readFileSync3(filePath);
5835
+ const buffer = readFileSync4(filePath);
5107
5836
  const base64 = buffer.toString("base64");
5108
5837
  return `data:image/png;base64,${base64}`;
5109
5838
  } catch {
@@ -5297,7 +6026,7 @@ function generateLatestReport() {
5297
6026
 
5298
6027
  // src/lib/costs.ts
5299
6028
  init_database();
5300
- import chalk3 from "chalk";
6029
+ import chalk4 from "chalk";
5301
6030
  function getDateFilter(period) {
5302
6031
  switch (period) {
5303
6032
  case "day":
@@ -5402,15 +6131,15 @@ function formatTokens(tokens) {
5402
6131
  function formatCostsTerminal(summary) {
5403
6132
  const lines = [];
5404
6133
  lines.push("");
5405
- lines.push(chalk3.bold(` Cost Summary (${summary.period})`));
6134
+ lines.push(chalk4.bold(` Cost Summary (${summary.period})`));
5406
6135
  lines.push("");
5407
- lines.push(` Total: ${chalk3.yellow(formatDollars(summary.totalCostCents))} (${formatTokens(summary.totalTokens)} tokens across ${summary.runCount} runs)`);
5408
- lines.push(` Avg/run: ${chalk3.yellow(formatDollars(summary.avgCostPerRun))}`);
5409
- lines.push(` Est/month: ${chalk3.yellow(formatDollars(summary.estimatedMonthlyCents))}`);
6136
+ lines.push(` Total: ${chalk4.yellow(formatDollars(summary.totalCostCents))} (${formatTokens(summary.totalTokens)} tokens across ${summary.runCount} runs)`);
6137
+ lines.push(` Avg/run: ${chalk4.yellow(formatDollars(summary.avgCostPerRun))}`);
6138
+ lines.push(` Est/month: ${chalk4.yellow(formatDollars(summary.estimatedMonthlyCents))}`);
5410
6139
  const modelEntries = Object.entries(summary.byModel);
5411
6140
  if (modelEntries.length > 0) {
5412
6141
  lines.push("");
5413
- lines.push(chalk3.bold(" By Model"));
6142
+ lines.push(chalk4.bold(" By Model"));
5414
6143
  lines.push(` ${"Model".padEnd(40)} ${"Cost".padEnd(12)} ${"Tokens".padEnd(12)} Runs`);
5415
6144
  lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(12)} ${"\u2500".repeat(12)} ${"\u2500".repeat(6)}`);
5416
6145
  for (const [model, data] of modelEntries) {
@@ -5419,7 +6148,7 @@ function formatCostsTerminal(summary) {
5419
6148
  }
5420
6149
  if (summary.byScenario.length > 0) {
5421
6150
  lines.push("");
5422
- lines.push(chalk3.bold(" Top Scenarios by Cost"));
6151
+ lines.push(chalk4.bold(" Top Scenarios by Cost"));
5423
6152
  lines.push(` ${"Scenario".padEnd(40)} ${"Cost".padEnd(12)} ${"Tokens".padEnd(12)} Runs`);
5424
6153
  lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(12)} ${"\u2500".repeat(12)} ${"\u2500".repeat(6)}`);
5425
6154
  for (const s of summary.byScenario) {
@@ -5588,7 +6317,7 @@ function listTemplateNames() {
5588
6317
 
5589
6318
  // src/db/auth-presets.ts
5590
6319
  init_database();
5591
- function fromRow(row) {
6320
+ function fromRow2(row) {
5592
6321
  return {
5593
6322
  id: row.id,
5594
6323
  name: row.name,
@@ -5612,12 +6341,12 @@ function createAuthPreset(input) {
5612
6341
  function getAuthPreset(name) {
5613
6342
  const db2 = getDatabase();
5614
6343
  const row = db2.query("SELECT * FROM auth_presets WHERE name = ?").get(name);
5615
- return row ? fromRow(row) : null;
6344
+ return row ? fromRow2(row) : null;
5616
6345
  }
5617
6346
  function listAuthPresets() {
5618
6347
  const db2 = getDatabase();
5619
6348
  const rows = db2.query("SELECT * FROM auth_presets ORDER BY created_at DESC").all();
5620
- return rows.map(fromRow);
6349
+ return rows.map(fromRow2);
5621
6350
  }
5622
6351
  function deleteAuthPreset(name) {
5623
6352
  const db2 = getDatabase();
@@ -5627,7 +6356,155 @@ function deleteAuthPreset(name) {
5627
6356
 
5628
6357
  // src/cli/index.tsx
5629
6358
  init_flows();
5630
- import { existsSync as existsSync7, mkdirSync as mkdirSync4 } from "fs";
6359
+
6360
+ // src/db/environments.ts
6361
+ init_database();
6362
+ function fromRow3(row) {
6363
+ return {
6364
+ id: row.id,
6365
+ name: row.name,
6366
+ url: row.url,
6367
+ authPresetName: row.auth_preset_name,
6368
+ projectId: row.project_id,
6369
+ isDefault: row.is_default === 1,
6370
+ createdAt: row.created_at
6371
+ };
6372
+ }
6373
+ function createEnvironment(input) {
6374
+ const db2 = getDatabase();
6375
+ const id = uuid();
6376
+ const timestamp = now();
6377
+ db2.query(`
6378
+ INSERT INTO environments (id, name, url, auth_preset_name, project_id, is_default, metadata, created_at)
6379
+ VALUES (?, ?, ?, ?, ?, ?, '{}', ?)
6380
+ `).run(id, input.name, input.url, input.authPresetName ?? null, input.projectId ?? null, input.isDefault ? 1 : 0, timestamp);
6381
+ return getEnvironment(input.name);
6382
+ }
6383
+ function getEnvironment(name) {
6384
+ const db2 = getDatabase();
6385
+ const row = db2.query("SELECT * FROM environments WHERE name = ?").get(name);
6386
+ return row ? fromRow3(row) : null;
6387
+ }
6388
+ function listEnvironments(projectId) {
6389
+ const db2 = getDatabase();
6390
+ if (projectId) {
6391
+ const rows2 = db2.query("SELECT * FROM environments WHERE project_id = ? ORDER BY is_default DESC, created_at DESC").all(projectId);
6392
+ return rows2.map(fromRow3);
6393
+ }
6394
+ const rows = db2.query("SELECT * FROM environments ORDER BY is_default DESC, created_at DESC").all();
6395
+ return rows.map(fromRow3);
6396
+ }
6397
+ function deleteEnvironment(name) {
6398
+ const db2 = getDatabase();
6399
+ const result = db2.query("DELETE FROM environments WHERE name = ?").run(name);
6400
+ return result.changes > 0;
6401
+ }
6402
+ function setDefaultEnvironment(name) {
6403
+ const db2 = getDatabase();
6404
+ db2.exec("UPDATE environments SET is_default = 0");
6405
+ const result = db2.query("UPDATE environments SET is_default = 1 WHERE name = ?").run(name);
6406
+ if (result.changes === 0) {
6407
+ throw new Error(`Environment not found: ${name}`);
6408
+ }
6409
+ }
6410
+ function getDefaultEnvironment() {
6411
+ const db2 = getDatabase();
6412
+ const row = db2.query("SELECT * FROM environments WHERE is_default = 1 LIMIT 1").get();
6413
+ return row ? fromRow3(row) : null;
6414
+ }
6415
+
6416
+ // src/lib/ci.ts
6417
+ function generateGitHubActionsWorkflow() {
6418
+ return `name: AI QA Tests
6419
+ on:
6420
+ pull_request:
6421
+ push:
6422
+ branches: [main]
6423
+
6424
+ jobs:
6425
+ test:
6426
+ runs-on: ubuntu-latest
6427
+ steps:
6428
+ - uses: actions/checkout@v4
6429
+ - uses: oven-sh/setup-bun@v2
6430
+ - run: bun install -g @hasna/testers
6431
+ - run: testers install-browser
6432
+ - run: testers run \${{ env.TEST_URL }} --json --output results.json
6433
+ env:
6434
+ ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
6435
+ TEST_URL: \${{ vars.TEST_URL || 'http://localhost:3000' }}
6436
+ - run: testers report --latest --output report.html
6437
+ - uses: actions/upload-artifact@v4
6438
+ if: always()
6439
+ with:
6440
+ name: test-report
6441
+ path: |
6442
+ report.html
6443
+ results.json
6444
+ `;
6445
+ }
6446
+
6447
+ // src/lib/assertions.ts
6448
+ function parseAssertionString(str) {
6449
+ const trimmed = str.trim();
6450
+ if (trimmed === "no-console-errors") {
6451
+ return { type: "no_console_errors", description: "No console errors" };
6452
+ }
6453
+ if (trimmed.startsWith("url:contains:")) {
6454
+ const expected = trimmed.slice("url:contains:".length);
6455
+ return { type: "url_contains", expected, description: `URL contains "${expected}"` };
6456
+ }
6457
+ if (trimmed.startsWith("title:contains:")) {
6458
+ const expected = trimmed.slice("title:contains:".length);
6459
+ return { type: "title_contains", expected, description: `Title contains "${expected}"` };
6460
+ }
6461
+ if (trimmed.startsWith("count:")) {
6462
+ const rest = trimmed.slice("count:".length);
6463
+ const eqIdx = rest.indexOf(" eq:");
6464
+ if (eqIdx === -1) {
6465
+ throw new Error(`Invalid count assertion format: ${str}. Expected "count:<selector> eq:<number>"`);
6466
+ }
6467
+ const selector = rest.slice(0, eqIdx);
6468
+ const expected = parseInt(rest.slice(eqIdx + " eq:".length), 10);
6469
+ return { type: "element_count", selector, expected, description: `${selector} count equals ${expected}` };
6470
+ }
6471
+ if (trimmed.startsWith("text:")) {
6472
+ const rest = trimmed.slice("text:".length);
6473
+ const containsIdx = rest.indexOf(" contains:");
6474
+ const equalsIdx = rest.indexOf(" equals:");
6475
+ if (containsIdx !== -1) {
6476
+ const selector = rest.slice(0, containsIdx);
6477
+ const expected = rest.slice(containsIdx + " contains:".length);
6478
+ return { type: "text_contains", selector, expected, description: `${selector} text contains "${expected}"` };
6479
+ }
6480
+ if (equalsIdx !== -1) {
6481
+ const selector = rest.slice(0, equalsIdx);
6482
+ const expected = rest.slice(equalsIdx + " equals:".length);
6483
+ return { type: "text_equals", selector, expected, description: `${selector} text equals "${expected}"` };
6484
+ }
6485
+ throw new Error(`Invalid text assertion format: ${str}. Expected "text:<selector> contains:<text>" or "text:<selector> equals:<text>"`);
6486
+ }
6487
+ if (trimmed.startsWith("selector:")) {
6488
+ const rest = trimmed.slice("selector:".length);
6489
+ const lastSpace = rest.lastIndexOf(" ");
6490
+ if (lastSpace === -1) {
6491
+ throw new Error(`Invalid selector assertion format: ${str}. Expected "selector:<selector> visible" or "selector:<selector> not-visible"`);
6492
+ }
6493
+ const selector = rest.slice(0, lastSpace);
6494
+ const action = rest.slice(lastSpace + 1);
6495
+ if (action === "visible") {
6496
+ return { type: "visible", selector, description: `${selector} is visible` };
6497
+ }
6498
+ if (action === "not-visible") {
6499
+ return { type: "not_visible", selector, description: `${selector} is not visible` };
6500
+ }
6501
+ throw new Error(`Unknown selector action: "${action}". Expected "visible" or "not-visible"`);
6502
+ }
6503
+ throw new Error(`Cannot parse assertion: "${str}". See --help for assertion formats.`);
6504
+ }
6505
+
6506
+ // src/cli/index.tsx
6507
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4 } from "fs";
5631
6508
  function formatToolInput(input) {
5632
6509
  const parts = [];
5633
6510
  for (const [key, value] of Object.entries(input)) {
@@ -5643,8 +6520,8 @@ var CONFIG_DIR2 = join6(process.env["HOME"] ?? "~", ".testers");
5643
6520
  var CONFIG_PATH2 = join6(CONFIG_DIR2, "config.json");
5644
6521
  function getActiveProject() {
5645
6522
  try {
5646
- if (existsSync7(CONFIG_PATH2)) {
5647
- const raw = JSON.parse(readFileSync4(CONFIG_PATH2, "utf-8"));
6523
+ if (existsSync8(CONFIG_PATH2)) {
6524
+ const raw = JSON.parse(readFileSync6(CONFIG_PATH2, "utf-8"));
5648
6525
  return raw.activeProject ?? undefined;
5649
6526
  }
5650
6527
  } catch {}
@@ -5659,21 +6536,25 @@ program2.command("add <name>").description("Create a new test scenario").option(
5659
6536
  }, []).option("-t, --tag <tag>", "Tag (repeatable)", (val, acc) => {
5660
6537
  acc.push(val);
5661
6538
  return acc;
5662
- }, []).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)").action((name, opts) => {
6539
+ }, []).option("-p, --priority <level>", "Priority level", "medium").option("-m, --model <model>", "AI model to use").option("--path <path>", "Target path on the URL").option("--auth", "Requires authentication", false).option("--timeout <ms>", "Timeout in milliseconds").option("--project <id>", "Project ID").option("--template <name>", "Seed scenarios from a template (auth, crud, forms, nav, a11y)").option("--assert <assertion>", "Structured assertion (repeatable). Formats: selector:<sel> visible, text:<sel> contains:<text>, no-console-errors, url:contains:<path>, title:contains:<text>, count:<sel> eq:<n>", (val, acc) => {
6540
+ acc.push(val);
6541
+ return acc;
6542
+ }, []).action((name, opts) => {
5663
6543
  try {
5664
6544
  if (opts.template) {
5665
6545
  const template = getTemplate(opts.template);
5666
6546
  if (!template) {
5667
- console.error(chalk4.red(`Unknown template: ${opts.template}. Available: ${listTemplateNames().join(", ")}`));
6547
+ console.error(chalk5.red(`Unknown template: ${opts.template}. Available: ${listTemplateNames().join(", ")}`));
5668
6548
  process.exit(1);
5669
6549
  }
5670
6550
  const projectId2 = resolveProject(opts.project);
5671
6551
  for (const input of template) {
5672
6552
  const s = createScenario({ ...input, projectId: projectId2 });
5673
- console.log(chalk4.green(` Created ${s.shortId}: ${s.name}`));
6553
+ console.log(chalk5.green(` Created ${s.shortId}: ${s.name}`));
5674
6554
  }
5675
6555
  return;
5676
6556
  }
6557
+ const assertions = opts.assert.map(parseAssertionString);
5677
6558
  const projectId = resolveProject(opts.project);
5678
6559
  const scenario = createScenario({
5679
6560
  name,
@@ -5685,11 +6566,12 @@ program2.command("add <name>").description("Create a new test scenario").option(
5685
6566
  targetPath: opts.path,
5686
6567
  requiresAuth: opts.auth,
5687
6568
  timeoutMs: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
6569
+ assertions: assertions.length > 0 ? assertions : undefined,
5688
6570
  projectId
5689
6571
  });
5690
- console.log(chalk4.green(`Created scenario ${chalk4.bold(scenario.shortId)}: ${scenario.name}`));
6572
+ console.log(chalk5.green(`Created scenario ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
5691
6573
  } catch (error) {
5692
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6574
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5693
6575
  process.exit(1);
5694
6576
  }
5695
6577
  });
@@ -5703,7 +6585,7 @@ program2.command("list").description("List test scenarios").option("-t, --tag <t
5703
6585
  });
5704
6586
  console.log(formatScenarioList(scenarios));
5705
6587
  } catch (error) {
5706
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6588
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5707
6589
  process.exit(1);
5708
6590
  }
5709
6591
  });
@@ -5711,33 +6593,33 @@ program2.command("show <id>").description("Show scenario details").action((id) =
5711
6593
  try {
5712
6594
  const scenario = getScenario(id) ?? getScenarioByShortId(id);
5713
6595
  if (!scenario) {
5714
- console.error(chalk4.red(`Scenario not found: ${id}`));
6596
+ console.error(chalk5.red(`Scenario not found: ${id}`));
5715
6597
  process.exit(1);
5716
6598
  }
5717
6599
  console.log("");
5718
- console.log(chalk4.bold(` Scenario ${scenario.shortId}`));
6600
+ console.log(chalk5.bold(` Scenario ${scenario.shortId}`));
5719
6601
  console.log(` Name: ${scenario.name}`);
5720
- console.log(` ID: ${chalk4.dim(scenario.id)}`);
6602
+ console.log(` ID: ${chalk5.dim(scenario.id)}`);
5721
6603
  console.log(` Description: ${scenario.description}`);
5722
6604
  console.log(` Priority: ${scenario.priority}`);
5723
- console.log(` Model: ${scenario.model ?? chalk4.dim("default")}`);
5724
- console.log(` Tags: ${scenario.tags.length > 0 ? scenario.tags.join(", ") : chalk4.dim("none")}`);
5725
- console.log(` Path: ${scenario.targetPath ?? chalk4.dim("none")}`);
6605
+ console.log(` Model: ${scenario.model ?? chalk5.dim("default")}`);
6606
+ console.log(` Tags: ${scenario.tags.length > 0 ? scenario.tags.join(", ") : chalk5.dim("none")}`);
6607
+ console.log(` Path: ${scenario.targetPath ?? chalk5.dim("none")}`);
5726
6608
  console.log(` Auth: ${scenario.requiresAuth ? "yes" : "no"}`);
5727
- console.log(` Timeout: ${scenario.timeoutMs ? `${scenario.timeoutMs}ms` : chalk4.dim("default")}`);
6609
+ console.log(` Timeout: ${scenario.timeoutMs ? `${scenario.timeoutMs}ms` : chalk5.dim("default")}`);
5728
6610
  console.log(` Version: ${scenario.version}`);
5729
6611
  console.log(` Created: ${scenario.createdAt}`);
5730
6612
  console.log(` Updated: ${scenario.updatedAt}`);
5731
6613
  if (scenario.steps.length > 0) {
5732
6614
  console.log("");
5733
- console.log(chalk4.bold(" Steps:"));
6615
+ console.log(chalk5.bold(" Steps:"));
5734
6616
  for (let i = 0;i < scenario.steps.length; i++) {
5735
6617
  console.log(` ${i + 1}. ${scenario.steps[i]}`);
5736
6618
  }
5737
6619
  }
5738
6620
  console.log("");
5739
6621
  } catch (error) {
5740
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6622
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5741
6623
  process.exit(1);
5742
6624
  }
5743
6625
  });
@@ -5751,7 +6633,7 @@ program2.command("update <id>").description("Update a scenario").option("-n, --n
5751
6633
  try {
5752
6634
  const scenario = getScenario(id) ?? getScenarioByShortId(id);
5753
6635
  if (!scenario) {
5754
- console.error(chalk4.red(`Scenario not found: ${id}`));
6636
+ console.error(chalk5.red(`Scenario not found: ${id}`));
5755
6637
  process.exit(1);
5756
6638
  }
5757
6639
  const updated = updateScenario(scenario.id, {
@@ -5762,9 +6644,9 @@ program2.command("update <id>").description("Update a scenario").option("-n, --n
5762
6644
  priority: opts.priority,
5763
6645
  model: opts.model
5764
6646
  }, scenario.version);
5765
- console.log(chalk4.green(`Updated scenario ${chalk4.bold(updated.shortId)}: ${updated.name}`));
6647
+ console.log(chalk5.green(`Updated scenario ${chalk5.bold(updated.shortId)}: ${updated.name}`));
5766
6648
  } catch (error) {
5767
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6649
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5768
6650
  process.exit(1);
5769
6651
  }
5770
6652
  });
@@ -5772,30 +6654,50 @@ program2.command("delete <id>").description("Delete a scenario").action((id) =>
5772
6654
  try {
5773
6655
  const scenario = getScenario(id) ?? getScenarioByShortId(id);
5774
6656
  if (!scenario) {
5775
- console.error(chalk4.red(`Scenario not found: ${id}`));
6657
+ console.error(chalk5.red(`Scenario not found: ${id}`));
5776
6658
  process.exit(1);
5777
6659
  }
5778
6660
  const deleted = deleteScenario(scenario.id);
5779
6661
  if (deleted) {
5780
- console.log(chalk4.green(`Deleted scenario ${scenario.shortId}: ${scenario.name}`));
6662
+ console.log(chalk5.green(`Deleted scenario ${scenario.shortId}: ${scenario.name}`));
5781
6663
  } else {
5782
- console.error(chalk4.red(`Failed to delete scenario: ${id}`));
6664
+ console.error(chalk5.red(`Failed to delete scenario: ${id}`));
5783
6665
  process.exit(1);
5784
6666
  }
5785
6667
  } catch (error) {
5786
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6668
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5787
6669
  process.exit(1);
5788
6670
  }
5789
6671
  });
5790
- program2.command("run <url> [description]").description("Run test scenarios against a URL").option("-t, --tag <tag>", "Filter by tag (repeatable)", (val, acc) => {
6672
+ program2.command("run [url] [description]").description("Run test scenarios against a URL").option("-t, --tag <tag>", "Filter by tag (repeatable)", (val, acc) => {
5791
6673
  acc.push(val);
5792
6674
  return acc;
5793
- }, []).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).action(async (url, description, opts) => {
6675
+ }, []).option("-s, --scenario <id>", "Run specific scenario ID").option("-p, --priority <level>", "Filter by priority").option("--headed", "Run browser in headed mode", false).option("-m, --model <model>", "AI model to use").option("--parallel <n>", "Number of parallel browsers", "1").option("--json", "Output results as JSON", false).option("-o, --output <filepath>", "Write JSON results to file").option("--timeout <ms>", "Timeout in milliseconds").option("--from-todos", "Import scenarios from todos before running", false).option("--project <id>", "Project ID").option("-b, --background", "Start run in background and return immediately", false).option("--browser <engine>", "Browser engine: playwright or lightpanda", "playwright").option("--env <name>", "Use a named environment for the URL").action(async (urlArg, description, opts) => {
5794
6676
  try {
5795
6677
  const projectId = resolveProject(opts.project);
6678
+ let url = urlArg;
6679
+ if (!url && opts.env) {
6680
+ const env = getEnvironment(opts.env);
6681
+ if (!env) {
6682
+ console.error(chalk5.red(`Environment not found: ${opts.env}`));
6683
+ process.exit(1);
6684
+ }
6685
+ url = env.url;
6686
+ }
6687
+ if (!url) {
6688
+ const defaultEnv = getDefaultEnvironment();
6689
+ if (defaultEnv) {
6690
+ url = defaultEnv.url;
6691
+ console.log(chalk5.dim(`Using default environment: ${defaultEnv.name} (${defaultEnv.url})`));
6692
+ }
6693
+ }
6694
+ if (!url) {
6695
+ console.error(chalk5.red("No URL provided. Pass a URL argument, use --env <name>, or set a default environment with 'testers env use <name>'."));
6696
+ process.exit(1);
6697
+ }
5796
6698
  if (opts.fromTodos) {
5797
6699
  const result = importFromTodos({ projectId });
5798
- console.log(chalk4.blue(`Imported ${result.imported} scenarios from todos (${result.skipped} skipped)`));
6700
+ console.log(chalk5.blue(`Imported ${result.imported} scenarios from todos (${result.skipped} skipped)`));
5799
6701
  }
5800
6702
  if (opts.background) {
5801
6703
  if (description) {
@@ -5810,53 +6712,54 @@ program2.command("run <url> [description]").description("Run test scenarios agai
5810
6712
  headed: opts.headed,
5811
6713
  parallel: parseInt(opts.parallel, 10),
5812
6714
  timeout: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
5813
- projectId
6715
+ projectId,
6716
+ engine: opts.browser
5814
6717
  });
5815
- console.log(chalk4.green(`Run started in background: ${chalk4.bold(runId.slice(0, 8))}`));
5816
- console.log(chalk4.dim(` Scenarios: ${scenarioCount}`));
5817
- console.log(chalk4.dim(` URL: ${url}`));
5818
- console.log(chalk4.dim(` Check progress: testers results ${runId.slice(0, 8)}`));
6718
+ console.log(chalk5.green(`Run started in background: ${chalk5.bold(runId.slice(0, 8))}`));
6719
+ console.log(chalk5.dim(` Scenarios: ${scenarioCount}`));
6720
+ console.log(chalk5.dim(` URL: ${url}`));
6721
+ console.log(chalk5.dim(` Check progress: testers results ${runId.slice(0, 8)}`));
5819
6722
  process.exit(0);
5820
6723
  }
5821
6724
  if (!opts.json && !opts.output) {
5822
6725
  onRunEvent((event) => {
5823
6726
  switch (event.type) {
5824
6727
  case "scenario:start":
5825
- console.log(chalk4.blue(` [start] ${event.scenarioName ?? event.scenarioId}`));
6728
+ console.log(chalk5.blue(` [start] ${event.scenarioName ?? event.scenarioId}`));
5826
6729
  break;
5827
6730
  case "step:thinking":
5828
6731
  if (event.thinking) {
5829
6732
  const preview = event.thinking.length > 120 ? event.thinking.slice(0, 120) + "..." : event.thinking;
5830
- console.log(chalk4.dim(` [think] ${preview}`));
6733
+ console.log(chalk5.dim(` [think] ${preview}`));
5831
6734
  }
5832
6735
  break;
5833
6736
  case "step:tool_call":
5834
- console.log(chalk4.cyan(` [step ${event.stepNumber}] ${event.toolName}${event.toolInput ? ` ${formatToolInput(event.toolInput)}` : ""}`));
6737
+ console.log(chalk5.cyan(` [step ${event.stepNumber}] ${event.toolName}${event.toolInput ? ` ${formatToolInput(event.toolInput)}` : ""}`));
5835
6738
  break;
5836
6739
  case "step:tool_result":
5837
6740
  if (event.toolName === "report_result") {
5838
- console.log(chalk4.bold(` [result] ${event.toolResult}`));
6741
+ console.log(chalk5.bold(` [result] ${event.toolResult}`));
5839
6742
  } else {
5840
6743
  const resultPreview = (event.toolResult ?? "").length > 100 ? (event.toolResult ?? "").slice(0, 100) + "..." : event.toolResult ?? "";
5841
- console.log(chalk4.dim(` [done] ${resultPreview}`));
6744
+ console.log(chalk5.dim(` [done] ${resultPreview}`));
5842
6745
  }
5843
6746
  break;
5844
6747
  case "screenshot:captured":
5845
- console.log(chalk4.dim(` [screenshot] ${event.screenshotPath}`));
6748
+ console.log(chalk5.dim(` [screenshot] ${event.screenshotPath}`));
5846
6749
  break;
5847
6750
  case "scenario:pass":
5848
- console.log(chalk4.green(` [PASS] ${event.scenarioName}`));
6751
+ console.log(chalk5.green(` [PASS] ${event.scenarioName}`));
5849
6752
  break;
5850
6753
  case "scenario:fail":
5851
- console.log(chalk4.red(` [FAIL] ${event.scenarioName}`));
6754
+ console.log(chalk5.red(` [FAIL] ${event.scenarioName}`));
5852
6755
  break;
5853
6756
  case "scenario:error":
5854
- console.log(chalk4.yellow(` [ERR] ${event.scenarioName}: ${event.error}`));
6757
+ console.log(chalk5.yellow(` [ERR] ${event.scenarioName}: ${event.error}`));
5855
6758
  break;
5856
6759
  }
5857
6760
  });
5858
6761
  console.log("");
5859
- console.log(chalk4.bold(` Running tests against ${url}`));
6762
+ console.log(chalk5.bold(` Running tests against ${url}`));
5860
6763
  console.log("");
5861
6764
  }
5862
6765
  if (description) {
@@ -5873,13 +6776,14 @@ program2.command("run <url> [description]").description("Run test scenarios agai
5873
6776
  headed: opts.headed,
5874
6777
  parallel: parseInt(opts.parallel, 10),
5875
6778
  timeout: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
5876
- projectId
6779
+ projectId,
6780
+ engine: opts.browser
5877
6781
  });
5878
6782
  if (opts.json || opts.output) {
5879
6783
  const jsonOutput = formatJSON(run2, results2);
5880
6784
  if (opts.output) {
5881
6785
  writeFileSync3(opts.output, jsonOutput, "utf-8");
5882
- console.log(chalk4.green(`Results written to ${opts.output}`));
6786
+ console.log(chalk5.green(`Results written to ${opts.output}`));
5883
6787
  }
5884
6788
  if (opts.json) {
5885
6789
  console.log(jsonOutput);
@@ -5898,13 +6802,14 @@ program2.command("run <url> [description]").description("Run test scenarios agai
5898
6802
  headed: opts.headed,
5899
6803
  parallel: parseInt(opts.parallel, 10),
5900
6804
  timeout: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
5901
- projectId
6805
+ projectId,
6806
+ engine: opts.browser
5902
6807
  });
5903
6808
  if (opts.json || opts.output) {
5904
6809
  const jsonOutput = formatJSON(run, results);
5905
6810
  if (opts.output) {
5906
6811
  writeFileSync3(opts.output, jsonOutput, "utf-8");
5907
- console.log(chalk4.green(`Results written to ${opts.output}`));
6812
+ console.log(chalk5.green(`Results written to ${opts.output}`));
5908
6813
  }
5909
6814
  if (opts.json) {
5910
6815
  console.log(jsonOutput);
@@ -5914,7 +6819,7 @@ program2.command("run <url> [description]").description("Run test scenarios agai
5914
6819
  }
5915
6820
  process.exit(getExitCode(run));
5916
6821
  } catch (error) {
5917
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6822
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5918
6823
  process.exit(1);
5919
6824
  }
5920
6825
  });
@@ -5926,7 +6831,7 @@ program2.command("runs").description("List past test runs").option("--status <st
5926
6831
  });
5927
6832
  console.log(formatRunList(runs));
5928
6833
  } catch (error) {
5929
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6834
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5930
6835
  process.exit(1);
5931
6836
  }
5932
6837
  });
@@ -5934,13 +6839,13 @@ program2.command("results <run-id>").description("Show results for a test run").
5934
6839
  try {
5935
6840
  const run = getRun(runId);
5936
6841
  if (!run) {
5937
- console.error(chalk4.red(`Run not found: ${runId}`));
6842
+ console.error(chalk5.red(`Run not found: ${runId}`));
5938
6843
  process.exit(1);
5939
6844
  }
5940
6845
  const results = getResultsByRun(run.id);
5941
6846
  console.log(formatTerminal(run, results));
5942
6847
  } catch (error) {
5943
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6848
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5944
6849
  process.exit(1);
5945
6850
  }
5946
6851
  });
@@ -5951,23 +6856,23 @@ program2.command("screenshots <id>").description("List screenshots for a run or
5951
6856
  const results = getResultsByRun(run.id);
5952
6857
  let total = 0;
5953
6858
  console.log("");
5954
- console.log(chalk4.bold(` Screenshots for run ${run.id.slice(0, 8)}`));
6859
+ console.log(chalk5.bold(` Screenshots for run ${run.id.slice(0, 8)}`));
5955
6860
  console.log("");
5956
6861
  for (const result of results) {
5957
6862
  const screenshots2 = listScreenshots(result.id);
5958
6863
  if (screenshots2.length > 0) {
5959
6864
  const scenario = getScenario(result.scenarioId);
5960
6865
  const label = scenario ? `${scenario.shortId}: ${scenario.name}` : result.scenarioId.slice(0, 8);
5961
- console.log(chalk4.bold(` ${label}`));
6866
+ console.log(chalk5.bold(` ${label}`));
5962
6867
  for (const ss of screenshots2) {
5963
- console.log(` ${chalk4.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk4.dim(ss.filePath)}`);
6868
+ console.log(` ${chalk5.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk5.dim(ss.filePath)}`);
5964
6869
  total++;
5965
6870
  }
5966
6871
  console.log("");
5967
6872
  }
5968
6873
  }
5969
6874
  if (total === 0) {
5970
- console.log(chalk4.dim(" No screenshots found."));
6875
+ console.log(chalk5.dim(" No screenshots found."));
5971
6876
  console.log("");
5972
6877
  }
5973
6878
  return;
@@ -5975,18 +6880,18 @@ program2.command("screenshots <id>").description("List screenshots for a run or
5975
6880
  const screenshots = listScreenshots(id);
5976
6881
  if (screenshots.length > 0) {
5977
6882
  console.log("");
5978
- console.log(chalk4.bold(` Screenshots for result ${id.slice(0, 8)}`));
6883
+ console.log(chalk5.bold(` Screenshots for result ${id.slice(0, 8)}`));
5979
6884
  console.log("");
5980
6885
  for (const ss of screenshots) {
5981
- console.log(` ${chalk4.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk4.dim(ss.filePath)}`);
6886
+ console.log(` ${chalk5.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk5.dim(ss.filePath)}`);
5982
6887
  }
5983
6888
  console.log("");
5984
6889
  return;
5985
6890
  }
5986
- console.error(chalk4.red(`No screenshots found for: ${id}`));
6891
+ console.error(chalk5.red(`No screenshots found for: ${id}`));
5987
6892
  process.exit(1);
5988
6893
  } catch (error) {
5989
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6894
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5990
6895
  process.exit(1);
5991
6896
  }
5992
6897
  });
@@ -5995,12 +6900,12 @@ program2.command("import <dir>").description("Import markdown test files as scen
5995
6900
  const absDir = resolve(dir);
5996
6901
  const files = readdirSync(absDir).filter((f) => f.endsWith(".md"));
5997
6902
  if (files.length === 0) {
5998
- console.log(chalk4.dim("No .md files found in directory."));
6903
+ console.log(chalk5.dim("No .md files found in directory."));
5999
6904
  return;
6000
6905
  }
6001
6906
  let imported = 0;
6002
6907
  for (const file of files) {
6003
- const content = readFileSync4(join6(absDir, file), "utf-8");
6908
+ const content = readFileSync6(join6(absDir, file), "utf-8");
6004
6909
  const lines = content.split(`
6005
6910
  `);
6006
6911
  let name = file.replace(/\.md$/, "");
@@ -6025,13 +6930,13 @@ program2.command("import <dir>").description("Import markdown test files as scen
6025
6930
  description: descriptionLines.join(" ") || name,
6026
6931
  steps
6027
6932
  });
6028
- console.log(chalk4.green(` Imported ${chalk4.bold(scenario.shortId)}: ${scenario.name}`));
6933
+ console.log(chalk5.green(` Imported ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
6029
6934
  imported++;
6030
6935
  }
6031
6936
  console.log("");
6032
- console.log(chalk4.green(`Imported ${imported} scenario(s) from ${absDir}`));
6937
+ console.log(chalk5.green(`Imported ${imported} scenario(s) from ${absDir}`));
6033
6938
  } catch (error) {
6034
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6939
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6035
6940
  process.exit(1);
6036
6941
  }
6037
6942
  });
@@ -6040,7 +6945,7 @@ program2.command("config").description("Show current configuration").action(() =
6040
6945
  const config = loadConfig();
6041
6946
  console.log(JSON.stringify(config, null, 2));
6042
6947
  } catch (error) {
6043
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6948
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6044
6949
  process.exit(1);
6045
6950
  }
6046
6951
  });
@@ -6050,25 +6955,32 @@ program2.command("status").description("Show database and auth status").action((
6050
6955
  const hasApiKey = !!config.anthropicApiKey || !!process.env["ANTHROPIC_API_KEY"];
6051
6956
  const dbPath = join6(process.env["HOME"] ?? "~", ".testers", "testers.db");
6052
6957
  console.log("");
6053
- console.log(chalk4.bold(" Open Testers Status"));
6958
+ console.log(chalk5.bold(" Open Testers Status"));
6054
6959
  console.log("");
6055
- console.log(` ANTHROPIC_API_KEY: ${hasApiKey ? chalk4.green("set") : chalk4.red("not set")}`);
6960
+ console.log(` ANTHROPIC_API_KEY: ${hasApiKey ? chalk5.green("set") : chalk5.red("not set")}`);
6056
6961
  console.log(` Database: ${dbPath}`);
6057
6962
  console.log(` Default model: ${config.defaultModel}`);
6058
6963
  console.log(` Screenshots dir: ${config.screenshots.dir}`);
6059
6964
  console.log("");
6060
6965
  } catch (error) {
6061
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6966
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6062
6967
  process.exit(1);
6063
6968
  }
6064
6969
  });
6065
- program2.command("install-browser").description("Install Playwright Chromium browser").action(async () => {
6970
+ program2.command("install-browser").description("Install browser engine").option("--engine <engine>", "Engine to install: playwright, lightpanda, or all", "playwright").action(async (opts) => {
6066
6971
  try {
6067
- console.log(chalk4.blue("Installing Playwright Chromium..."));
6068
- await installBrowser();
6069
- console.log(chalk4.green("Browser installed successfully."));
6972
+ if (opts.engine === "all" || opts.engine === "playwright") {
6973
+ console.log(chalk5.blue("Installing Playwright Chromium..."));
6974
+ await installBrowser("playwright");
6975
+ console.log(chalk5.green("Playwright Chromium installed."));
6976
+ }
6977
+ if (opts.engine === "all" || opts.engine === "lightpanda") {
6978
+ console.log(chalk5.blue("Installing Lightpanda..."));
6979
+ await installBrowser("lightpanda");
6980
+ console.log(chalk5.green("Lightpanda installed."));
6981
+ }
6070
6982
  } catch (error) {
6071
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6983
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6072
6984
  process.exit(1);
6073
6985
  }
6074
6986
  });
@@ -6080,9 +6992,9 @@ projectCmd.command("create <name>").description("Create a new project").option("
6080
6992
  path: opts.path,
6081
6993
  description: opts.description
6082
6994
  });
6083
- console.log(chalk4.green(`Created project ${chalk4.bold(project.name)} (${project.id})`));
6995
+ console.log(chalk5.green(`Created project ${chalk5.bold(project.name)} (${project.id})`));
6084
6996
  } catch (error) {
6085
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6997
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6086
6998
  process.exit(1);
6087
6999
  }
6088
7000
  });
@@ -6090,20 +7002,20 @@ projectCmd.command("list").description("List all projects").action(() => {
6090
7002
  try {
6091
7003
  const projects = listProjects();
6092
7004
  if (projects.length === 0) {
6093
- console.log(chalk4.dim("No projects found."));
7005
+ console.log(chalk5.dim("No projects found."));
6094
7006
  return;
6095
7007
  }
6096
7008
  console.log("");
6097
- console.log(chalk4.bold(" Projects"));
7009
+ console.log(chalk5.bold(" Projects"));
6098
7010
  console.log("");
6099
7011
  console.log(` ${"ID".padEnd(38)} ${"Name".padEnd(24)} ${"Path".padEnd(30)} Created`);
6100
7012
  console.log(` ${"\u2500".repeat(38)} ${"\u2500".repeat(24)} ${"\u2500".repeat(30)} ${"\u2500".repeat(20)}`);
6101
7013
  for (const p of projects) {
6102
- console.log(` ${p.id.padEnd(38)} ${p.name.padEnd(24)} ${(p.path ?? chalk4.dim("\u2014")).toString().padEnd(30)} ${p.createdAt}`);
7014
+ console.log(` ${p.id.padEnd(38)} ${p.name.padEnd(24)} ${(p.path ?? chalk5.dim("\u2014")).toString().padEnd(30)} ${p.createdAt}`);
6103
7015
  }
6104
7016
  console.log("");
6105
7017
  } catch (error) {
6106
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7018
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6107
7019
  process.exit(1);
6108
7020
  }
6109
7021
  });
@@ -6111,39 +7023,39 @@ projectCmd.command("show <id>").description("Show project details").action((id)
6111
7023
  try {
6112
7024
  const project = getProject(id);
6113
7025
  if (!project) {
6114
- console.error(chalk4.red(`Project not found: ${id}`));
7026
+ console.error(chalk5.red(`Project not found: ${id}`));
6115
7027
  process.exit(1);
6116
7028
  }
6117
7029
  console.log("");
6118
- console.log(chalk4.bold(` Project: ${project.name}`));
7030
+ console.log(chalk5.bold(` Project: ${project.name}`));
6119
7031
  console.log(` ID: ${project.id}`);
6120
- console.log(` Path: ${project.path ?? chalk4.dim("none")}`);
6121
- console.log(` Description: ${project.description ?? chalk4.dim("none")}`);
7032
+ console.log(` Path: ${project.path ?? chalk5.dim("none")}`);
7033
+ console.log(` Description: ${project.description ?? chalk5.dim("none")}`);
6122
7034
  console.log(` Created: ${project.createdAt}`);
6123
7035
  console.log(` Updated: ${project.updatedAt}`);
6124
7036
  console.log("");
6125
7037
  } catch (error) {
6126
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7038
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6127
7039
  process.exit(1);
6128
7040
  }
6129
7041
  });
6130
7042
  projectCmd.command("use <name>").description("Set active project (find or create)").action((name) => {
6131
7043
  try {
6132
7044
  const project = ensureProject(name, process.cwd());
6133
- if (!existsSync7(CONFIG_DIR2)) {
7045
+ if (!existsSync8(CONFIG_DIR2)) {
6134
7046
  mkdirSync4(CONFIG_DIR2, { recursive: true });
6135
7047
  }
6136
7048
  let config = {};
6137
- if (existsSync7(CONFIG_PATH2)) {
7049
+ if (existsSync8(CONFIG_PATH2)) {
6138
7050
  try {
6139
- config = JSON.parse(readFileSync4(CONFIG_PATH2, "utf-8"));
7051
+ config = JSON.parse(readFileSync6(CONFIG_PATH2, "utf-8"));
6140
7052
  } catch {}
6141
7053
  }
6142
7054
  config.activeProject = project.id;
6143
7055
  writeFileSync3(CONFIG_PATH2, JSON.stringify(config, null, 2), "utf-8");
6144
- console.log(chalk4.green(`Active project set to ${chalk4.bold(project.name)} (${project.id})`));
7056
+ console.log(chalk5.green(`Active project set to ${chalk5.bold(project.name)} (${project.id})`));
6145
7057
  } catch (error) {
6146
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7058
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6147
7059
  process.exit(1);
6148
7060
  }
6149
7061
  });
@@ -6168,12 +7080,12 @@ scheduleCmd.command("create <name>").description("Create a new schedule").requir
6168
7080
  timeoutMs: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
6169
7081
  projectId
6170
7082
  });
6171
- console.log(chalk4.green(`Created schedule ${chalk4.bold(schedule.name)} (${schedule.id})`));
7083
+ console.log(chalk5.green(`Created schedule ${chalk5.bold(schedule.name)} (${schedule.id})`));
6172
7084
  if (schedule.nextRunAt) {
6173
- console.log(chalk4.dim(` Next run at: ${schedule.nextRunAt}`));
7085
+ console.log(chalk5.dim(` Next run at: ${schedule.nextRunAt}`));
6174
7086
  }
6175
7087
  } catch (error) {
6176
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7088
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6177
7089
  process.exit(1);
6178
7090
  }
6179
7091
  });
@@ -6185,23 +7097,23 @@ scheduleCmd.command("list").description("List schedules").option("--project <id>
6185
7097
  enabled: opts.enabled ? true : undefined
6186
7098
  });
6187
7099
  if (schedules.length === 0) {
6188
- console.log(chalk4.dim("No schedules found."));
7100
+ console.log(chalk5.dim("No schedules found."));
6189
7101
  return;
6190
7102
  }
6191
7103
  console.log("");
6192
- console.log(chalk4.bold(" Schedules"));
7104
+ console.log(chalk5.bold(" Schedules"));
6193
7105
  console.log("");
6194
7106
  console.log(` ${"Name".padEnd(20)} ${"Cron".padEnd(18)} ${"URL".padEnd(30)} ${"Enabled".padEnd(9)} ${"Next Run".padEnd(22)} Last Run`);
6195
7107
  console.log(` ${"\u2500".repeat(20)} ${"\u2500".repeat(18)} ${"\u2500".repeat(30)} ${"\u2500".repeat(9)} ${"\u2500".repeat(22)} ${"\u2500".repeat(22)}`);
6196
7108
  for (const s of schedules) {
6197
- const enabled = s.enabled ? chalk4.green("yes") : chalk4.red("no");
6198
- const nextRun = s.nextRunAt ?? chalk4.dim("\u2014");
6199
- const lastRun = s.lastRunAt ?? chalk4.dim("\u2014");
7109
+ const enabled = s.enabled ? chalk5.green("yes") : chalk5.red("no");
7110
+ const nextRun = s.nextRunAt ?? chalk5.dim("\u2014");
7111
+ const lastRun = s.lastRunAt ?? chalk5.dim("\u2014");
6200
7112
  console.log(` ${s.name.padEnd(20)} ${s.cronExpression.padEnd(18)} ${s.url.padEnd(30)} ${enabled.toString().padEnd(9)} ${nextRun.toString().padEnd(22)} ${lastRun}`);
6201
7113
  }
6202
7114
  console.log("");
6203
7115
  } catch (error) {
6204
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7116
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6205
7117
  process.exit(1);
6206
7118
  }
6207
7119
  });
@@ -6209,47 +7121,47 @@ scheduleCmd.command("show <id>").description("Show schedule details").action((id
6209
7121
  try {
6210
7122
  const schedule = getSchedule(id);
6211
7123
  if (!schedule) {
6212
- console.error(chalk4.red(`Schedule not found: ${id}`));
7124
+ console.error(chalk5.red(`Schedule not found: ${id}`));
6213
7125
  process.exit(1);
6214
7126
  }
6215
7127
  console.log("");
6216
- console.log(chalk4.bold(` Schedule: ${schedule.name}`));
7128
+ console.log(chalk5.bold(` Schedule: ${schedule.name}`));
6217
7129
  console.log(` ID: ${schedule.id}`);
6218
7130
  console.log(` Cron: ${schedule.cronExpression}`);
6219
7131
  console.log(` URL: ${schedule.url}`);
6220
- console.log(` Enabled: ${schedule.enabled ? chalk4.green("yes") : chalk4.red("no")}`);
6221
- console.log(` Model: ${schedule.model ?? chalk4.dim("default")}`);
7132
+ console.log(` Enabled: ${schedule.enabled ? chalk5.green("yes") : chalk5.red("no")}`);
7133
+ console.log(` Model: ${schedule.model ?? chalk5.dim("default")}`);
6222
7134
  console.log(` Headed: ${schedule.headed ? "yes" : "no"}`);
6223
7135
  console.log(` Parallel: ${schedule.parallel}`);
6224
- console.log(` Timeout: ${schedule.timeoutMs ? `${schedule.timeoutMs}ms` : chalk4.dim("default")}`);
6225
- console.log(` Project: ${schedule.projectId ?? chalk4.dim("none")}`);
7136
+ console.log(` Timeout: ${schedule.timeoutMs ? `${schedule.timeoutMs}ms` : chalk5.dim("default")}`);
7137
+ console.log(` Project: ${schedule.projectId ?? chalk5.dim("none")}`);
6226
7138
  console.log(` Filter: ${JSON.stringify(schedule.scenarioFilter)}`);
6227
- console.log(` Next run: ${schedule.nextRunAt ?? chalk4.dim("not scheduled")}`);
6228
- console.log(` Last run: ${schedule.lastRunAt ?? chalk4.dim("never")}`);
6229
- console.log(` Last run ID: ${schedule.lastRunId ?? chalk4.dim("none")}`);
7139
+ console.log(` Next run: ${schedule.nextRunAt ?? chalk5.dim("not scheduled")}`);
7140
+ console.log(` Last run: ${schedule.lastRunAt ?? chalk5.dim("never")}`);
7141
+ console.log(` Last run ID: ${schedule.lastRunId ?? chalk5.dim("none")}`);
6230
7142
  console.log(` Created: ${schedule.createdAt}`);
6231
7143
  console.log(` Updated: ${schedule.updatedAt}`);
6232
7144
  console.log("");
6233
7145
  } catch (error) {
6234
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7146
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6235
7147
  process.exit(1);
6236
7148
  }
6237
7149
  });
6238
7150
  scheduleCmd.command("enable <id>").description("Enable a schedule").action((id) => {
6239
7151
  try {
6240
7152
  const schedule = updateSchedule(id, { enabled: true });
6241
- console.log(chalk4.green(`Enabled schedule ${chalk4.bold(schedule.name)}`));
7153
+ console.log(chalk5.green(`Enabled schedule ${chalk5.bold(schedule.name)}`));
6242
7154
  } catch (error) {
6243
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7155
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6244
7156
  process.exit(1);
6245
7157
  }
6246
7158
  });
6247
7159
  scheduleCmd.command("disable <id>").description("Disable a schedule").action((id) => {
6248
7160
  try {
6249
7161
  const schedule = updateSchedule(id, { enabled: false });
6250
- console.log(chalk4.green(`Disabled schedule ${chalk4.bold(schedule.name)}`));
7162
+ console.log(chalk5.green(`Disabled schedule ${chalk5.bold(schedule.name)}`));
6251
7163
  } catch (error) {
6252
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7164
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6253
7165
  process.exit(1);
6254
7166
  }
6255
7167
  });
@@ -6257,13 +7169,13 @@ scheduleCmd.command("delete <id>").description("Delete a schedule").action((id)
6257
7169
  try {
6258
7170
  const deleted = deleteSchedule(id);
6259
7171
  if (deleted) {
6260
- console.log(chalk4.green(`Deleted schedule: ${id}`));
7172
+ console.log(chalk5.green(`Deleted schedule: ${id}`));
6261
7173
  } else {
6262
- console.error(chalk4.red(`Schedule not found: ${id}`));
7174
+ console.error(chalk5.red(`Schedule not found: ${id}`));
6263
7175
  process.exit(1);
6264
7176
  }
6265
7177
  } catch (error) {
6266
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7178
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6267
7179
  process.exit(1);
6268
7180
  }
6269
7181
  });
@@ -6271,11 +7183,11 @@ scheduleCmd.command("run <id>").description("Manually trigger a schedule").optio
6271
7183
  try {
6272
7184
  const schedule = getSchedule(id);
6273
7185
  if (!schedule) {
6274
- console.error(chalk4.red(`Schedule not found: ${id}`));
7186
+ console.error(chalk5.red(`Schedule not found: ${id}`));
6275
7187
  process.exit(1);
6276
7188
  return;
6277
7189
  }
6278
- console.log(chalk4.blue(`Running schedule ${chalk4.bold(schedule.name)} against ${schedule.url}...`));
7190
+ console.log(chalk5.blue(`Running schedule ${chalk5.bold(schedule.name)} against ${schedule.url}...`));
6279
7191
  const { run, results } = await runByFilter({
6280
7192
  url: schedule.url,
6281
7193
  tags: schedule.scenarioFilter.tags,
@@ -6294,15 +7206,15 @@ scheduleCmd.command("run <id>").description("Manually trigger a schedule").optio
6294
7206
  }
6295
7207
  process.exit(getExitCode(run));
6296
7208
  } catch (error) {
6297
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7209
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6298
7210
  process.exit(1);
6299
7211
  }
6300
7212
  });
6301
7213
  program2.command("daemon").description("Start the scheduler daemon").option("--interval <seconds>", "Check interval in seconds", "60").action(async (opts) => {
6302
7214
  try {
6303
7215
  const intervalMs = parseInt(opts.interval, 10) * 1000;
6304
- console.log(chalk4.blue("Scheduler daemon started. Press Ctrl+C to stop."));
6305
- console.log(chalk4.dim(` Check interval: ${opts.interval}s`));
7216
+ console.log(chalk5.blue("Scheduler daemon started. Press Ctrl+C to stop."));
7217
+ console.log(chalk5.dim(` Check interval: ${opts.interval}s`));
6306
7218
  let running = true;
6307
7219
  const checkAndRun = async () => {
6308
7220
  while (running) {
@@ -6311,7 +7223,7 @@ program2.command("daemon").description("Start the scheduler daemon").option("--i
6311
7223
  const now2 = new Date().toISOString();
6312
7224
  for (const schedule of schedules) {
6313
7225
  if (schedule.nextRunAt && schedule.nextRunAt <= now2) {
6314
- console.log(chalk4.blue(`[${new Date().toISOString()}] Triggering schedule: ${schedule.name}`));
7226
+ console.log(chalk5.blue(`[${new Date().toISOString()}] Triggering schedule: ${schedule.name}`));
6315
7227
  try {
6316
7228
  const { run } = await runByFilter({
6317
7229
  url: schedule.url,
@@ -6324,39 +7236,39 @@ program2.command("daemon").description("Start the scheduler daemon").option("--i
6324
7236
  timeout: schedule.timeoutMs ?? undefined,
6325
7237
  projectId: schedule.projectId ?? undefined
6326
7238
  });
6327
- const statusColor = run.status === "passed" ? chalk4.green : chalk4.red;
7239
+ const statusColor = run.status === "passed" ? chalk5.green : chalk5.red;
6328
7240
  console.log(` ${statusColor(run.status)} \u2014 ${run.passed}/${run.total} passed`);
6329
7241
  updateSchedule(schedule.id, {});
6330
7242
  } catch (err) {
6331
- console.error(chalk4.red(` Error running schedule ${schedule.name}: ${err instanceof Error ? err.message : String(err)}`));
7243
+ console.error(chalk5.red(` Error running schedule ${schedule.name}: ${err instanceof Error ? err.message : String(err)}`));
6332
7244
  }
6333
7245
  }
6334
7246
  }
6335
7247
  } catch (err) {
6336
- console.error(chalk4.red(`Daemon error: ${err instanceof Error ? err.message : String(err)}`));
7248
+ console.error(chalk5.red(`Daemon error: ${err instanceof Error ? err.message : String(err)}`));
6337
7249
  }
6338
7250
  await new Promise((resolve2) => setTimeout(resolve2, intervalMs));
6339
7251
  }
6340
7252
  };
6341
7253
  process.on("SIGINT", () => {
6342
- console.log(chalk4.yellow(`
7254
+ console.log(chalk5.yellow(`
6343
7255
  Shutting down scheduler daemon...`));
6344
7256
  running = false;
6345
7257
  process.exit(0);
6346
7258
  });
6347
7259
  process.on("SIGTERM", () => {
6348
- console.log(chalk4.yellow(`
7260
+ console.log(chalk5.yellow(`
6349
7261
  Shutting down scheduler daemon...`));
6350
7262
  running = false;
6351
7263
  process.exit(0);
6352
7264
  });
6353
7265
  await checkAndRun();
6354
7266
  } catch (error) {
6355
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7267
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6356
7268
  process.exit(1);
6357
7269
  }
6358
7270
  });
6359
- 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").action((opts) => {
7271
+ program2.command("init").description("Initialize a new testing project").option("-n, --name <name>", "Project name").option("-u, --url <url>", "Base URL").option("-p, --path <path>", "Project path").option("--ci <provider>", "Generate CI workflow (github)").action((opts) => {
6360
7272
  try {
6361
7273
  const { project, scenarios, framework } = initProject({
6362
7274
  name: opts.name,
@@ -6364,30 +7276,41 @@ program2.command("init").description("Initialize a new testing project").option(
6364
7276
  path: opts.path
6365
7277
  });
6366
7278
  console.log("");
6367
- console.log(chalk4.bold(" Project initialized!"));
7279
+ console.log(chalk5.bold(" Project initialized!"));
6368
7280
  console.log("");
6369
7281
  if (framework) {
6370
- console.log(` Framework: ${chalk4.cyan(framework.name)}`);
7282
+ console.log(` Framework: ${chalk5.cyan(framework.name)}`);
6371
7283
  if (framework.features.length > 0) {
6372
- console.log(` Features: ${chalk4.dim(framework.features.join(", "))}`);
7284
+ console.log(` Features: ${chalk5.dim(framework.features.join(", "))}`);
6373
7285
  }
6374
7286
  } else {
6375
- console.log(` Framework: ${chalk4.dim("not detected")}`);
7287
+ console.log(` Framework: ${chalk5.dim("not detected")}`);
6376
7288
  }
6377
- console.log(` Project: ${chalk4.green(project.name)} ${chalk4.dim(`(${project.id})`)}`);
6378
- console.log(` Scenarios: ${chalk4.green(String(scenarios.length))} starter scenarios created`);
7289
+ console.log(` Project: ${chalk5.green(project.name)} ${chalk5.dim(`(${project.id})`)}`);
7290
+ console.log(` Scenarios: ${chalk5.green(String(scenarios.length))} starter scenarios created`);
6379
7291
  console.log("");
6380
7292
  for (const s of scenarios) {
6381
- console.log(` ${chalk4.dim(s.shortId)} ${s.name} ${chalk4.dim(`[${s.tags.join(", ")}]`)}`);
7293
+ console.log(` ${chalk5.dim(s.shortId)} ${s.name} ${chalk5.dim(`[${s.tags.join(", ")}]`)}`);
7294
+ }
7295
+ if (opts.ci === "github") {
7296
+ const workflowDir = join6(process.cwd(), ".github", "workflows");
7297
+ if (!existsSync8(workflowDir)) {
7298
+ mkdirSync4(workflowDir, { recursive: true });
7299
+ }
7300
+ const workflowPath = join6(workflowDir, "testers.yml");
7301
+ writeFileSync3(workflowPath, generateGitHubActionsWorkflow(), "utf-8");
7302
+ console.log(` CI: ${chalk5.green("GitHub Actions workflow written to .github/workflows/testers.yml")}`);
7303
+ } else if (opts.ci) {
7304
+ console.log(chalk5.yellow(` Unknown CI provider: ${opts.ci}. Supported: github`));
6382
7305
  }
6383
7306
  console.log("");
6384
- console.log(chalk4.bold(" Next steps:"));
7307
+ console.log(chalk5.bold(" Next steps:"));
6385
7308
  console.log(` 1. Start your dev server`);
6386
- console.log(` 2. Run ${chalk4.cyan("testers run <url>")} to execute tests`);
6387
- console.log(` 3. Add more scenarios with ${chalk4.cyan("testers add <name>")}`);
7309
+ console.log(` 2. Run ${chalk5.cyan("testers run <url>")} to execute tests`);
7310
+ console.log(` 3. Add more scenarios with ${chalk5.cyan("testers add <name>")}`);
6388
7311
  console.log("");
6389
7312
  } catch (error) {
6390
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7313
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6391
7314
  process.exit(1);
6392
7315
  }
6393
7316
  });
@@ -6395,16 +7318,16 @@ program2.command("replay <run-id>").description("Re-run all scenarios from a pre
6395
7318
  try {
6396
7319
  const originalRun = getRun(runId);
6397
7320
  if (!originalRun) {
6398
- console.error(chalk4.red(`Run not found: ${runId}`));
7321
+ console.error(chalk5.red(`Run not found: ${runId}`));
6399
7322
  process.exit(1);
6400
7323
  }
6401
7324
  const originalResults = getResultsByRun(originalRun.id);
6402
7325
  const scenarioIds = originalResults.map((r) => r.scenarioId);
6403
7326
  if (scenarioIds.length === 0) {
6404
- console.log(chalk4.dim("No scenarios to replay."));
7327
+ console.log(chalk5.dim("No scenarios to replay."));
6405
7328
  return;
6406
7329
  }
6407
- console.log(chalk4.blue(`Replaying ${scenarioIds.length} scenarios from run ${originalRun.id.slice(0, 8)}...`));
7330
+ console.log(chalk5.blue(`Replaying ${scenarioIds.length} scenarios from run ${originalRun.id.slice(0, 8)}...`));
6408
7331
  const { run, results } = await runByFilter({
6409
7332
  url: opts.url ?? originalRun.url,
6410
7333
  scenarioIds,
@@ -6419,7 +7342,7 @@ program2.command("replay <run-id>").description("Re-run all scenarios from a pre
6419
7342
  }
6420
7343
  process.exit(getExitCode(run));
6421
7344
  } catch (error) {
6422
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7345
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6423
7346
  process.exit(1);
6424
7347
  }
6425
7348
  });
@@ -6427,16 +7350,16 @@ program2.command("retry <run-id>").description("Re-run only failed scenarios fro
6427
7350
  try {
6428
7351
  const originalRun = getRun(runId);
6429
7352
  if (!originalRun) {
6430
- console.error(chalk4.red(`Run not found: ${runId}`));
7353
+ console.error(chalk5.red(`Run not found: ${runId}`));
6431
7354
  process.exit(1);
6432
7355
  }
6433
7356
  const originalResults = getResultsByRun(originalRun.id);
6434
7357
  const failedScenarioIds = originalResults.filter((r) => r.status === "failed" || r.status === "error").map((r) => r.scenarioId);
6435
7358
  if (failedScenarioIds.length === 0) {
6436
- console.log(chalk4.green("No failed scenarios to retry. All passed!"));
7359
+ console.log(chalk5.green("No failed scenarios to retry. All passed!"));
6437
7360
  return;
6438
7361
  }
6439
- console.log(chalk4.blue(`Retrying ${failedScenarioIds.length} failed scenarios from run ${originalRun.id.slice(0, 8)}...`));
7362
+ console.log(chalk5.blue(`Retrying ${failedScenarioIds.length} failed scenarios from run ${originalRun.id.slice(0, 8)}...`));
6440
7363
  const { run, results } = await runByFilter({
6441
7364
  url: opts.url ?? originalRun.url,
6442
7365
  scenarioIds: failedScenarioIds,
@@ -6446,13 +7369,13 @@ program2.command("retry <run-id>").description("Re-run only failed scenarios fro
6446
7369
  });
6447
7370
  if (!opts.json) {
6448
7371
  console.log("");
6449
- console.log(chalk4.bold(" Comparison with original run:"));
7372
+ console.log(chalk5.bold(" Comparison with original run:"));
6450
7373
  for (const result of results) {
6451
7374
  const original = originalResults.find((r) => r.scenarioId === result.scenarioId);
6452
7375
  if (original) {
6453
7376
  const changed = original.status !== result.status;
6454
- const arrow = changed ? chalk4.yellow(`${original.status} \u2192 ${result.status}`) : chalk4.dim(`${result.status} (unchanged)`);
6455
- const icon = result.status === "passed" ? chalk4.green("\u2713") : chalk4.red("\u2717");
7377
+ const arrow = changed ? chalk5.yellow(`${original.status} \u2192 ${result.status}`) : chalk5.dim(`${result.status} (unchanged)`);
7378
+ const icon = result.status === "passed" ? chalk5.green("\u2713") : chalk5.red("\u2717");
6456
7379
  console.log(` ${icon} ${result.scenarioId.slice(0, 8)}: ${arrow}`);
6457
7380
  }
6458
7381
  }
@@ -6465,14 +7388,14 @@ program2.command("retry <run-id>").description("Re-run only failed scenarios fro
6465
7388
  }
6466
7389
  process.exit(getExitCode(run));
6467
7390
  } catch (error) {
6468
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7391
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6469
7392
  process.exit(1);
6470
7393
  }
6471
7394
  });
6472
7395
  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) => {
6473
7396
  try {
6474
7397
  const projectId = resolveProject(opts.project);
6475
- console.log(chalk4.blue(`Running smoke test against ${chalk4.bold(url)}...`));
7398
+ console.log(chalk5.blue(`Running smoke test against ${chalk5.bold(url)}...`));
6476
7399
  console.log("");
6477
7400
  const smokeResult = await runSmoke({
6478
7401
  url,
@@ -6494,11 +7417,11 @@ program2.command("smoke <url>").description("Run autonomous smoke test").option(
6494
7417
  const hasCritical = smokeResult.issuesFound.some((i) => i.severity === "critical" || i.severity === "high");
6495
7418
  process.exit(hasCritical ? 1 : 0);
6496
7419
  } catch (error) {
6497
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7420
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6498
7421
  process.exit(1);
6499
7422
  }
6500
7423
  });
6501
- program2.command("diff <run1> <run2>").description("Compare two test runs").option("--json", "JSON output", false).action((run1, run2, opts) => {
7424
+ program2.command("diff <run1> <run2>").description("Compare two test runs").option("--json", "JSON output", false).option("--threshold <percent>", "Visual diff threshold percentage", "0.1").action((run1, run2, opts) => {
6502
7425
  try {
6503
7426
  const diff = diffRuns(run1, run2);
6504
7427
  if (opts.json) {
@@ -6506,9 +7429,19 @@ program2.command("diff <run1> <run2>").description("Compare two test runs").opti
6506
7429
  } else {
6507
7430
  console.log(formatDiffTerminal(diff));
6508
7431
  }
6509
- process.exit(diff.regressions.length > 0 ? 1 : 0);
7432
+ const threshold = parseFloat(opts.threshold);
7433
+ const visualResults = compareRunScreenshots(run2, run1, threshold);
7434
+ if (visualResults.length > 0) {
7435
+ if (opts.json) {
7436
+ console.log(JSON.stringify({ visualDiff: visualResults }, null, 2));
7437
+ } else {
7438
+ console.log(formatVisualDiffTerminal(visualResults, threshold));
7439
+ }
7440
+ }
7441
+ const hasVisualRegressions = visualResults.some((r) => r.isRegression);
7442
+ process.exit(diff.regressions.length > 0 || hasVisualRegressions ? 1 : 0);
6510
7443
  } catch (error) {
6511
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7444
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6512
7445
  process.exit(1);
6513
7446
  }
6514
7447
  });
@@ -6521,9 +7454,9 @@ program2.command("report [run-id]").description("Generate HTML test report").opt
6521
7454
  html = generateHtmlReport(runId);
6522
7455
  }
6523
7456
  writeFileSync3(opts.output, html, "utf-8");
6524
- console.log(chalk4.green(`Report generated: ${opts.output}`));
7457
+ console.log(chalk5.green(`Report generated: ${opts.output}`));
6525
7458
  } catch (error) {
6526
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7459
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6527
7460
  process.exit(1);
6528
7461
  }
6529
7462
  });
@@ -6536,9 +7469,9 @@ authCmd.command("add <name>").description("Create an auth preset").requiredOptio
6536
7469
  password: opts.password,
6537
7470
  loginPath: opts.loginPath
6538
7471
  });
6539
- console.log(chalk4.green(`Created auth preset ${chalk4.bold(preset.name)} (${preset.email})`));
7472
+ console.log(chalk5.green(`Created auth preset ${chalk5.bold(preset.name)} (${preset.email})`));
6540
7473
  } catch (error) {
6541
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7474
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6542
7475
  process.exit(1);
6543
7476
  }
6544
7477
  });
@@ -6546,11 +7479,11 @@ authCmd.command("list").description("List auth presets").action(() => {
6546
7479
  try {
6547
7480
  const presets = listAuthPresets();
6548
7481
  if (presets.length === 0) {
6549
- console.log(chalk4.dim("No auth presets found."));
7482
+ console.log(chalk5.dim("No auth presets found."));
6550
7483
  return;
6551
7484
  }
6552
7485
  console.log("");
6553
- console.log(chalk4.bold(" Auth Presets"));
7486
+ console.log(chalk5.bold(" Auth Presets"));
6554
7487
  console.log("");
6555
7488
  console.log(` ${"Name".padEnd(20)} ${"Email".padEnd(30)} ${"Login Path".padEnd(15)} Created`);
6556
7489
  console.log(` ${"\u2500".repeat(20)} ${"\u2500".repeat(30)} ${"\u2500".repeat(15)} ${"\u2500".repeat(22)}`);
@@ -6559,7 +7492,7 @@ authCmd.command("list").description("List auth presets").action(() => {
6559
7492
  }
6560
7493
  console.log("");
6561
7494
  } catch (error) {
6562
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7495
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6563
7496
  process.exit(1);
6564
7497
  }
6565
7498
  });
@@ -6567,13 +7500,13 @@ authCmd.command("delete <name>").description("Delete an auth preset").action((na
6567
7500
  try {
6568
7501
  const deleted = deleteAuthPreset(name);
6569
7502
  if (deleted) {
6570
- console.log(chalk4.green(`Deleted auth preset: ${name}`));
7503
+ console.log(chalk5.green(`Deleted auth preset: ${name}`));
6571
7504
  } else {
6572
- console.error(chalk4.red(`Auth preset not found: ${name}`));
7505
+ console.error(chalk5.red(`Auth preset not found: ${name}`));
6573
7506
  process.exit(1);
6574
7507
  }
6575
7508
  } catch (error) {
6576
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7509
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6577
7510
  process.exit(1);
6578
7511
  }
6579
7512
  });
@@ -6586,7 +7519,7 @@ program2.command("costs").description("Show cost tracking and budget status").op
6586
7519
  console.log(formatCostsTerminal(summary));
6587
7520
  }
6588
7521
  } catch (error) {
6589
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7522
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6590
7523
  process.exit(1);
6591
7524
  }
6592
7525
  });
@@ -6594,18 +7527,18 @@ program2.command("chain <scenario-id>").description("Add a dependency to a scena
6594
7527
  try {
6595
7528
  const scenario = getScenario(scenarioId) ?? getScenarioByShortId(scenarioId);
6596
7529
  if (!scenario) {
6597
- console.error(chalk4.red(`Scenario not found: ${scenarioId}`));
7530
+ console.error(chalk5.red(`Scenario not found: ${scenarioId}`));
6598
7531
  process.exit(1);
6599
7532
  }
6600
7533
  const dep = getScenario(opts.dependsOn) ?? getScenarioByShortId(opts.dependsOn);
6601
7534
  if (!dep) {
6602
- console.error(chalk4.red(`Dependency scenario not found: ${opts.dependsOn}`));
7535
+ console.error(chalk5.red(`Dependency scenario not found: ${opts.dependsOn}`));
6603
7536
  process.exit(1);
6604
7537
  }
6605
7538
  addDependency(scenario.id, dep.id);
6606
- console.log(chalk4.green(`${scenario.shortId} now depends on ${dep.shortId}`));
7539
+ console.log(chalk5.green(`${scenario.shortId} now depends on ${dep.shortId}`));
6607
7540
  } catch (error) {
6608
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7541
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6609
7542
  process.exit(1);
6610
7543
  }
6611
7544
  });
@@ -6613,18 +7546,18 @@ program2.command("unchain <scenario-id>").description("Remove a dependency from
6613
7546
  try {
6614
7547
  const scenario = getScenario(scenarioId) ?? getScenarioByShortId(scenarioId);
6615
7548
  if (!scenario) {
6616
- console.error(chalk4.red(`Scenario not found: ${scenarioId}`));
7549
+ console.error(chalk5.red(`Scenario not found: ${scenarioId}`));
6617
7550
  process.exit(1);
6618
7551
  }
6619
7552
  const dep = getScenario(opts.from) ?? getScenarioByShortId(opts.from);
6620
7553
  if (!dep) {
6621
- console.error(chalk4.red(`Dependency not found: ${opts.from}`));
7554
+ console.error(chalk5.red(`Dependency not found: ${opts.from}`));
6622
7555
  process.exit(1);
6623
7556
  }
6624
7557
  removeDependency(scenario.id, dep.id);
6625
- console.log(chalk4.green(`Removed dependency: ${scenario.shortId} no longer depends on ${dep.shortId}`));
7558
+ console.log(chalk5.green(`Removed dependency: ${scenario.shortId} no longer depends on ${dep.shortId}`));
6626
7559
  } catch (error) {
6627
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7560
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6628
7561
  process.exit(1);
6629
7562
  }
6630
7563
  });
@@ -6632,26 +7565,26 @@ program2.command("deps <scenario-id>").description("Show dependencies for a scen
6632
7565
  try {
6633
7566
  const scenario = getScenario(scenarioId) ?? getScenarioByShortId(scenarioId);
6634
7567
  if (!scenario) {
6635
- console.error(chalk4.red(`Scenario not found: ${scenarioId}`));
7568
+ console.error(chalk5.red(`Scenario not found: ${scenarioId}`));
6636
7569
  process.exit(1);
6637
7570
  }
6638
7571
  const deps = getDependencies(scenario.id);
6639
7572
  const dependents = getDependents(scenario.id);
6640
7573
  console.log("");
6641
- console.log(chalk4.bold(` Dependencies for ${scenario.shortId}: ${scenario.name}`));
7574
+ console.log(chalk5.bold(` Dependencies for ${scenario.shortId}: ${scenario.name}`));
6642
7575
  console.log("");
6643
7576
  if (deps.length > 0) {
6644
- console.log(chalk4.dim(" Depends on:"));
7577
+ console.log(chalk5.dim(" Depends on:"));
6645
7578
  for (const depId of deps) {
6646
7579
  const s = getScenario(depId);
6647
7580
  console.log(` \u2192 ${s ? `${s.shortId}: ${s.name}` : depId.slice(0, 8)}`);
6648
7581
  }
6649
7582
  } else {
6650
- console.log(chalk4.dim(" No dependencies"));
7583
+ console.log(chalk5.dim(" No dependencies"));
6651
7584
  }
6652
7585
  if (dependents.length > 0) {
6653
7586
  console.log("");
6654
- console.log(chalk4.dim(" Required by:"));
7587
+ console.log(chalk5.dim(" Required by:"));
6655
7588
  for (const depId of dependents) {
6656
7589
  const s = getScenario(depId);
6657
7590
  console.log(` \u2190 ${s ? `${s.shortId}: ${s.name}` : depId.slice(0, 8)}`);
@@ -6659,7 +7592,7 @@ program2.command("deps <scenario-id>").description("Show dependencies for a scen
6659
7592
  }
6660
7593
  console.log("");
6661
7594
  } catch (error) {
6662
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7595
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6663
7596
  process.exit(1);
6664
7597
  }
6665
7598
  });
@@ -6669,7 +7602,7 @@ flowCmd.command("create <name>").description("Create a flow from scenario IDs").
6669
7602
  const ids = opts.chain.split(",").map((id) => {
6670
7603
  const s = getScenario(id.trim()) ?? getScenarioByShortId(id.trim());
6671
7604
  if (!s) {
6672
- console.error(chalk4.red(`Scenario not found: ${id.trim()}`));
7605
+ console.error(chalk5.red(`Scenario not found: ${id.trim()}`));
6673
7606
  process.exit(1);
6674
7607
  }
6675
7608
  return s.id;
@@ -6680,37 +7613,37 @@ flowCmd.command("create <name>").description("Create a flow from scenario IDs").
6680
7613
  } catch {}
6681
7614
  }
6682
7615
  const flow = createFlow({ name, scenarioIds: ids, projectId: resolveProject(opts.project) });
6683
- console.log(chalk4.green(`Flow created: ${flow.id.slice(0, 8)} \u2014 ${flow.name} (${ids.length} scenarios)`));
7616
+ console.log(chalk5.green(`Flow created: ${flow.id.slice(0, 8)} \u2014 ${flow.name} (${ids.length} scenarios)`));
6684
7617
  } catch (error) {
6685
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7618
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6686
7619
  process.exit(1);
6687
7620
  }
6688
7621
  });
6689
7622
  flowCmd.command("list").description("List all flows").option("--project <id>", "Project ID").action((opts) => {
6690
7623
  const flows = listFlows(resolveProject(opts.project) ?? undefined);
6691
7624
  if (flows.length === 0) {
6692
- console.log(chalk4.dim(`
7625
+ console.log(chalk5.dim(`
6693
7626
  No flows found.
6694
7627
  `));
6695
7628
  return;
6696
7629
  }
6697
7630
  console.log("");
6698
- console.log(chalk4.bold(" Flows"));
7631
+ console.log(chalk5.bold(" Flows"));
6699
7632
  console.log("");
6700
7633
  for (const f of flows) {
6701
- console.log(` ${chalk4.dim(f.id.slice(0, 8))} ${f.name} ${chalk4.dim(`(${f.scenarioIds.length} scenarios)`)}`);
7634
+ console.log(` ${chalk5.dim(f.id.slice(0, 8))} ${f.name} ${chalk5.dim(`(${f.scenarioIds.length} scenarios)`)}`);
6702
7635
  }
6703
7636
  console.log("");
6704
7637
  });
6705
7638
  flowCmd.command("show <id>").description("Show flow details").action((id) => {
6706
7639
  const flow = getFlow(id);
6707
7640
  if (!flow) {
6708
- console.error(chalk4.red(`Flow not found: ${id}`));
7641
+ console.error(chalk5.red(`Flow not found: ${id}`));
6709
7642
  process.exit(1);
6710
7643
  }
6711
7644
  console.log("");
6712
- console.log(chalk4.bold(` Flow: ${flow.name}`));
6713
- console.log(` ID: ${chalk4.dim(flow.id)}`);
7645
+ console.log(chalk5.bold(` Flow: ${flow.name}`));
7646
+ console.log(` ID: ${chalk5.dim(flow.id)}`);
6714
7647
  console.log(` Scenarios (in order):`);
6715
7648
  for (let i = 0;i < flow.scenarioIds.length; i++) {
6716
7649
  const s = getScenario(flow.scenarioIds[i]);
@@ -6720,9 +7653,9 @@ flowCmd.command("show <id>").description("Show flow details").action((id) => {
6720
7653
  });
6721
7654
  flowCmd.command("delete <id>").description("Delete a flow").action((id) => {
6722
7655
  if (deleteFlow(id))
6723
- console.log(chalk4.green("Flow deleted."));
7656
+ console.log(chalk5.green("Flow deleted."));
6724
7657
  else {
6725
- console.error(chalk4.red("Flow not found."));
7658
+ console.error(chalk5.red("Flow not found."));
6726
7659
  process.exit(1);
6727
7660
  }
6728
7661
  });
@@ -6730,14 +7663,14 @@ flowCmd.command("run <id>").description("Run a flow (scenarios in dependency ord
6730
7663
  try {
6731
7664
  const flow = getFlow(id);
6732
7665
  if (!flow) {
6733
- console.error(chalk4.red(`Flow not found: ${id}`));
7666
+ console.error(chalk5.red(`Flow not found: ${id}`));
6734
7667
  process.exit(1);
6735
7668
  }
6736
7669
  if (!opts.url) {
6737
- console.error(chalk4.red("--url is required for flow run"));
7670
+ console.error(chalk5.red("--url is required for flow run"));
6738
7671
  process.exit(1);
6739
7672
  }
6740
- console.log(chalk4.blue(`Running flow: ${flow.name} (${flow.scenarioIds.length} scenarios)`));
7673
+ console.log(chalk5.blue(`Running flow: ${flow.name} (${flow.scenarioIds.length} scenarios)`));
6741
7674
  const { run, results } = await runByFilter({
6742
7675
  url: opts.url,
6743
7676
  scenarioIds: flow.scenarioIds,
@@ -6751,7 +7684,103 @@ flowCmd.command("run <id>").description("Run a flow (scenarios in dependency ord
6751
7684
  console.log(formatTerminal(run, results));
6752
7685
  process.exit(getExitCode(run));
6753
7686
  } catch (error) {
6754
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7687
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7688
+ process.exit(1);
7689
+ }
7690
+ });
7691
+ var envCmd = program2.command("env").description("Manage environments");
7692
+ envCmd.command("add <name>").description("Add a named environment").requiredOption("--url <url>", "Environment URL").option("--auth <preset>", "Auth preset name").option("--project <id>", "Project ID").option("--default", "Set as default environment", false).action((name, opts) => {
7693
+ try {
7694
+ const env = createEnvironment({
7695
+ name,
7696
+ url: opts.url,
7697
+ authPresetName: opts.auth,
7698
+ projectId: opts.project,
7699
+ isDefault: opts.default
7700
+ });
7701
+ console.log(chalk5.green(`Environment added: ${env.name} \u2192 ${env.url}${env.isDefault ? " (default)" : ""}`));
7702
+ } catch (error) {
7703
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7704
+ process.exit(1);
7705
+ }
7706
+ });
7707
+ envCmd.command("list").description("List all environments").option("--project <id>", "Filter by project ID").action((opts) => {
7708
+ try {
7709
+ const envs = listEnvironments(opts.project);
7710
+ if (envs.length === 0) {
7711
+ console.log(chalk5.dim("No environments configured. Add one with: testers env add <name> --url <url>"));
7712
+ return;
7713
+ }
7714
+ for (const env of envs) {
7715
+ const marker = env.isDefault ? chalk5.green(" \u2605 default") : "";
7716
+ const auth = env.authPresetName ? chalk5.dim(` (auth: ${env.authPresetName})`) : "";
7717
+ console.log(` ${chalk5.bold(env.name)} ${env.url}${auth}${marker}`);
7718
+ }
7719
+ } catch (error) {
7720
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7721
+ process.exit(1);
7722
+ }
7723
+ });
7724
+ envCmd.command("use <name>").description("Set an environment as the default").action((name) => {
7725
+ try {
7726
+ setDefaultEnvironment(name);
7727
+ const env = getEnvironment(name);
7728
+ console.log(chalk5.green(`Default environment set: ${env.name} \u2192 ${env.url}`));
7729
+ } catch (error) {
7730
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7731
+ process.exit(1);
7732
+ }
7733
+ });
7734
+ envCmd.command("delete <name>").description("Delete an environment").action((name) => {
7735
+ try {
7736
+ const deleted = deleteEnvironment(name);
7737
+ if (deleted) {
7738
+ console.log(chalk5.green(`Environment deleted: ${name}`));
7739
+ } else {
7740
+ console.error(chalk5.red(`Environment not found: ${name}`));
7741
+ process.exit(1);
7742
+ }
7743
+ } catch (error) {
7744
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7745
+ process.exit(1);
7746
+ }
7747
+ });
7748
+ program2.command("baseline <run-id>").description("Set a run as the visual baseline").action((runId) => {
7749
+ try {
7750
+ setBaseline(runId);
7751
+ const run = getRun(runId);
7752
+ console.log(chalk5.green(`Baseline set: ${chalk5.bold(runId.slice(0, 8))}${run ? ` (${run.status}, ${run.total} scenarios)` : ""}`));
7753
+ } catch (error) {
7754
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7755
+ process.exit(1);
7756
+ }
7757
+ });
7758
+ program2.command("import-api <spec>").description("Import test scenarios from an OpenAPI/Swagger spec file").option("--project <id>", "Project ID").action(async (spec, opts) => {
7759
+ try {
7760
+ const { importFromOpenAPI: importFromOpenAPI2 } = await Promise.resolve().then(() => (init_openapi_import(), exports_openapi_import));
7761
+ const { imported, scenarios } = importFromOpenAPI2(spec, resolveProject(opts.project) ?? undefined);
7762
+ console.log(chalk5.green(`
7763
+ Imported ${imported} scenarios from API spec:`));
7764
+ for (const s of scenarios) {
7765
+ console.log(` ${chalk5.cyan(s.shortId)} ${s.name} ${chalk5.dim(`[${s.tags.join(", ")}]`)}`);
7766
+ }
7767
+ console.log("");
7768
+ } catch (error) {
7769
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7770
+ process.exit(1);
7771
+ }
7772
+ });
7773
+ program2.command("record <url>").description("Record a browser session and generate a test scenario").option("-n, --name <name>", "Scenario name", "Recorded session").option("--project <id>", "Project ID").action(async (url, opts) => {
7774
+ try {
7775
+ const { recordAndSave: recordAndSave2 } = await Promise.resolve().then(() => (init_recorder(), exports_recorder));
7776
+ console.log(chalk5.blue("Opening browser for recording..."));
7777
+ const { recording, scenario } = await recordAndSave2(url, opts.name, resolveProject(opts.project) ?? undefined);
7778
+ console.log("");
7779
+ console.log(chalk5.green(`Recording saved as scenario ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
7780
+ console.log(chalk5.dim(` ${recording.actions.length} actions recorded in ${(recording.duration / 1000).toFixed(0)}s`));
7781
+ console.log(chalk5.dim(` ${scenario.steps.length} steps generated`));
7782
+ } catch (error) {
7783
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6755
7784
  process.exit(1);
6756
7785
  }
6757
7786
  });