@hasna/testers 0.0.6 → 0.0.7

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
  }
@@ -2751,194 +2946,287 @@ var init_flows = __esm(() => {
2751
2946
  init_types();
2752
2947
  });
2753
2948
 
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}`;
2949
+ // src/lib/openapi-import.ts
2950
+ var exports_openapi_import = {};
2951
+ __export(exports_openapi_import, {
2952
+ parseOpenAPISpec: () => parseOpenAPISpec,
2953
+ importFromOpenAPI: () => importFromOpenAPI
2954
+ });
2955
+ import { readFileSync as readFileSync5 } from "fs";
2956
+ function parseSpec(content) {
2957
+ try {
2958
+ return JSON.parse(content);
2959
+ } catch {
2960
+ 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`");
2961
+ }
2962
+ }
2963
+ function methodPriority(method) {
2964
+ switch (method.toUpperCase()) {
2965
+ case "GET":
2966
+ return "medium";
2967
+ case "POST":
2968
+ return "high";
2969
+ case "PUT":
2970
+ return "high";
2971
+ case "DELETE":
2972
+ return "critical";
2973
+ case "PATCH":
2974
+ return "medium";
2975
+ default:
2976
+ return "low";
2977
+ }
2978
+ }
2979
+ function parseOpenAPISpec(filePathOrUrl) {
2980
+ let content;
2981
+ if (filePathOrUrl.startsWith("http")) {
2982
+ throw new Error("URL fetching not supported yet. Download the spec file first.");
2983
+ }
2984
+ content = readFileSync5(filePathOrUrl, "utf-8");
2985
+ const spec = parseSpec(content);
2986
+ const isOpenAPI3 = !!spec.openapi;
2987
+ const isSwagger2 = !!spec.swagger;
2988
+ if (!isOpenAPI3 && !isSwagger2) {
2989
+ throw new Error("Not a valid OpenAPI 3.x or Swagger 2.0 spec");
2990
+ }
2991
+ const scenarios = [];
2992
+ const paths = spec.paths ?? {};
2993
+ for (const [path, methods] of Object.entries(paths)) {
2994
+ for (const [method, operation] of Object.entries(methods)) {
2995
+ if (["get", "post", "put", "delete", "patch"].indexOf(method.toLowerCase()) === -1)
2996
+ continue;
2997
+ const op = operation;
2998
+ const name = op.summary ?? op.operationId ?? `${method.toUpperCase()} ${path}`;
2999
+ const tags = op.tags ?? [];
3000
+ const requiresAuth = !!(op.security?.length ?? spec.security?.length);
3001
+ const steps = [];
3002
+ steps.push(`Navigate to the API endpoint: ${method.toUpperCase()} ${path}`);
3003
+ if (op.parameters?.length) {
3004
+ const required = op.parameters.filter((p) => p.required);
3005
+ if (required.length > 0) {
3006
+ steps.push(`Fill required parameters: ${required.map((p) => p.name).join(", ")}`);
3007
+ }
3008
+ }
3009
+ if (["post", "put", "patch"].includes(method.toLowerCase())) {
3010
+ steps.push("Fill the request body with valid test data");
3011
+ }
3012
+ steps.push("Submit the request");
3013
+ const responses = op.responses ?? {};
3014
+ const successCodes = Object.keys(responses).filter((c) => c.startsWith("2"));
3015
+ if (successCodes.length > 0) {
3016
+ steps.push(`Verify response status is ${successCodes.join(" or ")}`);
3017
+ } else {
3018
+ steps.push("Verify the response is successful");
3019
+ }
3020
+ const description = [
3021
+ op.description ?? `Test the ${method.toUpperCase()} ${path} endpoint.`,
3022
+ requiresAuth ? "This endpoint requires authentication." : ""
3023
+ ].filter(Boolean).join(" ");
3024
+ scenarios.push({
3025
+ name,
3026
+ description,
3027
+ steps,
3028
+ tags: [...tags, "api", method.toLowerCase()],
3029
+ priority: methodPriority(method),
3030
+ targetPath: path,
3031
+ requiresAuth
3032
+ });
2786
3033
  }
2787
3034
  }
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;
3035
+ return scenarios;
2816
3036
  }
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;
3037
+ function importFromOpenAPI(filePathOrUrl, projectId) {
3038
+ const inputs = parseOpenAPISpec(filePathOrUrl);
3039
+ const scenarios = inputs.map((input) => createScenario({ ...input, projectId }));
3040
+ return { imported: scenarios.length, scenarios };
2821
3041
  }
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}"%`);
3042
+ var init_openapi_import = __esm(() => {
3043
+ init_scenarios();
3044
+ });
3045
+
3046
+ // src/lib/recorder.ts
3047
+ var exports_recorder = {};
3048
+ __export(exports_recorder, {
3049
+ recordSession: () => recordSession,
3050
+ recordAndSave: () => recordAndSave,
3051
+ actionsToScenarioInput: () => actionsToScenarioInput
3052
+ });
3053
+ import { chromium as chromium2 } from "playwright";
3054
+ async function recordSession(url, options) {
3055
+ const browser = await chromium2.launch({ headless: false });
3056
+ const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
3057
+ const page = await context.newPage();
3058
+ const actions = [];
3059
+ const startTime = Date.now();
3060
+ const timeout = options?.timeout ?? 300000;
3061
+ page.on("framenavigated", (frame) => {
3062
+ if (frame === page.mainFrame()) {
3063
+ actions.push({ type: "navigate", url: frame.url(), timestamp: Date.now() - startTime });
3064
+ }
3065
+ });
3066
+ await page.addInitScript(() => {
3067
+ document.addEventListener("click", (e) => {
3068
+ const target = e.target;
3069
+ const selector = buildSelector(target);
3070
+ window.postMessage({ __testers_action: "click", selector }, "*");
3071
+ }, true);
3072
+ document.addEventListener("input", (e) => {
3073
+ const target = e.target;
3074
+ const selector = buildSelector(target);
3075
+ window.postMessage({ __testers_action: "fill", selector, value: target.value }, "*");
3076
+ }, true);
3077
+ document.addEventListener("change", (e) => {
3078
+ const target = e.target;
3079
+ if (target.tagName === "SELECT") {
3080
+ const selector = buildSelector(target);
3081
+ window.postMessage({ __testers_action: "select", selector, value: target.value }, "*");
3082
+ }
3083
+ }, true);
3084
+ document.addEventListener("keydown", (e) => {
3085
+ if (["Enter", "Tab", "Escape"].includes(e.key)) {
3086
+ window.postMessage({ __testers_action: "press", key: e.key }, "*");
3087
+ }
3088
+ }, true);
3089
+ function buildSelector(el) {
3090
+ if (el.id)
3091
+ return `#${el.id}`;
3092
+ if (el.getAttribute("data-testid"))
3093
+ return `[data-testid="${el.getAttribute("data-testid")}"]`;
3094
+ if (el.getAttribute("name"))
3095
+ return `${el.tagName.toLowerCase()}[name="${el.getAttribute("name")}"]`;
3096
+ if (el.getAttribute("aria-label"))
3097
+ return `[aria-label="${el.getAttribute("aria-label")}"]`;
3098
+ if (el.className && typeof el.className === "string") {
3099
+ const classes = el.className.trim().split(/\s+/).slice(0, 2).join(".");
3100
+ if (classes)
3101
+ return `${el.tagName.toLowerCase()}.${classes}`;
3102
+ }
3103
+ const text = el.textContent?.trim().slice(0, 30);
3104
+ if (text)
3105
+ return `text="${text}"`;
3106
+ return el.tagName.toLowerCase();
3107
+ }
3108
+ });
3109
+ const pollInterval = setInterval(async () => {
3110
+ try {
3111
+ const newActions = await page.evaluate(() => {
3112
+ const collected = window.__testers_collected ?? [];
3113
+ window.__testers_collected = [];
3114
+ return collected;
3115
+ });
3116
+ for (const a of newActions) {
3117
+ actions.push({
3118
+ type: a["type"],
3119
+ selector: a["selector"],
3120
+ value: a["value"],
3121
+ key: a["key"],
3122
+ timestamp: Date.now() - startTime
3123
+ });
3124
+ }
3125
+ } catch {}
3126
+ }, 500);
3127
+ await page.exposeFunction("__testersRecord", (action) => {
3128
+ actions.push({ ...action, timestamp: Date.now() - startTime });
3129
+ });
3130
+ await page.addInitScript(() => {
3131
+ window.addEventListener("message", (e) => {
3132
+ if (e.data?.__testers_action) {
3133
+ const { __testers_action, ...rest } = e.data;
3134
+ window.__testersRecord({ type: __testers_action, ...rest });
3135
+ }
3136
+ });
3137
+ });
3138
+ await page.goto(url);
3139
+ actions.push({ type: "navigate", url, timestamp: 0 });
3140
+ console.log(`
3141
+ Recording started. Interact with the browser.`);
3142
+ console.log(` Close the browser window or wait ${timeout / 1000}s to stop.
3143
+ `);
3144
+ await Promise.race([
3145
+ page.waitForEvent("close").catch(() => {}),
3146
+ context.waitForEvent("close").catch(() => {}),
3147
+ new Promise((resolve) => setTimeout(resolve, timeout))
3148
+ ]);
3149
+ clearInterval(pollInterval);
3150
+ try {
3151
+ await browser.close();
3152
+ } catch {}
3153
+ return {
3154
+ actions,
3155
+ url,
3156
+ duration: Date.now() - startTime
3157
+ };
3158
+ }
3159
+ function actionsToScenarioInput(recording, name, projectId) {
3160
+ const steps = [];
3161
+ const seenFills = new Map;
3162
+ for (const action of recording.actions) {
3163
+ switch (action.type) {
3164
+ case "navigate":
3165
+ if (action.url)
3166
+ steps.push(`Navigate to ${action.url}`);
3167
+ break;
3168
+ case "click":
3169
+ if (action.selector)
3170
+ steps.push(`Click ${action.selector}`);
3171
+ break;
3172
+ case "fill":
3173
+ if (action.selector && action.value) {
3174
+ seenFills.set(action.selector, action.value);
3175
+ }
3176
+ break;
3177
+ case "select":
3178
+ if (action.selector && action.value)
3179
+ steps.push(`Select "${action.value}" in ${action.selector}`);
3180
+ break;
3181
+ case "press":
3182
+ if (action.key)
3183
+ steps.push(`Press ${action.key}`);
3184
+ break;
2834
3185
  }
2835
3186
  }
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);
2860
- }
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);
3187
+ for (const [selector, value] of seenFills) {
3188
+ steps.push(`Fill ${selector} with "${value}"`);
2928
3189
  }
2929
- return getScenario(existing.id);
3190
+ return {
3191
+ name,
3192
+ description: `Recorded session on ${recording.url} (${(recording.duration / 1000).toFixed(0)}s, ${recording.actions.length} actions)`,
3193
+ steps,
3194
+ tags: ["recorded"],
3195
+ projectId
3196
+ };
2930
3197
  }
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;
3198
+ async function recordAndSave(url, name, projectId) {
3199
+ const recording = await recordSession(url);
3200
+ const input = actionsToScenarioInput(recording, name, projectId);
3201
+ const scenario = createScenario(input);
3202
+ return { recording, scenario };
2938
3203
  }
3204
+ var init_recorder = __esm(() => {
3205
+ init_scenarios();
3206
+ });
3207
+
3208
+ // node_modules/commander/esm.mjs
3209
+ var import__ = __toESM(require_commander(), 1);
3210
+ var {
3211
+ program,
3212
+ createCommand,
3213
+ createArgument,
3214
+ createOption,
3215
+ CommanderError,
3216
+ InvalidArgumentError,
3217
+ InvalidOptionArgumentError,
3218
+ Command,
3219
+ Argument,
3220
+ Option,
3221
+ Help
3222
+ } = import__.default;
2939
3223
 
2940
3224
  // src/cli/index.tsx
3225
+ init_scenarios();
2941
3226
  init_runs();
3227
+ import chalk5 from "chalk";
3228
+ import { readFileSync as readFileSync6, readdirSync, writeFileSync as writeFileSync3 } from "fs";
3229
+ import { join as join6, resolve } from "path";
2942
3230
 
2943
3231
  // src/db/results.ts
2944
3232
  init_types();
@@ -3044,6 +3332,7 @@ function listScreenshots(resultId) {
3044
3332
 
3045
3333
  // src/lib/runner.ts
3046
3334
  init_runs();
3335
+ init_scenarios();
3047
3336
 
3048
3337
  // src/lib/browser.ts
3049
3338
  init_types();
@@ -4025,6 +4314,104 @@ function loadConfig() {
4025
4314
  return config;
4026
4315
  }
4027
4316
 
4317
+ // src/lib/webhooks.ts
4318
+ init_database();
4319
+ function fromRow(row) {
4320
+ return {
4321
+ id: row.id,
4322
+ url: row.url,
4323
+ events: JSON.parse(row.events),
4324
+ projectId: row.project_id,
4325
+ secret: row.secret,
4326
+ active: row.active === 1,
4327
+ createdAt: row.created_at
4328
+ };
4329
+ }
4330
+ function listWebhooks(projectId) {
4331
+ const db2 = getDatabase();
4332
+ let query = "SELECT * FROM webhooks WHERE active = 1";
4333
+ const params = [];
4334
+ if (projectId) {
4335
+ query += " AND (project_id = ? OR project_id IS NULL)";
4336
+ params.push(projectId);
4337
+ }
4338
+ query += " ORDER BY created_at DESC";
4339
+ const rows = db2.query(query).all(...params);
4340
+ return rows.map(fromRow);
4341
+ }
4342
+ function signPayload(body, secret) {
4343
+ const encoder = new TextEncoder;
4344
+ const key = encoder.encode(secret);
4345
+ const data = encoder.encode(body);
4346
+ let hash = 0;
4347
+ for (let i = 0;i < data.length; i++) {
4348
+ hash = (hash << 5) - hash + data[i] + (key[i % key.length] ?? 0) | 0;
4349
+ }
4350
+ return `sha256=${Math.abs(hash).toString(16).padStart(16, "0")}`;
4351
+ }
4352
+ function formatSlackPayload(payload) {
4353
+ const status = payload.run.status === "passed" ? ":white_check_mark:" : ":x:";
4354
+ const color = payload.run.status === "passed" ? "#22c55e" : "#ef4444";
4355
+ return {
4356
+ attachments: [
4357
+ {
4358
+ color,
4359
+ blocks: [
4360
+ {
4361
+ type: "section",
4362
+ text: {
4363
+ type: "mrkdwn",
4364
+ text: `${status} *Test Run ${payload.run.status.toUpperCase()}*
4365
+ ` + `URL: ${payload.run.url}
4366
+ ` + `Results: ${payload.run.passed}/${payload.run.total} passed` + (payload.run.failed > 0 ? ` (${payload.run.failed} failed)` : "") + (payload.schedule ? `
4367
+ Schedule: ${payload.schedule.name}` : "")
4368
+ }
4369
+ }
4370
+ ]
4371
+ }
4372
+ ]
4373
+ };
4374
+ }
4375
+ async function dispatchWebhooks(event, run, schedule) {
4376
+ const webhooks = listWebhooks(run.projectId ?? undefined);
4377
+ const payload = {
4378
+ event,
4379
+ run: {
4380
+ id: run.id,
4381
+ url: run.url,
4382
+ status: run.status,
4383
+ passed: run.passed,
4384
+ failed: run.failed,
4385
+ total: run.total
4386
+ },
4387
+ schedule,
4388
+ timestamp: new Date().toISOString()
4389
+ };
4390
+ for (const webhook of webhooks) {
4391
+ if (!webhook.events.includes(event) && !webhook.events.includes("*"))
4392
+ continue;
4393
+ const isSlack = webhook.url.includes("hooks.slack.com");
4394
+ const body = isSlack ? JSON.stringify(formatSlackPayload(payload)) : JSON.stringify(payload);
4395
+ const headers = {
4396
+ "Content-Type": "application/json"
4397
+ };
4398
+ if (webhook.secret) {
4399
+ headers["X-Testers-Signature"] = signPayload(body, webhook.secret);
4400
+ }
4401
+ try {
4402
+ const response = await fetch(webhook.url, {
4403
+ method: "POST",
4404
+ headers,
4405
+ body
4406
+ });
4407
+ if (!response.ok) {
4408
+ await new Promise((r) => setTimeout(r, 5000));
4409
+ await fetch(webhook.url, { method: "POST", headers, body });
4410
+ }
4411
+ } catch {}
4412
+ }
4413
+ }
4414
+
4028
4415
  // src/lib/runner.ts
4029
4416
  var eventHandler = null;
4030
4417
  function onRunEvent(handler) {
@@ -4034,6 +4421,20 @@ function emit(event) {
4034
4421
  if (eventHandler)
4035
4422
  eventHandler(event);
4036
4423
  }
4424
+ function withTimeout(promise, ms, label) {
4425
+ return new Promise((resolve, reject) => {
4426
+ const timer = setTimeout(() => {
4427
+ reject(new Error(`Scenario timeout after ${ms}ms: ${label}`));
4428
+ }, ms);
4429
+ promise.then((val) => {
4430
+ clearTimeout(timer);
4431
+ resolve(val);
4432
+ }, (err) => {
4433
+ clearTimeout(timer);
4434
+ reject(err);
4435
+ });
4436
+ });
4437
+ }
4037
4438
  async function runSingleScenario(scenario, runId, options) {
4038
4439
  const config = loadConfig();
4039
4440
  const model = resolveModel(options.model ?? scenario.model ?? config.defaultModel);
@@ -4056,8 +4457,9 @@ async function runSingleScenario(scenario, runId, options) {
4056
4457
  viewport: config.browser.viewport
4057
4458
  });
4058
4459
  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({
4460
+ const scenarioTimeout = scenario.timeoutMs ?? options.timeout ?? config.browser.timeout ?? 60000;
4461
+ await page.goto(targetUrl, { timeout: Math.min(scenarioTimeout, 30000) });
4462
+ const agentResult = await withTimeout(runAgentLoop({
4061
4463
  client,
4062
4464
  page,
4063
4465
  scenario,
@@ -4078,7 +4480,7 @@ async function runSingleScenario(scenario, runId, options) {
4078
4480
  stepNumber: stepEvent.stepNumber
4079
4481
  });
4080
4482
  }
4081
- });
4483
+ }), scenarioTimeout, scenario.name);
4082
4484
  for (const ss of agentResult.screenshots) {
4083
4485
  createScreenshot({
4084
4486
  resultId: result.id,
@@ -4210,6 +4612,8 @@ async function runBatch(scenarios, options) {
4210
4612
  finished_at: new Date().toISOString()
4211
4613
  });
4212
4614
  emit({ type: "run:complete", runId: run.id });
4615
+ const eventType = finalRun.status === "failed" ? "failed" : "completed";
4616
+ dispatchWebhooks(eventType, finalRun).catch(() => {});
4213
4617
  return { run: finalRun, results };
4214
4618
  }
4215
4619
  async function runByFilter(options) {
@@ -4295,6 +4699,9 @@ function startRunAsync(options) {
4295
4699
  finished_at: new Date().toISOString()
4296
4700
  });
4297
4701
  emit({ type: "run:complete", runId: run.id });
4702
+ const asyncRun = getRun(run.id);
4703
+ if (asyncRun)
4704
+ dispatchWebhooks(asyncRun.status === "failed" ? "failed" : "completed", asyncRun).catch(() => {});
4298
4705
  } catch (error) {
4299
4706
  const errorMsg = error instanceof Error ? error.message : String(error);
4300
4707
  updateRun(run.id, {
@@ -4302,6 +4709,9 @@ function startRunAsync(options) {
4302
4709
  finished_at: new Date().toISOString()
4303
4710
  });
4304
4711
  emit({ type: "run:complete", runId: run.id, error: errorMsg });
4712
+ const failedRun = getRun(run.id);
4713
+ if (failedRun)
4714
+ dispatchWebhooks("failed", failedRun).catch(() => {});
4305
4715
  }
4306
4716
  })();
4307
4717
  return { runId: run.id, scenarioCount: scenarios.length };
@@ -4318,6 +4728,7 @@ function estimateCost(model, tokens) {
4318
4728
 
4319
4729
  // src/lib/reporter.ts
4320
4730
  import chalk from "chalk";
4731
+ init_scenarios();
4321
4732
  function formatTerminal(run, results) {
4322
4733
  const lines = [];
4323
4734
  lines.push("");
@@ -4471,11 +4882,12 @@ function formatScenarioList(scenarios) {
4471
4882
  }
4472
4883
 
4473
4884
  // src/lib/todos-connector.ts
4885
+ init_scenarios();
4886
+ init_types();
4474
4887
  import { Database as Database2 } from "bun:sqlite";
4475
4888
  import { existsSync as existsSync4 } from "fs";
4476
4889
  import { join as join4 } from "path";
4477
4890
  import { homedir as homedir4 } from "os";
4478
- init_types();
4479
4891
  function resolveTodosDbPath() {
4480
4892
  const envPath = process.env["TODOS_DB_PATH"];
4481
4893
  if (envPath)
@@ -4573,6 +4985,7 @@ function importFromTodos(options = {}) {
4573
4985
  }
4574
4986
 
4575
4987
  // src/lib/init.ts
4988
+ init_scenarios();
4576
4989
  import { existsSync as existsSync5, readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
4577
4990
  import { join as join5, basename } from "path";
4578
4991
  import { homedir as homedir5 } from "os";
@@ -4730,6 +5143,7 @@ function initProject(options) {
4730
5143
  }
4731
5144
 
4732
5145
  // src/lib/smoke.ts
5146
+ init_scenarios();
4733
5147
  init_runs();
4734
5148
  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
5149
 
@@ -4951,6 +5365,7 @@ function formatSmokeReport(result) {
4951
5365
  // src/lib/diff.ts
4952
5366
  init_runs();
4953
5367
  import chalk2 from "chalk";
5368
+ init_scenarios();
4954
5369
  function diffRuns(runId1, runId2) {
4955
5370
  const run1 = getRun(runId1);
4956
5371
  if (!run1) {
@@ -5096,14 +5511,145 @@ function formatDiffJSON(diff) {
5096
5511
  return JSON.stringify(diff, null, 2);
5097
5512
  }
5098
5513
 
5514
+ // src/lib/visual-diff.ts
5515
+ import { readFileSync as readFileSync3, existsSync as existsSync6 } from "fs";
5516
+ import chalk3 from "chalk";
5517
+ init_runs();
5518
+ init_scenarios();
5519
+ init_database();
5520
+ var DEFAULT_THRESHOLD = 0.1;
5521
+ function setBaseline(runId) {
5522
+ const run = getRun(runId);
5523
+ if (!run) {
5524
+ throw new Error(`Run not found: ${runId}`);
5525
+ }
5526
+ const db2 = getDatabase();
5527
+ if (run.projectId) {
5528
+ db2.query("UPDATE runs SET is_baseline = 0 WHERE project_id = ? AND is_baseline = 1").run(run.projectId);
5529
+ } else {
5530
+ db2.query("UPDATE runs SET is_baseline = 0 WHERE project_id IS NULL AND is_baseline = 1").run();
5531
+ }
5532
+ updateRun(run.id, { is_baseline: 1 });
5533
+ }
5534
+ function compareImages(image1Path, image2Path) {
5535
+ if (!existsSync6(image1Path)) {
5536
+ throw new Error(`Baseline image not found: ${image1Path}`);
5537
+ }
5538
+ if (!existsSync6(image2Path)) {
5539
+ throw new Error(`Current image not found: ${image2Path}`);
5540
+ }
5541
+ const buf1 = readFileSync3(image1Path);
5542
+ const buf2 = readFileSync3(image2Path);
5543
+ if (buf1.equals(buf2)) {
5544
+ const estimatedPixels = Math.max(1, Math.floor(buf1.length / 4));
5545
+ return { diffPercent: 0, diffPixels: 0, totalPixels: estimatedPixels };
5546
+ }
5547
+ if (buf1.length !== buf2.length) {
5548
+ const maxLen = Math.max(buf1.length, buf2.length);
5549
+ const estimatedPixels = Math.max(1, Math.floor(maxLen / 4));
5550
+ return { diffPercent: 100, diffPixels: estimatedPixels, totalPixels: estimatedPixels };
5551
+ }
5552
+ let diffBytes = 0;
5553
+ for (let i = 0;i < buf1.length; i++) {
5554
+ if (buf1[i] !== buf2[i]) {
5555
+ diffBytes++;
5556
+ }
5557
+ }
5558
+ const totalPixels = Math.max(1, Math.floor(buf1.length / 4));
5559
+ const diffPixels = Math.max(1, Math.floor(diffBytes / 4));
5560
+ const diffPercent = parseFloat((diffBytes / buf1.length * 100).toFixed(4));
5561
+ return { diffPercent, diffPixels, totalPixels };
5562
+ }
5563
+ function compareRunScreenshots(runId, baselineRunId, threshold = DEFAULT_THRESHOLD) {
5564
+ const run = getRun(runId);
5565
+ if (!run)
5566
+ throw new Error(`Run not found: ${runId}`);
5567
+ const baselineRun = getRun(baselineRunId);
5568
+ if (!baselineRun)
5569
+ throw new Error(`Baseline run not found: ${baselineRunId}`);
5570
+ const currentResults = getResultsByRun(run.id);
5571
+ const baselineResults = getResultsByRun(baselineRun.id);
5572
+ const baselineMap = new Map;
5573
+ for (const result of baselineResults) {
5574
+ const screenshots = listScreenshots(result.id);
5575
+ for (const ss of screenshots) {
5576
+ const key = `${result.scenarioId}:${ss.stepNumber}`;
5577
+ baselineMap.set(key, { path: ss.filePath, action: ss.action });
5578
+ }
5579
+ }
5580
+ const results = [];
5581
+ for (const result of currentResults) {
5582
+ const screenshots = listScreenshots(result.id);
5583
+ for (const ss of screenshots) {
5584
+ const key = `${result.scenarioId}:${ss.stepNumber}`;
5585
+ const baseline = baselineMap.get(key);
5586
+ if (!baseline)
5587
+ continue;
5588
+ if (!existsSync6(baseline.path) || !existsSync6(ss.filePath))
5589
+ continue;
5590
+ try {
5591
+ const comparison = compareImages(baseline.path, ss.filePath);
5592
+ results.push({
5593
+ scenarioId: result.scenarioId,
5594
+ stepNumber: ss.stepNumber,
5595
+ action: ss.action,
5596
+ baselinePath: baseline.path,
5597
+ currentPath: ss.filePath,
5598
+ diffPercent: comparison.diffPercent,
5599
+ isRegression: comparison.diffPercent > threshold
5600
+ });
5601
+ } catch {}
5602
+ }
5603
+ }
5604
+ return results;
5605
+ }
5606
+ function formatVisualDiffTerminal(results, threshold = DEFAULT_THRESHOLD) {
5607
+ if (results.length === 0) {
5608
+ return chalk3.dim(`
5609
+ No screenshot comparisons found.
5610
+ `);
5611
+ }
5612
+ const lines = [];
5613
+ lines.push("");
5614
+ lines.push(chalk3.bold(" Visual Regression Summary"));
5615
+ lines.push("");
5616
+ const regressions = results.filter((r) => r.diffPercent >= threshold);
5617
+ const passed = results.filter((r) => r.diffPercent < threshold);
5618
+ if (regressions.length > 0) {
5619
+ lines.push(chalk3.red.bold(` Regressions (${regressions.length}):`));
5620
+ for (const r of regressions) {
5621
+ const scenario = getScenario(r.scenarioId);
5622
+ const label = scenario ? `${scenario.shortId}: ${scenario.name}` : r.scenarioId.slice(0, 8);
5623
+ const pct = chalk3.red(`${r.diffPercent.toFixed(2)}%`);
5624
+ lines.push(` ${chalk3.red("!")} ${label} step ${r.stepNumber} (${r.action}) \u2014 ${pct} diff`);
5625
+ }
5626
+ lines.push("");
5627
+ }
5628
+ if (passed.length > 0) {
5629
+ lines.push(chalk3.green.bold(` Passed (${passed.length}):`));
5630
+ for (const r of passed) {
5631
+ const scenario = getScenario(r.scenarioId);
5632
+ const label = scenario ? `${scenario.shortId}: ${scenario.name}` : r.scenarioId.slice(0, 8);
5633
+ const pct = chalk3.green(`${r.diffPercent.toFixed(2)}%`);
5634
+ lines.push(` ${chalk3.green("\u2713")} ${label} step ${r.stepNumber} (${r.action}) \u2014 ${pct} diff`);
5635
+ }
5636
+ lines.push("");
5637
+ }
5638
+ lines.push(chalk3.bold(` Visual Summary: ${regressions.length} regressions, ${passed.length} passed (threshold: ${threshold}%)`));
5639
+ lines.push("");
5640
+ return lines.join(`
5641
+ `);
5642
+ }
5643
+
5099
5644
  // src/lib/report.ts
5100
5645
  init_runs();
5101
- import { readFileSync as readFileSync3, existsSync as existsSync6 } from "fs";
5646
+ import { readFileSync as readFileSync4, existsSync as existsSync7 } from "fs";
5647
+ init_scenarios();
5102
5648
  function imageToBase64(filePath) {
5103
- if (!filePath || !existsSync6(filePath))
5649
+ if (!filePath || !existsSync7(filePath))
5104
5650
  return "";
5105
5651
  try {
5106
- const buffer = readFileSync3(filePath);
5652
+ const buffer = readFileSync4(filePath);
5107
5653
  const base64 = buffer.toString("base64");
5108
5654
  return `data:image/png;base64,${base64}`;
5109
5655
  } catch {
@@ -5297,7 +5843,7 @@ function generateLatestReport() {
5297
5843
 
5298
5844
  // src/lib/costs.ts
5299
5845
  init_database();
5300
- import chalk3 from "chalk";
5846
+ import chalk4 from "chalk";
5301
5847
  function getDateFilter(period) {
5302
5848
  switch (period) {
5303
5849
  case "day":
@@ -5402,15 +5948,15 @@ function formatTokens(tokens) {
5402
5948
  function formatCostsTerminal(summary) {
5403
5949
  const lines = [];
5404
5950
  lines.push("");
5405
- lines.push(chalk3.bold(` Cost Summary (${summary.period})`));
5951
+ lines.push(chalk4.bold(` Cost Summary (${summary.period})`));
5406
5952
  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))}`);
5953
+ lines.push(` Total: ${chalk4.yellow(formatDollars(summary.totalCostCents))} (${formatTokens(summary.totalTokens)} tokens across ${summary.runCount} runs)`);
5954
+ lines.push(` Avg/run: ${chalk4.yellow(formatDollars(summary.avgCostPerRun))}`);
5955
+ lines.push(` Est/month: ${chalk4.yellow(formatDollars(summary.estimatedMonthlyCents))}`);
5410
5956
  const modelEntries = Object.entries(summary.byModel);
5411
5957
  if (modelEntries.length > 0) {
5412
5958
  lines.push("");
5413
- lines.push(chalk3.bold(" By Model"));
5959
+ lines.push(chalk4.bold(" By Model"));
5414
5960
  lines.push(` ${"Model".padEnd(40)} ${"Cost".padEnd(12)} ${"Tokens".padEnd(12)} Runs`);
5415
5961
  lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(12)} ${"\u2500".repeat(12)} ${"\u2500".repeat(6)}`);
5416
5962
  for (const [model, data] of modelEntries) {
@@ -5419,7 +5965,7 @@ function formatCostsTerminal(summary) {
5419
5965
  }
5420
5966
  if (summary.byScenario.length > 0) {
5421
5967
  lines.push("");
5422
- lines.push(chalk3.bold(" Top Scenarios by Cost"));
5968
+ lines.push(chalk4.bold(" Top Scenarios by Cost"));
5423
5969
  lines.push(` ${"Scenario".padEnd(40)} ${"Cost".padEnd(12)} ${"Tokens".padEnd(12)} Runs`);
5424
5970
  lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(12)} ${"\u2500".repeat(12)} ${"\u2500".repeat(6)}`);
5425
5971
  for (const s of summary.byScenario) {
@@ -5588,7 +6134,7 @@ function listTemplateNames() {
5588
6134
 
5589
6135
  // src/db/auth-presets.ts
5590
6136
  init_database();
5591
- function fromRow(row) {
6137
+ function fromRow2(row) {
5592
6138
  return {
5593
6139
  id: row.id,
5594
6140
  name: row.name,
@@ -5612,12 +6158,12 @@ function createAuthPreset(input) {
5612
6158
  function getAuthPreset(name) {
5613
6159
  const db2 = getDatabase();
5614
6160
  const row = db2.query("SELECT * FROM auth_presets WHERE name = ?").get(name);
5615
- return row ? fromRow(row) : null;
6161
+ return row ? fromRow2(row) : null;
5616
6162
  }
5617
6163
  function listAuthPresets() {
5618
6164
  const db2 = getDatabase();
5619
6165
  const rows = db2.query("SELECT * FROM auth_presets ORDER BY created_at DESC").all();
5620
- return rows.map(fromRow);
6166
+ return rows.map(fromRow2);
5621
6167
  }
5622
6168
  function deleteAuthPreset(name) {
5623
6169
  const db2 = getDatabase();
@@ -5627,7 +6173,155 @@ function deleteAuthPreset(name) {
5627
6173
 
5628
6174
  // src/cli/index.tsx
5629
6175
  init_flows();
5630
- import { existsSync as existsSync7, mkdirSync as mkdirSync4 } from "fs";
6176
+
6177
+ // src/db/environments.ts
6178
+ init_database();
6179
+ function fromRow3(row) {
6180
+ return {
6181
+ id: row.id,
6182
+ name: row.name,
6183
+ url: row.url,
6184
+ authPresetName: row.auth_preset_name,
6185
+ projectId: row.project_id,
6186
+ isDefault: row.is_default === 1,
6187
+ createdAt: row.created_at
6188
+ };
6189
+ }
6190
+ function createEnvironment(input) {
6191
+ const db2 = getDatabase();
6192
+ const id = uuid();
6193
+ const timestamp = now();
6194
+ db2.query(`
6195
+ INSERT INTO environments (id, name, url, auth_preset_name, project_id, is_default, metadata, created_at)
6196
+ VALUES (?, ?, ?, ?, ?, ?, '{}', ?)
6197
+ `).run(id, input.name, input.url, input.authPresetName ?? null, input.projectId ?? null, input.isDefault ? 1 : 0, timestamp);
6198
+ return getEnvironment(input.name);
6199
+ }
6200
+ function getEnvironment(name) {
6201
+ const db2 = getDatabase();
6202
+ const row = db2.query("SELECT * FROM environments WHERE name = ?").get(name);
6203
+ return row ? fromRow3(row) : null;
6204
+ }
6205
+ function listEnvironments(projectId) {
6206
+ const db2 = getDatabase();
6207
+ if (projectId) {
6208
+ const rows2 = db2.query("SELECT * FROM environments WHERE project_id = ? ORDER BY is_default DESC, created_at DESC").all(projectId);
6209
+ return rows2.map(fromRow3);
6210
+ }
6211
+ const rows = db2.query("SELECT * FROM environments ORDER BY is_default DESC, created_at DESC").all();
6212
+ return rows.map(fromRow3);
6213
+ }
6214
+ function deleteEnvironment(name) {
6215
+ const db2 = getDatabase();
6216
+ const result = db2.query("DELETE FROM environments WHERE name = ?").run(name);
6217
+ return result.changes > 0;
6218
+ }
6219
+ function setDefaultEnvironment(name) {
6220
+ const db2 = getDatabase();
6221
+ db2.exec("UPDATE environments SET is_default = 0");
6222
+ const result = db2.query("UPDATE environments SET is_default = 1 WHERE name = ?").run(name);
6223
+ if (result.changes === 0) {
6224
+ throw new Error(`Environment not found: ${name}`);
6225
+ }
6226
+ }
6227
+ function getDefaultEnvironment() {
6228
+ const db2 = getDatabase();
6229
+ const row = db2.query("SELECT * FROM environments WHERE is_default = 1 LIMIT 1").get();
6230
+ return row ? fromRow3(row) : null;
6231
+ }
6232
+
6233
+ // src/lib/ci.ts
6234
+ function generateGitHubActionsWorkflow() {
6235
+ return `name: AI QA Tests
6236
+ on:
6237
+ pull_request:
6238
+ push:
6239
+ branches: [main]
6240
+
6241
+ jobs:
6242
+ test:
6243
+ runs-on: ubuntu-latest
6244
+ steps:
6245
+ - uses: actions/checkout@v4
6246
+ - uses: oven-sh/setup-bun@v2
6247
+ - run: bun install -g @hasna/testers
6248
+ - run: testers install-browser
6249
+ - run: testers run \${{ env.TEST_URL }} --json --output results.json
6250
+ env:
6251
+ ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
6252
+ TEST_URL: \${{ vars.TEST_URL || 'http://localhost:3000' }}
6253
+ - run: testers report --latest --output report.html
6254
+ - uses: actions/upload-artifact@v4
6255
+ if: always()
6256
+ with:
6257
+ name: test-report
6258
+ path: |
6259
+ report.html
6260
+ results.json
6261
+ `;
6262
+ }
6263
+
6264
+ // src/lib/assertions.ts
6265
+ function parseAssertionString(str) {
6266
+ const trimmed = str.trim();
6267
+ if (trimmed === "no-console-errors") {
6268
+ return { type: "no_console_errors", description: "No console errors" };
6269
+ }
6270
+ if (trimmed.startsWith("url:contains:")) {
6271
+ const expected = trimmed.slice("url:contains:".length);
6272
+ return { type: "url_contains", expected, description: `URL contains "${expected}"` };
6273
+ }
6274
+ if (trimmed.startsWith("title:contains:")) {
6275
+ const expected = trimmed.slice("title:contains:".length);
6276
+ return { type: "title_contains", expected, description: `Title contains "${expected}"` };
6277
+ }
6278
+ if (trimmed.startsWith("count:")) {
6279
+ const rest = trimmed.slice("count:".length);
6280
+ const eqIdx = rest.indexOf(" eq:");
6281
+ if (eqIdx === -1) {
6282
+ throw new Error(`Invalid count assertion format: ${str}. Expected "count:<selector> eq:<number>"`);
6283
+ }
6284
+ const selector = rest.slice(0, eqIdx);
6285
+ const expected = parseInt(rest.slice(eqIdx + " eq:".length), 10);
6286
+ return { type: "element_count", selector, expected, description: `${selector} count equals ${expected}` };
6287
+ }
6288
+ if (trimmed.startsWith("text:")) {
6289
+ const rest = trimmed.slice("text:".length);
6290
+ const containsIdx = rest.indexOf(" contains:");
6291
+ const equalsIdx = rest.indexOf(" equals:");
6292
+ if (containsIdx !== -1) {
6293
+ const selector = rest.slice(0, containsIdx);
6294
+ const expected = rest.slice(containsIdx + " contains:".length);
6295
+ return { type: "text_contains", selector, expected, description: `${selector} text contains "${expected}"` };
6296
+ }
6297
+ if (equalsIdx !== -1) {
6298
+ const selector = rest.slice(0, equalsIdx);
6299
+ const expected = rest.slice(equalsIdx + " equals:".length);
6300
+ return { type: "text_equals", selector, expected, description: `${selector} text equals "${expected}"` };
6301
+ }
6302
+ throw new Error(`Invalid text assertion format: ${str}. Expected "text:<selector> contains:<text>" or "text:<selector> equals:<text>"`);
6303
+ }
6304
+ if (trimmed.startsWith("selector:")) {
6305
+ const rest = trimmed.slice("selector:".length);
6306
+ const lastSpace = rest.lastIndexOf(" ");
6307
+ if (lastSpace === -1) {
6308
+ throw new Error(`Invalid selector assertion format: ${str}. Expected "selector:<selector> visible" or "selector:<selector> not-visible"`);
6309
+ }
6310
+ const selector = rest.slice(0, lastSpace);
6311
+ const action = rest.slice(lastSpace + 1);
6312
+ if (action === "visible") {
6313
+ return { type: "visible", selector, description: `${selector} is visible` };
6314
+ }
6315
+ if (action === "not-visible") {
6316
+ return { type: "not_visible", selector, description: `${selector} is not visible` };
6317
+ }
6318
+ throw new Error(`Unknown selector action: "${action}". Expected "visible" or "not-visible"`);
6319
+ }
6320
+ throw new Error(`Cannot parse assertion: "${str}". See --help for assertion formats.`);
6321
+ }
6322
+
6323
+ // src/cli/index.tsx
6324
+ import { existsSync as existsSync8, mkdirSync as mkdirSync4 } from "fs";
5631
6325
  function formatToolInput(input) {
5632
6326
  const parts = [];
5633
6327
  for (const [key, value] of Object.entries(input)) {
@@ -5643,8 +6337,8 @@ var CONFIG_DIR2 = join6(process.env["HOME"] ?? "~", ".testers");
5643
6337
  var CONFIG_PATH2 = join6(CONFIG_DIR2, "config.json");
5644
6338
  function getActiveProject() {
5645
6339
  try {
5646
- if (existsSync7(CONFIG_PATH2)) {
5647
- const raw = JSON.parse(readFileSync4(CONFIG_PATH2, "utf-8"));
6340
+ if (existsSync8(CONFIG_PATH2)) {
6341
+ const raw = JSON.parse(readFileSync6(CONFIG_PATH2, "utf-8"));
5648
6342
  return raw.activeProject ?? undefined;
5649
6343
  }
5650
6344
  } catch {}
@@ -5659,21 +6353,25 @@ program2.command("add <name>").description("Create a new test scenario").option(
5659
6353
  }, []).option("-t, --tag <tag>", "Tag (repeatable)", (val, acc) => {
5660
6354
  acc.push(val);
5661
6355
  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) => {
6356
+ }, []).option("-p, --priority <level>", "Priority level", "medium").option("-m, --model <model>", "AI model to use").option("--path <path>", "Target path on the URL").option("--auth", "Requires authentication", false).option("--timeout <ms>", "Timeout in milliseconds").option("--project <id>", "Project ID").option("--template <name>", "Seed scenarios from a template (auth, crud, forms, nav, a11y)").option("--assert <assertion>", "Structured assertion (repeatable). Formats: selector:<sel> visible, text:<sel> contains:<text>, no-console-errors, url:contains:<path>, title:contains:<text>, count:<sel> eq:<n>", (val, acc) => {
6357
+ acc.push(val);
6358
+ return acc;
6359
+ }, []).action((name, opts) => {
5663
6360
  try {
5664
6361
  if (opts.template) {
5665
6362
  const template = getTemplate(opts.template);
5666
6363
  if (!template) {
5667
- console.error(chalk4.red(`Unknown template: ${opts.template}. Available: ${listTemplateNames().join(", ")}`));
6364
+ console.error(chalk5.red(`Unknown template: ${opts.template}. Available: ${listTemplateNames().join(", ")}`));
5668
6365
  process.exit(1);
5669
6366
  }
5670
6367
  const projectId2 = resolveProject(opts.project);
5671
6368
  for (const input of template) {
5672
6369
  const s = createScenario({ ...input, projectId: projectId2 });
5673
- console.log(chalk4.green(` Created ${s.shortId}: ${s.name}`));
6370
+ console.log(chalk5.green(` Created ${s.shortId}: ${s.name}`));
5674
6371
  }
5675
6372
  return;
5676
6373
  }
6374
+ const assertions = opts.assert.map(parseAssertionString);
5677
6375
  const projectId = resolveProject(opts.project);
5678
6376
  const scenario = createScenario({
5679
6377
  name,
@@ -5685,11 +6383,12 @@ program2.command("add <name>").description("Create a new test scenario").option(
5685
6383
  targetPath: opts.path,
5686
6384
  requiresAuth: opts.auth,
5687
6385
  timeoutMs: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
6386
+ assertions: assertions.length > 0 ? assertions : undefined,
5688
6387
  projectId
5689
6388
  });
5690
- console.log(chalk4.green(`Created scenario ${chalk4.bold(scenario.shortId)}: ${scenario.name}`));
6389
+ console.log(chalk5.green(`Created scenario ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
5691
6390
  } catch (error) {
5692
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6391
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5693
6392
  process.exit(1);
5694
6393
  }
5695
6394
  });
@@ -5703,7 +6402,7 @@ program2.command("list").description("List test scenarios").option("-t, --tag <t
5703
6402
  });
5704
6403
  console.log(formatScenarioList(scenarios));
5705
6404
  } catch (error) {
5706
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6405
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5707
6406
  process.exit(1);
5708
6407
  }
5709
6408
  });
@@ -5711,33 +6410,33 @@ program2.command("show <id>").description("Show scenario details").action((id) =
5711
6410
  try {
5712
6411
  const scenario = getScenario(id) ?? getScenarioByShortId(id);
5713
6412
  if (!scenario) {
5714
- console.error(chalk4.red(`Scenario not found: ${id}`));
6413
+ console.error(chalk5.red(`Scenario not found: ${id}`));
5715
6414
  process.exit(1);
5716
6415
  }
5717
6416
  console.log("");
5718
- console.log(chalk4.bold(` Scenario ${scenario.shortId}`));
6417
+ console.log(chalk5.bold(` Scenario ${scenario.shortId}`));
5719
6418
  console.log(` Name: ${scenario.name}`);
5720
- console.log(` ID: ${chalk4.dim(scenario.id)}`);
6419
+ console.log(` ID: ${chalk5.dim(scenario.id)}`);
5721
6420
  console.log(` Description: ${scenario.description}`);
5722
6421
  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")}`);
6422
+ console.log(` Model: ${scenario.model ?? chalk5.dim("default")}`);
6423
+ console.log(` Tags: ${scenario.tags.length > 0 ? scenario.tags.join(", ") : chalk5.dim("none")}`);
6424
+ console.log(` Path: ${scenario.targetPath ?? chalk5.dim("none")}`);
5726
6425
  console.log(` Auth: ${scenario.requiresAuth ? "yes" : "no"}`);
5727
- console.log(` Timeout: ${scenario.timeoutMs ? `${scenario.timeoutMs}ms` : chalk4.dim("default")}`);
6426
+ console.log(` Timeout: ${scenario.timeoutMs ? `${scenario.timeoutMs}ms` : chalk5.dim("default")}`);
5728
6427
  console.log(` Version: ${scenario.version}`);
5729
6428
  console.log(` Created: ${scenario.createdAt}`);
5730
6429
  console.log(` Updated: ${scenario.updatedAt}`);
5731
6430
  if (scenario.steps.length > 0) {
5732
6431
  console.log("");
5733
- console.log(chalk4.bold(" Steps:"));
6432
+ console.log(chalk5.bold(" Steps:"));
5734
6433
  for (let i = 0;i < scenario.steps.length; i++) {
5735
6434
  console.log(` ${i + 1}. ${scenario.steps[i]}`);
5736
6435
  }
5737
6436
  }
5738
6437
  console.log("");
5739
6438
  } catch (error) {
5740
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6439
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5741
6440
  process.exit(1);
5742
6441
  }
5743
6442
  });
@@ -5751,7 +6450,7 @@ program2.command("update <id>").description("Update a scenario").option("-n, --n
5751
6450
  try {
5752
6451
  const scenario = getScenario(id) ?? getScenarioByShortId(id);
5753
6452
  if (!scenario) {
5754
- console.error(chalk4.red(`Scenario not found: ${id}`));
6453
+ console.error(chalk5.red(`Scenario not found: ${id}`));
5755
6454
  process.exit(1);
5756
6455
  }
5757
6456
  const updated = updateScenario(scenario.id, {
@@ -5762,9 +6461,9 @@ program2.command("update <id>").description("Update a scenario").option("-n, --n
5762
6461
  priority: opts.priority,
5763
6462
  model: opts.model
5764
6463
  }, scenario.version);
5765
- console.log(chalk4.green(`Updated scenario ${chalk4.bold(updated.shortId)}: ${updated.name}`));
6464
+ console.log(chalk5.green(`Updated scenario ${chalk5.bold(updated.shortId)}: ${updated.name}`));
5766
6465
  } catch (error) {
5767
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6466
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5768
6467
  process.exit(1);
5769
6468
  }
5770
6469
  });
@@ -5772,30 +6471,50 @@ program2.command("delete <id>").description("Delete a scenario").action((id) =>
5772
6471
  try {
5773
6472
  const scenario = getScenario(id) ?? getScenarioByShortId(id);
5774
6473
  if (!scenario) {
5775
- console.error(chalk4.red(`Scenario not found: ${id}`));
6474
+ console.error(chalk5.red(`Scenario not found: ${id}`));
5776
6475
  process.exit(1);
5777
6476
  }
5778
6477
  const deleted = deleteScenario(scenario.id);
5779
6478
  if (deleted) {
5780
- console.log(chalk4.green(`Deleted scenario ${scenario.shortId}: ${scenario.name}`));
6479
+ console.log(chalk5.green(`Deleted scenario ${scenario.shortId}: ${scenario.name}`));
5781
6480
  } else {
5782
- console.error(chalk4.red(`Failed to delete scenario: ${id}`));
6481
+ console.error(chalk5.red(`Failed to delete scenario: ${id}`));
5783
6482
  process.exit(1);
5784
6483
  }
5785
6484
  } catch (error) {
5786
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6485
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5787
6486
  process.exit(1);
5788
6487
  }
5789
6488
  });
5790
- program2.command("run <url> [description]").description("Run test scenarios against a URL").option("-t, --tag <tag>", "Filter by tag (repeatable)", (val, acc) => {
6489
+ program2.command("run [url] [description]").description("Run test scenarios against a URL").option("-t, --tag <tag>", "Filter by tag (repeatable)", (val, acc) => {
5791
6490
  acc.push(val);
5792
6491
  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) => {
6492
+ }, []).option("-s, --scenario <id>", "Run specific scenario ID").option("-p, --priority <level>", "Filter by priority").option("--headed", "Run browser in headed mode", false).option("-m, --model <model>", "AI model to use").option("--parallel <n>", "Number of parallel browsers", "1").option("--json", "Output results as JSON", false).option("-o, --output <filepath>", "Write JSON results to file").option("--timeout <ms>", "Timeout in milliseconds").option("--from-todos", "Import scenarios from todos before running", false).option("--project <id>", "Project ID").option("-b, --background", "Start run in background and return immediately", false).option("--env <name>", "Use a named environment for the URL").action(async (urlArg, description, opts) => {
5794
6493
  try {
5795
6494
  const projectId = resolveProject(opts.project);
6495
+ let url = urlArg;
6496
+ if (!url && opts.env) {
6497
+ const env = getEnvironment(opts.env);
6498
+ if (!env) {
6499
+ console.error(chalk5.red(`Environment not found: ${opts.env}`));
6500
+ process.exit(1);
6501
+ }
6502
+ url = env.url;
6503
+ }
6504
+ if (!url) {
6505
+ const defaultEnv = getDefaultEnvironment();
6506
+ if (defaultEnv) {
6507
+ url = defaultEnv.url;
6508
+ console.log(chalk5.dim(`Using default environment: ${defaultEnv.name} (${defaultEnv.url})`));
6509
+ }
6510
+ }
6511
+ if (!url) {
6512
+ console.error(chalk5.red("No URL provided. Pass a URL argument, use --env <name>, or set a default environment with 'testers env use <name>'."));
6513
+ process.exit(1);
6514
+ }
5796
6515
  if (opts.fromTodos) {
5797
6516
  const result = importFromTodos({ projectId });
5798
- console.log(chalk4.blue(`Imported ${result.imported} scenarios from todos (${result.skipped} skipped)`));
6517
+ console.log(chalk5.blue(`Imported ${result.imported} scenarios from todos (${result.skipped} skipped)`));
5799
6518
  }
5800
6519
  if (opts.background) {
5801
6520
  if (description) {
@@ -5812,51 +6531,51 @@ program2.command("run <url> [description]").description("Run test scenarios agai
5812
6531
  timeout: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
5813
6532
  projectId
5814
6533
  });
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)}`));
6534
+ console.log(chalk5.green(`Run started in background: ${chalk5.bold(runId.slice(0, 8))}`));
6535
+ console.log(chalk5.dim(` Scenarios: ${scenarioCount}`));
6536
+ console.log(chalk5.dim(` URL: ${url}`));
6537
+ console.log(chalk5.dim(` Check progress: testers results ${runId.slice(0, 8)}`));
5819
6538
  process.exit(0);
5820
6539
  }
5821
6540
  if (!opts.json && !opts.output) {
5822
6541
  onRunEvent((event) => {
5823
6542
  switch (event.type) {
5824
6543
  case "scenario:start":
5825
- console.log(chalk4.blue(` [start] ${event.scenarioName ?? event.scenarioId}`));
6544
+ console.log(chalk5.blue(` [start] ${event.scenarioName ?? event.scenarioId}`));
5826
6545
  break;
5827
6546
  case "step:thinking":
5828
6547
  if (event.thinking) {
5829
6548
  const preview = event.thinking.length > 120 ? event.thinking.slice(0, 120) + "..." : event.thinking;
5830
- console.log(chalk4.dim(` [think] ${preview}`));
6549
+ console.log(chalk5.dim(` [think] ${preview}`));
5831
6550
  }
5832
6551
  break;
5833
6552
  case "step:tool_call":
5834
- console.log(chalk4.cyan(` [step ${event.stepNumber}] ${event.toolName}${event.toolInput ? ` ${formatToolInput(event.toolInput)}` : ""}`));
6553
+ console.log(chalk5.cyan(` [step ${event.stepNumber}] ${event.toolName}${event.toolInput ? ` ${formatToolInput(event.toolInput)}` : ""}`));
5835
6554
  break;
5836
6555
  case "step:tool_result":
5837
6556
  if (event.toolName === "report_result") {
5838
- console.log(chalk4.bold(` [result] ${event.toolResult}`));
6557
+ console.log(chalk5.bold(` [result] ${event.toolResult}`));
5839
6558
  } else {
5840
6559
  const resultPreview = (event.toolResult ?? "").length > 100 ? (event.toolResult ?? "").slice(0, 100) + "..." : event.toolResult ?? "";
5841
- console.log(chalk4.dim(` [done] ${resultPreview}`));
6560
+ console.log(chalk5.dim(` [done] ${resultPreview}`));
5842
6561
  }
5843
6562
  break;
5844
6563
  case "screenshot:captured":
5845
- console.log(chalk4.dim(` [screenshot] ${event.screenshotPath}`));
6564
+ console.log(chalk5.dim(` [screenshot] ${event.screenshotPath}`));
5846
6565
  break;
5847
6566
  case "scenario:pass":
5848
- console.log(chalk4.green(` [PASS] ${event.scenarioName}`));
6567
+ console.log(chalk5.green(` [PASS] ${event.scenarioName}`));
5849
6568
  break;
5850
6569
  case "scenario:fail":
5851
- console.log(chalk4.red(` [FAIL] ${event.scenarioName}`));
6570
+ console.log(chalk5.red(` [FAIL] ${event.scenarioName}`));
5852
6571
  break;
5853
6572
  case "scenario:error":
5854
- console.log(chalk4.yellow(` [ERR] ${event.scenarioName}: ${event.error}`));
6573
+ console.log(chalk5.yellow(` [ERR] ${event.scenarioName}: ${event.error}`));
5855
6574
  break;
5856
6575
  }
5857
6576
  });
5858
6577
  console.log("");
5859
- console.log(chalk4.bold(` Running tests against ${url}`));
6578
+ console.log(chalk5.bold(` Running tests against ${url}`));
5860
6579
  console.log("");
5861
6580
  }
5862
6581
  if (description) {
@@ -5879,7 +6598,7 @@ program2.command("run <url> [description]").description("Run test scenarios agai
5879
6598
  const jsonOutput = formatJSON(run2, results2);
5880
6599
  if (opts.output) {
5881
6600
  writeFileSync3(opts.output, jsonOutput, "utf-8");
5882
- console.log(chalk4.green(`Results written to ${opts.output}`));
6601
+ console.log(chalk5.green(`Results written to ${opts.output}`));
5883
6602
  }
5884
6603
  if (opts.json) {
5885
6604
  console.log(jsonOutput);
@@ -5904,7 +6623,7 @@ program2.command("run <url> [description]").description("Run test scenarios agai
5904
6623
  const jsonOutput = formatJSON(run, results);
5905
6624
  if (opts.output) {
5906
6625
  writeFileSync3(opts.output, jsonOutput, "utf-8");
5907
- console.log(chalk4.green(`Results written to ${opts.output}`));
6626
+ console.log(chalk5.green(`Results written to ${opts.output}`));
5908
6627
  }
5909
6628
  if (opts.json) {
5910
6629
  console.log(jsonOutput);
@@ -5914,7 +6633,7 @@ program2.command("run <url> [description]").description("Run test scenarios agai
5914
6633
  }
5915
6634
  process.exit(getExitCode(run));
5916
6635
  } catch (error) {
5917
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6636
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5918
6637
  process.exit(1);
5919
6638
  }
5920
6639
  });
@@ -5926,7 +6645,7 @@ program2.command("runs").description("List past test runs").option("--status <st
5926
6645
  });
5927
6646
  console.log(formatRunList(runs));
5928
6647
  } catch (error) {
5929
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6648
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5930
6649
  process.exit(1);
5931
6650
  }
5932
6651
  });
@@ -5934,13 +6653,13 @@ program2.command("results <run-id>").description("Show results for a test run").
5934
6653
  try {
5935
6654
  const run = getRun(runId);
5936
6655
  if (!run) {
5937
- console.error(chalk4.red(`Run not found: ${runId}`));
6656
+ console.error(chalk5.red(`Run not found: ${runId}`));
5938
6657
  process.exit(1);
5939
6658
  }
5940
6659
  const results = getResultsByRun(run.id);
5941
6660
  console.log(formatTerminal(run, results));
5942
6661
  } catch (error) {
5943
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6662
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5944
6663
  process.exit(1);
5945
6664
  }
5946
6665
  });
@@ -5951,23 +6670,23 @@ program2.command("screenshots <id>").description("List screenshots for a run or
5951
6670
  const results = getResultsByRun(run.id);
5952
6671
  let total = 0;
5953
6672
  console.log("");
5954
- console.log(chalk4.bold(` Screenshots for run ${run.id.slice(0, 8)}`));
6673
+ console.log(chalk5.bold(` Screenshots for run ${run.id.slice(0, 8)}`));
5955
6674
  console.log("");
5956
6675
  for (const result of results) {
5957
6676
  const screenshots2 = listScreenshots(result.id);
5958
6677
  if (screenshots2.length > 0) {
5959
6678
  const scenario = getScenario(result.scenarioId);
5960
6679
  const label = scenario ? `${scenario.shortId}: ${scenario.name}` : result.scenarioId.slice(0, 8);
5961
- console.log(chalk4.bold(` ${label}`));
6680
+ console.log(chalk5.bold(` ${label}`));
5962
6681
  for (const ss of screenshots2) {
5963
- console.log(` ${chalk4.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk4.dim(ss.filePath)}`);
6682
+ console.log(` ${chalk5.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk5.dim(ss.filePath)}`);
5964
6683
  total++;
5965
6684
  }
5966
6685
  console.log("");
5967
6686
  }
5968
6687
  }
5969
6688
  if (total === 0) {
5970
- console.log(chalk4.dim(" No screenshots found."));
6689
+ console.log(chalk5.dim(" No screenshots found."));
5971
6690
  console.log("");
5972
6691
  }
5973
6692
  return;
@@ -5975,18 +6694,18 @@ program2.command("screenshots <id>").description("List screenshots for a run or
5975
6694
  const screenshots = listScreenshots(id);
5976
6695
  if (screenshots.length > 0) {
5977
6696
  console.log("");
5978
- console.log(chalk4.bold(` Screenshots for result ${id.slice(0, 8)}`));
6697
+ console.log(chalk5.bold(` Screenshots for result ${id.slice(0, 8)}`));
5979
6698
  console.log("");
5980
6699
  for (const ss of screenshots) {
5981
- console.log(` ${chalk4.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk4.dim(ss.filePath)}`);
6700
+ console.log(` ${chalk5.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk5.dim(ss.filePath)}`);
5982
6701
  }
5983
6702
  console.log("");
5984
6703
  return;
5985
6704
  }
5986
- console.error(chalk4.red(`No screenshots found for: ${id}`));
6705
+ console.error(chalk5.red(`No screenshots found for: ${id}`));
5987
6706
  process.exit(1);
5988
6707
  } catch (error) {
5989
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6708
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
5990
6709
  process.exit(1);
5991
6710
  }
5992
6711
  });
@@ -5995,12 +6714,12 @@ program2.command("import <dir>").description("Import markdown test files as scen
5995
6714
  const absDir = resolve(dir);
5996
6715
  const files = readdirSync(absDir).filter((f) => f.endsWith(".md"));
5997
6716
  if (files.length === 0) {
5998
- console.log(chalk4.dim("No .md files found in directory."));
6717
+ console.log(chalk5.dim("No .md files found in directory."));
5999
6718
  return;
6000
6719
  }
6001
6720
  let imported = 0;
6002
6721
  for (const file of files) {
6003
- const content = readFileSync4(join6(absDir, file), "utf-8");
6722
+ const content = readFileSync6(join6(absDir, file), "utf-8");
6004
6723
  const lines = content.split(`
6005
6724
  `);
6006
6725
  let name = file.replace(/\.md$/, "");
@@ -6025,13 +6744,13 @@ program2.command("import <dir>").description("Import markdown test files as scen
6025
6744
  description: descriptionLines.join(" ") || name,
6026
6745
  steps
6027
6746
  });
6028
- console.log(chalk4.green(` Imported ${chalk4.bold(scenario.shortId)}: ${scenario.name}`));
6747
+ console.log(chalk5.green(` Imported ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
6029
6748
  imported++;
6030
6749
  }
6031
6750
  console.log("");
6032
- console.log(chalk4.green(`Imported ${imported} scenario(s) from ${absDir}`));
6751
+ console.log(chalk5.green(`Imported ${imported} scenario(s) from ${absDir}`));
6033
6752
  } catch (error) {
6034
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6753
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6035
6754
  process.exit(1);
6036
6755
  }
6037
6756
  });
@@ -6040,7 +6759,7 @@ program2.command("config").description("Show current configuration").action(() =
6040
6759
  const config = loadConfig();
6041
6760
  console.log(JSON.stringify(config, null, 2));
6042
6761
  } catch (error) {
6043
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6762
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6044
6763
  process.exit(1);
6045
6764
  }
6046
6765
  });
@@ -6050,25 +6769,25 @@ program2.command("status").description("Show database and auth status").action((
6050
6769
  const hasApiKey = !!config.anthropicApiKey || !!process.env["ANTHROPIC_API_KEY"];
6051
6770
  const dbPath = join6(process.env["HOME"] ?? "~", ".testers", "testers.db");
6052
6771
  console.log("");
6053
- console.log(chalk4.bold(" Open Testers Status"));
6772
+ console.log(chalk5.bold(" Open Testers Status"));
6054
6773
  console.log("");
6055
- console.log(` ANTHROPIC_API_KEY: ${hasApiKey ? chalk4.green("set") : chalk4.red("not set")}`);
6774
+ console.log(` ANTHROPIC_API_KEY: ${hasApiKey ? chalk5.green("set") : chalk5.red("not set")}`);
6056
6775
  console.log(` Database: ${dbPath}`);
6057
6776
  console.log(` Default model: ${config.defaultModel}`);
6058
6777
  console.log(` Screenshots dir: ${config.screenshots.dir}`);
6059
6778
  console.log("");
6060
6779
  } catch (error) {
6061
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6780
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6062
6781
  process.exit(1);
6063
6782
  }
6064
6783
  });
6065
6784
  program2.command("install-browser").description("Install Playwright Chromium browser").action(async () => {
6066
6785
  try {
6067
- console.log(chalk4.blue("Installing Playwright Chromium..."));
6786
+ console.log(chalk5.blue("Installing Playwright Chromium..."));
6068
6787
  await installBrowser();
6069
- console.log(chalk4.green("Browser installed successfully."));
6788
+ console.log(chalk5.green("Browser installed successfully."));
6070
6789
  } catch (error) {
6071
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6790
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6072
6791
  process.exit(1);
6073
6792
  }
6074
6793
  });
@@ -6080,9 +6799,9 @@ projectCmd.command("create <name>").description("Create a new project").option("
6080
6799
  path: opts.path,
6081
6800
  description: opts.description
6082
6801
  });
6083
- console.log(chalk4.green(`Created project ${chalk4.bold(project.name)} (${project.id})`));
6802
+ console.log(chalk5.green(`Created project ${chalk5.bold(project.name)} (${project.id})`));
6084
6803
  } catch (error) {
6085
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6804
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6086
6805
  process.exit(1);
6087
6806
  }
6088
6807
  });
@@ -6090,20 +6809,20 @@ projectCmd.command("list").description("List all projects").action(() => {
6090
6809
  try {
6091
6810
  const projects = listProjects();
6092
6811
  if (projects.length === 0) {
6093
- console.log(chalk4.dim("No projects found."));
6812
+ console.log(chalk5.dim("No projects found."));
6094
6813
  return;
6095
6814
  }
6096
6815
  console.log("");
6097
- console.log(chalk4.bold(" Projects"));
6816
+ console.log(chalk5.bold(" Projects"));
6098
6817
  console.log("");
6099
6818
  console.log(` ${"ID".padEnd(38)} ${"Name".padEnd(24)} ${"Path".padEnd(30)} Created`);
6100
6819
  console.log(` ${"\u2500".repeat(38)} ${"\u2500".repeat(24)} ${"\u2500".repeat(30)} ${"\u2500".repeat(20)}`);
6101
6820
  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}`);
6821
+ console.log(` ${p.id.padEnd(38)} ${p.name.padEnd(24)} ${(p.path ?? chalk5.dim("\u2014")).toString().padEnd(30)} ${p.createdAt}`);
6103
6822
  }
6104
6823
  console.log("");
6105
6824
  } catch (error) {
6106
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6825
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6107
6826
  process.exit(1);
6108
6827
  }
6109
6828
  });
@@ -6111,39 +6830,39 @@ projectCmd.command("show <id>").description("Show project details").action((id)
6111
6830
  try {
6112
6831
  const project = getProject(id);
6113
6832
  if (!project) {
6114
- console.error(chalk4.red(`Project not found: ${id}`));
6833
+ console.error(chalk5.red(`Project not found: ${id}`));
6115
6834
  process.exit(1);
6116
6835
  }
6117
6836
  console.log("");
6118
- console.log(chalk4.bold(` Project: ${project.name}`));
6837
+ console.log(chalk5.bold(` Project: ${project.name}`));
6119
6838
  console.log(` ID: ${project.id}`);
6120
- console.log(` Path: ${project.path ?? chalk4.dim("none")}`);
6121
- console.log(` Description: ${project.description ?? chalk4.dim("none")}`);
6839
+ console.log(` Path: ${project.path ?? chalk5.dim("none")}`);
6840
+ console.log(` Description: ${project.description ?? chalk5.dim("none")}`);
6122
6841
  console.log(` Created: ${project.createdAt}`);
6123
6842
  console.log(` Updated: ${project.updatedAt}`);
6124
6843
  console.log("");
6125
6844
  } catch (error) {
6126
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6845
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6127
6846
  process.exit(1);
6128
6847
  }
6129
6848
  });
6130
6849
  projectCmd.command("use <name>").description("Set active project (find or create)").action((name) => {
6131
6850
  try {
6132
6851
  const project = ensureProject(name, process.cwd());
6133
- if (!existsSync7(CONFIG_DIR2)) {
6852
+ if (!existsSync8(CONFIG_DIR2)) {
6134
6853
  mkdirSync4(CONFIG_DIR2, { recursive: true });
6135
6854
  }
6136
6855
  let config = {};
6137
- if (existsSync7(CONFIG_PATH2)) {
6856
+ if (existsSync8(CONFIG_PATH2)) {
6138
6857
  try {
6139
- config = JSON.parse(readFileSync4(CONFIG_PATH2, "utf-8"));
6858
+ config = JSON.parse(readFileSync6(CONFIG_PATH2, "utf-8"));
6140
6859
  } catch {}
6141
6860
  }
6142
6861
  config.activeProject = project.id;
6143
6862
  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})`));
6863
+ console.log(chalk5.green(`Active project set to ${chalk5.bold(project.name)} (${project.id})`));
6145
6864
  } catch (error) {
6146
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6865
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6147
6866
  process.exit(1);
6148
6867
  }
6149
6868
  });
@@ -6168,12 +6887,12 @@ scheduleCmd.command("create <name>").description("Create a new schedule").requir
6168
6887
  timeoutMs: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
6169
6888
  projectId
6170
6889
  });
6171
- console.log(chalk4.green(`Created schedule ${chalk4.bold(schedule.name)} (${schedule.id})`));
6890
+ console.log(chalk5.green(`Created schedule ${chalk5.bold(schedule.name)} (${schedule.id})`));
6172
6891
  if (schedule.nextRunAt) {
6173
- console.log(chalk4.dim(` Next run at: ${schedule.nextRunAt}`));
6892
+ console.log(chalk5.dim(` Next run at: ${schedule.nextRunAt}`));
6174
6893
  }
6175
6894
  } catch (error) {
6176
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6895
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6177
6896
  process.exit(1);
6178
6897
  }
6179
6898
  });
@@ -6185,23 +6904,23 @@ scheduleCmd.command("list").description("List schedules").option("--project <id>
6185
6904
  enabled: opts.enabled ? true : undefined
6186
6905
  });
6187
6906
  if (schedules.length === 0) {
6188
- console.log(chalk4.dim("No schedules found."));
6907
+ console.log(chalk5.dim("No schedules found."));
6189
6908
  return;
6190
6909
  }
6191
6910
  console.log("");
6192
- console.log(chalk4.bold(" Schedules"));
6911
+ console.log(chalk5.bold(" Schedules"));
6193
6912
  console.log("");
6194
6913
  console.log(` ${"Name".padEnd(20)} ${"Cron".padEnd(18)} ${"URL".padEnd(30)} ${"Enabled".padEnd(9)} ${"Next Run".padEnd(22)} Last Run`);
6195
6914
  console.log(` ${"\u2500".repeat(20)} ${"\u2500".repeat(18)} ${"\u2500".repeat(30)} ${"\u2500".repeat(9)} ${"\u2500".repeat(22)} ${"\u2500".repeat(22)}`);
6196
6915
  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");
6916
+ const enabled = s.enabled ? chalk5.green("yes") : chalk5.red("no");
6917
+ const nextRun = s.nextRunAt ?? chalk5.dim("\u2014");
6918
+ const lastRun = s.lastRunAt ?? chalk5.dim("\u2014");
6200
6919
  console.log(` ${s.name.padEnd(20)} ${s.cronExpression.padEnd(18)} ${s.url.padEnd(30)} ${enabled.toString().padEnd(9)} ${nextRun.toString().padEnd(22)} ${lastRun}`);
6201
6920
  }
6202
6921
  console.log("");
6203
6922
  } catch (error) {
6204
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6923
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6205
6924
  process.exit(1);
6206
6925
  }
6207
6926
  });
@@ -6209,47 +6928,47 @@ scheduleCmd.command("show <id>").description("Show schedule details").action((id
6209
6928
  try {
6210
6929
  const schedule = getSchedule(id);
6211
6930
  if (!schedule) {
6212
- console.error(chalk4.red(`Schedule not found: ${id}`));
6931
+ console.error(chalk5.red(`Schedule not found: ${id}`));
6213
6932
  process.exit(1);
6214
6933
  }
6215
6934
  console.log("");
6216
- console.log(chalk4.bold(` Schedule: ${schedule.name}`));
6935
+ console.log(chalk5.bold(` Schedule: ${schedule.name}`));
6217
6936
  console.log(` ID: ${schedule.id}`);
6218
6937
  console.log(` Cron: ${schedule.cronExpression}`);
6219
6938
  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")}`);
6939
+ console.log(` Enabled: ${schedule.enabled ? chalk5.green("yes") : chalk5.red("no")}`);
6940
+ console.log(` Model: ${schedule.model ?? chalk5.dim("default")}`);
6222
6941
  console.log(` Headed: ${schedule.headed ? "yes" : "no"}`);
6223
6942
  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")}`);
6943
+ console.log(` Timeout: ${schedule.timeoutMs ? `${schedule.timeoutMs}ms` : chalk5.dim("default")}`);
6944
+ console.log(` Project: ${schedule.projectId ?? chalk5.dim("none")}`);
6226
6945
  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")}`);
6946
+ console.log(` Next run: ${schedule.nextRunAt ?? chalk5.dim("not scheduled")}`);
6947
+ console.log(` Last run: ${schedule.lastRunAt ?? chalk5.dim("never")}`);
6948
+ console.log(` Last run ID: ${schedule.lastRunId ?? chalk5.dim("none")}`);
6230
6949
  console.log(` Created: ${schedule.createdAt}`);
6231
6950
  console.log(` Updated: ${schedule.updatedAt}`);
6232
6951
  console.log("");
6233
6952
  } catch (error) {
6234
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6953
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6235
6954
  process.exit(1);
6236
6955
  }
6237
6956
  });
6238
6957
  scheduleCmd.command("enable <id>").description("Enable a schedule").action((id) => {
6239
6958
  try {
6240
6959
  const schedule = updateSchedule(id, { enabled: true });
6241
- console.log(chalk4.green(`Enabled schedule ${chalk4.bold(schedule.name)}`));
6960
+ console.log(chalk5.green(`Enabled schedule ${chalk5.bold(schedule.name)}`));
6242
6961
  } catch (error) {
6243
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6962
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6244
6963
  process.exit(1);
6245
6964
  }
6246
6965
  });
6247
6966
  scheduleCmd.command("disable <id>").description("Disable a schedule").action((id) => {
6248
6967
  try {
6249
6968
  const schedule = updateSchedule(id, { enabled: false });
6250
- console.log(chalk4.green(`Disabled schedule ${chalk4.bold(schedule.name)}`));
6969
+ console.log(chalk5.green(`Disabled schedule ${chalk5.bold(schedule.name)}`));
6251
6970
  } catch (error) {
6252
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6971
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6253
6972
  process.exit(1);
6254
6973
  }
6255
6974
  });
@@ -6257,13 +6976,13 @@ scheduleCmd.command("delete <id>").description("Delete a schedule").action((id)
6257
6976
  try {
6258
6977
  const deleted = deleteSchedule(id);
6259
6978
  if (deleted) {
6260
- console.log(chalk4.green(`Deleted schedule: ${id}`));
6979
+ console.log(chalk5.green(`Deleted schedule: ${id}`));
6261
6980
  } else {
6262
- console.error(chalk4.red(`Schedule not found: ${id}`));
6981
+ console.error(chalk5.red(`Schedule not found: ${id}`));
6263
6982
  process.exit(1);
6264
6983
  }
6265
6984
  } catch (error) {
6266
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6985
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6267
6986
  process.exit(1);
6268
6987
  }
6269
6988
  });
@@ -6271,11 +6990,11 @@ scheduleCmd.command("run <id>").description("Manually trigger a schedule").optio
6271
6990
  try {
6272
6991
  const schedule = getSchedule(id);
6273
6992
  if (!schedule) {
6274
- console.error(chalk4.red(`Schedule not found: ${id}`));
6993
+ console.error(chalk5.red(`Schedule not found: ${id}`));
6275
6994
  process.exit(1);
6276
6995
  return;
6277
6996
  }
6278
- console.log(chalk4.blue(`Running schedule ${chalk4.bold(schedule.name)} against ${schedule.url}...`));
6997
+ console.log(chalk5.blue(`Running schedule ${chalk5.bold(schedule.name)} against ${schedule.url}...`));
6279
6998
  const { run, results } = await runByFilter({
6280
6999
  url: schedule.url,
6281
7000
  tags: schedule.scenarioFilter.tags,
@@ -6294,15 +7013,15 @@ scheduleCmd.command("run <id>").description("Manually trigger a schedule").optio
6294
7013
  }
6295
7014
  process.exit(getExitCode(run));
6296
7015
  } catch (error) {
6297
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7016
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6298
7017
  process.exit(1);
6299
7018
  }
6300
7019
  });
6301
7020
  program2.command("daemon").description("Start the scheduler daemon").option("--interval <seconds>", "Check interval in seconds", "60").action(async (opts) => {
6302
7021
  try {
6303
7022
  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`));
7023
+ console.log(chalk5.blue("Scheduler daemon started. Press Ctrl+C to stop."));
7024
+ console.log(chalk5.dim(` Check interval: ${opts.interval}s`));
6306
7025
  let running = true;
6307
7026
  const checkAndRun = async () => {
6308
7027
  while (running) {
@@ -6311,7 +7030,7 @@ program2.command("daemon").description("Start the scheduler daemon").option("--i
6311
7030
  const now2 = new Date().toISOString();
6312
7031
  for (const schedule of schedules) {
6313
7032
  if (schedule.nextRunAt && schedule.nextRunAt <= now2) {
6314
- console.log(chalk4.blue(`[${new Date().toISOString()}] Triggering schedule: ${schedule.name}`));
7033
+ console.log(chalk5.blue(`[${new Date().toISOString()}] Triggering schedule: ${schedule.name}`));
6315
7034
  try {
6316
7035
  const { run } = await runByFilter({
6317
7036
  url: schedule.url,
@@ -6324,39 +7043,39 @@ program2.command("daemon").description("Start the scheduler daemon").option("--i
6324
7043
  timeout: schedule.timeoutMs ?? undefined,
6325
7044
  projectId: schedule.projectId ?? undefined
6326
7045
  });
6327
- const statusColor = run.status === "passed" ? chalk4.green : chalk4.red;
7046
+ const statusColor = run.status === "passed" ? chalk5.green : chalk5.red;
6328
7047
  console.log(` ${statusColor(run.status)} \u2014 ${run.passed}/${run.total} passed`);
6329
7048
  updateSchedule(schedule.id, {});
6330
7049
  } catch (err) {
6331
- console.error(chalk4.red(` Error running schedule ${schedule.name}: ${err instanceof Error ? err.message : String(err)}`));
7050
+ console.error(chalk5.red(` Error running schedule ${schedule.name}: ${err instanceof Error ? err.message : String(err)}`));
6332
7051
  }
6333
7052
  }
6334
7053
  }
6335
7054
  } catch (err) {
6336
- console.error(chalk4.red(`Daemon error: ${err instanceof Error ? err.message : String(err)}`));
7055
+ console.error(chalk5.red(`Daemon error: ${err instanceof Error ? err.message : String(err)}`));
6337
7056
  }
6338
7057
  await new Promise((resolve2) => setTimeout(resolve2, intervalMs));
6339
7058
  }
6340
7059
  };
6341
7060
  process.on("SIGINT", () => {
6342
- console.log(chalk4.yellow(`
7061
+ console.log(chalk5.yellow(`
6343
7062
  Shutting down scheduler daemon...`));
6344
7063
  running = false;
6345
7064
  process.exit(0);
6346
7065
  });
6347
7066
  process.on("SIGTERM", () => {
6348
- console.log(chalk4.yellow(`
7067
+ console.log(chalk5.yellow(`
6349
7068
  Shutting down scheduler daemon...`));
6350
7069
  running = false;
6351
7070
  process.exit(0);
6352
7071
  });
6353
7072
  await checkAndRun();
6354
7073
  } catch (error) {
6355
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7074
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6356
7075
  process.exit(1);
6357
7076
  }
6358
7077
  });
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) => {
7078
+ program2.command("init").description("Initialize a new testing project").option("-n, --name <name>", "Project name").option("-u, --url <url>", "Base URL").option("-p, --path <path>", "Project path").option("--ci <provider>", "Generate CI workflow (github)").action((opts) => {
6360
7079
  try {
6361
7080
  const { project, scenarios, framework } = initProject({
6362
7081
  name: opts.name,
@@ -6364,30 +7083,41 @@ program2.command("init").description("Initialize a new testing project").option(
6364
7083
  path: opts.path
6365
7084
  });
6366
7085
  console.log("");
6367
- console.log(chalk4.bold(" Project initialized!"));
7086
+ console.log(chalk5.bold(" Project initialized!"));
6368
7087
  console.log("");
6369
7088
  if (framework) {
6370
- console.log(` Framework: ${chalk4.cyan(framework.name)}`);
7089
+ console.log(` Framework: ${chalk5.cyan(framework.name)}`);
6371
7090
  if (framework.features.length > 0) {
6372
- console.log(` Features: ${chalk4.dim(framework.features.join(", "))}`);
7091
+ console.log(` Features: ${chalk5.dim(framework.features.join(", "))}`);
6373
7092
  }
6374
7093
  } else {
6375
- console.log(` Framework: ${chalk4.dim("not detected")}`);
7094
+ console.log(` Framework: ${chalk5.dim("not detected")}`);
6376
7095
  }
6377
- console.log(` Project: ${chalk4.green(project.name)} ${chalk4.dim(`(${project.id})`)}`);
6378
- console.log(` Scenarios: ${chalk4.green(String(scenarios.length))} starter scenarios created`);
7096
+ console.log(` Project: ${chalk5.green(project.name)} ${chalk5.dim(`(${project.id})`)}`);
7097
+ console.log(` Scenarios: ${chalk5.green(String(scenarios.length))} starter scenarios created`);
6379
7098
  console.log("");
6380
7099
  for (const s of scenarios) {
6381
- console.log(` ${chalk4.dim(s.shortId)} ${s.name} ${chalk4.dim(`[${s.tags.join(", ")}]`)}`);
7100
+ console.log(` ${chalk5.dim(s.shortId)} ${s.name} ${chalk5.dim(`[${s.tags.join(", ")}]`)}`);
7101
+ }
7102
+ if (opts.ci === "github") {
7103
+ const workflowDir = join6(process.cwd(), ".github", "workflows");
7104
+ if (!existsSync8(workflowDir)) {
7105
+ mkdirSync4(workflowDir, { recursive: true });
7106
+ }
7107
+ const workflowPath = join6(workflowDir, "testers.yml");
7108
+ writeFileSync3(workflowPath, generateGitHubActionsWorkflow(), "utf-8");
7109
+ console.log(` CI: ${chalk5.green("GitHub Actions workflow written to .github/workflows/testers.yml")}`);
7110
+ } else if (opts.ci) {
7111
+ console.log(chalk5.yellow(` Unknown CI provider: ${opts.ci}. Supported: github`));
6382
7112
  }
6383
7113
  console.log("");
6384
- console.log(chalk4.bold(" Next steps:"));
7114
+ console.log(chalk5.bold(" Next steps:"));
6385
7115
  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>")}`);
7116
+ console.log(` 2. Run ${chalk5.cyan("testers run <url>")} to execute tests`);
7117
+ console.log(` 3. Add more scenarios with ${chalk5.cyan("testers add <name>")}`);
6388
7118
  console.log("");
6389
7119
  } catch (error) {
6390
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7120
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6391
7121
  process.exit(1);
6392
7122
  }
6393
7123
  });
@@ -6395,16 +7125,16 @@ program2.command("replay <run-id>").description("Re-run all scenarios from a pre
6395
7125
  try {
6396
7126
  const originalRun = getRun(runId);
6397
7127
  if (!originalRun) {
6398
- console.error(chalk4.red(`Run not found: ${runId}`));
7128
+ console.error(chalk5.red(`Run not found: ${runId}`));
6399
7129
  process.exit(1);
6400
7130
  }
6401
7131
  const originalResults = getResultsByRun(originalRun.id);
6402
7132
  const scenarioIds = originalResults.map((r) => r.scenarioId);
6403
7133
  if (scenarioIds.length === 0) {
6404
- console.log(chalk4.dim("No scenarios to replay."));
7134
+ console.log(chalk5.dim("No scenarios to replay."));
6405
7135
  return;
6406
7136
  }
6407
- console.log(chalk4.blue(`Replaying ${scenarioIds.length} scenarios from run ${originalRun.id.slice(0, 8)}...`));
7137
+ console.log(chalk5.blue(`Replaying ${scenarioIds.length} scenarios from run ${originalRun.id.slice(0, 8)}...`));
6408
7138
  const { run, results } = await runByFilter({
6409
7139
  url: opts.url ?? originalRun.url,
6410
7140
  scenarioIds,
@@ -6419,7 +7149,7 @@ program2.command("replay <run-id>").description("Re-run all scenarios from a pre
6419
7149
  }
6420
7150
  process.exit(getExitCode(run));
6421
7151
  } catch (error) {
6422
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7152
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6423
7153
  process.exit(1);
6424
7154
  }
6425
7155
  });
@@ -6427,16 +7157,16 @@ program2.command("retry <run-id>").description("Re-run only failed scenarios fro
6427
7157
  try {
6428
7158
  const originalRun = getRun(runId);
6429
7159
  if (!originalRun) {
6430
- console.error(chalk4.red(`Run not found: ${runId}`));
7160
+ console.error(chalk5.red(`Run not found: ${runId}`));
6431
7161
  process.exit(1);
6432
7162
  }
6433
7163
  const originalResults = getResultsByRun(originalRun.id);
6434
7164
  const failedScenarioIds = originalResults.filter((r) => r.status === "failed" || r.status === "error").map((r) => r.scenarioId);
6435
7165
  if (failedScenarioIds.length === 0) {
6436
- console.log(chalk4.green("No failed scenarios to retry. All passed!"));
7166
+ console.log(chalk5.green("No failed scenarios to retry. All passed!"));
6437
7167
  return;
6438
7168
  }
6439
- console.log(chalk4.blue(`Retrying ${failedScenarioIds.length} failed scenarios from run ${originalRun.id.slice(0, 8)}...`));
7169
+ console.log(chalk5.blue(`Retrying ${failedScenarioIds.length} failed scenarios from run ${originalRun.id.slice(0, 8)}...`));
6440
7170
  const { run, results } = await runByFilter({
6441
7171
  url: opts.url ?? originalRun.url,
6442
7172
  scenarioIds: failedScenarioIds,
@@ -6446,13 +7176,13 @@ program2.command("retry <run-id>").description("Re-run only failed scenarios fro
6446
7176
  });
6447
7177
  if (!opts.json) {
6448
7178
  console.log("");
6449
- console.log(chalk4.bold(" Comparison with original run:"));
7179
+ console.log(chalk5.bold(" Comparison with original run:"));
6450
7180
  for (const result of results) {
6451
7181
  const original = originalResults.find((r) => r.scenarioId === result.scenarioId);
6452
7182
  if (original) {
6453
7183
  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");
7184
+ const arrow = changed ? chalk5.yellow(`${original.status} \u2192 ${result.status}`) : chalk5.dim(`${result.status} (unchanged)`);
7185
+ const icon = result.status === "passed" ? chalk5.green("\u2713") : chalk5.red("\u2717");
6456
7186
  console.log(` ${icon} ${result.scenarioId.slice(0, 8)}: ${arrow}`);
6457
7187
  }
6458
7188
  }
@@ -6465,14 +7195,14 @@ program2.command("retry <run-id>").description("Re-run only failed scenarios fro
6465
7195
  }
6466
7196
  process.exit(getExitCode(run));
6467
7197
  } catch (error) {
6468
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7198
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6469
7199
  process.exit(1);
6470
7200
  }
6471
7201
  });
6472
7202
  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
7203
  try {
6474
7204
  const projectId = resolveProject(opts.project);
6475
- console.log(chalk4.blue(`Running smoke test against ${chalk4.bold(url)}...`));
7205
+ console.log(chalk5.blue(`Running smoke test against ${chalk5.bold(url)}...`));
6476
7206
  console.log("");
6477
7207
  const smokeResult = await runSmoke({
6478
7208
  url,
@@ -6494,11 +7224,11 @@ program2.command("smoke <url>").description("Run autonomous smoke test").option(
6494
7224
  const hasCritical = smokeResult.issuesFound.some((i) => i.severity === "critical" || i.severity === "high");
6495
7225
  process.exit(hasCritical ? 1 : 0);
6496
7226
  } catch (error) {
6497
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7227
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6498
7228
  process.exit(1);
6499
7229
  }
6500
7230
  });
6501
- program2.command("diff <run1> <run2>").description("Compare two test runs").option("--json", "JSON output", false).action((run1, run2, opts) => {
7231
+ 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
7232
  try {
6503
7233
  const diff = diffRuns(run1, run2);
6504
7234
  if (opts.json) {
@@ -6506,9 +7236,19 @@ program2.command("diff <run1> <run2>").description("Compare two test runs").opti
6506
7236
  } else {
6507
7237
  console.log(formatDiffTerminal(diff));
6508
7238
  }
6509
- process.exit(diff.regressions.length > 0 ? 1 : 0);
7239
+ const threshold = parseFloat(opts.threshold);
7240
+ const visualResults = compareRunScreenshots(run2, run1, threshold);
7241
+ if (visualResults.length > 0) {
7242
+ if (opts.json) {
7243
+ console.log(JSON.stringify({ visualDiff: visualResults }, null, 2));
7244
+ } else {
7245
+ console.log(formatVisualDiffTerminal(visualResults, threshold));
7246
+ }
7247
+ }
7248
+ const hasVisualRegressions = visualResults.some((r) => r.isRegression);
7249
+ process.exit(diff.regressions.length > 0 || hasVisualRegressions ? 1 : 0);
6510
7250
  } catch (error) {
6511
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7251
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6512
7252
  process.exit(1);
6513
7253
  }
6514
7254
  });
@@ -6521,9 +7261,9 @@ program2.command("report [run-id]").description("Generate HTML test report").opt
6521
7261
  html = generateHtmlReport(runId);
6522
7262
  }
6523
7263
  writeFileSync3(opts.output, html, "utf-8");
6524
- console.log(chalk4.green(`Report generated: ${opts.output}`));
7264
+ console.log(chalk5.green(`Report generated: ${opts.output}`));
6525
7265
  } catch (error) {
6526
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7266
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6527
7267
  process.exit(1);
6528
7268
  }
6529
7269
  });
@@ -6536,9 +7276,9 @@ authCmd.command("add <name>").description("Create an auth preset").requiredOptio
6536
7276
  password: opts.password,
6537
7277
  loginPath: opts.loginPath
6538
7278
  });
6539
- console.log(chalk4.green(`Created auth preset ${chalk4.bold(preset.name)} (${preset.email})`));
7279
+ console.log(chalk5.green(`Created auth preset ${chalk5.bold(preset.name)} (${preset.email})`));
6540
7280
  } catch (error) {
6541
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7281
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6542
7282
  process.exit(1);
6543
7283
  }
6544
7284
  });
@@ -6546,11 +7286,11 @@ authCmd.command("list").description("List auth presets").action(() => {
6546
7286
  try {
6547
7287
  const presets = listAuthPresets();
6548
7288
  if (presets.length === 0) {
6549
- console.log(chalk4.dim("No auth presets found."));
7289
+ console.log(chalk5.dim("No auth presets found."));
6550
7290
  return;
6551
7291
  }
6552
7292
  console.log("");
6553
- console.log(chalk4.bold(" Auth Presets"));
7293
+ console.log(chalk5.bold(" Auth Presets"));
6554
7294
  console.log("");
6555
7295
  console.log(` ${"Name".padEnd(20)} ${"Email".padEnd(30)} ${"Login Path".padEnd(15)} Created`);
6556
7296
  console.log(` ${"\u2500".repeat(20)} ${"\u2500".repeat(30)} ${"\u2500".repeat(15)} ${"\u2500".repeat(22)}`);
@@ -6559,7 +7299,7 @@ authCmd.command("list").description("List auth presets").action(() => {
6559
7299
  }
6560
7300
  console.log("");
6561
7301
  } catch (error) {
6562
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7302
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6563
7303
  process.exit(1);
6564
7304
  }
6565
7305
  });
@@ -6567,13 +7307,13 @@ authCmd.command("delete <name>").description("Delete an auth preset").action((na
6567
7307
  try {
6568
7308
  const deleted = deleteAuthPreset(name);
6569
7309
  if (deleted) {
6570
- console.log(chalk4.green(`Deleted auth preset: ${name}`));
7310
+ console.log(chalk5.green(`Deleted auth preset: ${name}`));
6571
7311
  } else {
6572
- console.error(chalk4.red(`Auth preset not found: ${name}`));
7312
+ console.error(chalk5.red(`Auth preset not found: ${name}`));
6573
7313
  process.exit(1);
6574
7314
  }
6575
7315
  } catch (error) {
6576
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7316
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6577
7317
  process.exit(1);
6578
7318
  }
6579
7319
  });
@@ -6586,7 +7326,7 @@ program2.command("costs").description("Show cost tracking and budget status").op
6586
7326
  console.log(formatCostsTerminal(summary));
6587
7327
  }
6588
7328
  } catch (error) {
6589
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7329
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6590
7330
  process.exit(1);
6591
7331
  }
6592
7332
  });
@@ -6594,18 +7334,18 @@ program2.command("chain <scenario-id>").description("Add a dependency to a scena
6594
7334
  try {
6595
7335
  const scenario = getScenario(scenarioId) ?? getScenarioByShortId(scenarioId);
6596
7336
  if (!scenario) {
6597
- console.error(chalk4.red(`Scenario not found: ${scenarioId}`));
7337
+ console.error(chalk5.red(`Scenario not found: ${scenarioId}`));
6598
7338
  process.exit(1);
6599
7339
  }
6600
7340
  const dep = getScenario(opts.dependsOn) ?? getScenarioByShortId(opts.dependsOn);
6601
7341
  if (!dep) {
6602
- console.error(chalk4.red(`Dependency scenario not found: ${opts.dependsOn}`));
7342
+ console.error(chalk5.red(`Dependency scenario not found: ${opts.dependsOn}`));
6603
7343
  process.exit(1);
6604
7344
  }
6605
7345
  addDependency(scenario.id, dep.id);
6606
- console.log(chalk4.green(`${scenario.shortId} now depends on ${dep.shortId}`));
7346
+ console.log(chalk5.green(`${scenario.shortId} now depends on ${dep.shortId}`));
6607
7347
  } catch (error) {
6608
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7348
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6609
7349
  process.exit(1);
6610
7350
  }
6611
7351
  });
@@ -6613,18 +7353,18 @@ program2.command("unchain <scenario-id>").description("Remove a dependency from
6613
7353
  try {
6614
7354
  const scenario = getScenario(scenarioId) ?? getScenarioByShortId(scenarioId);
6615
7355
  if (!scenario) {
6616
- console.error(chalk4.red(`Scenario not found: ${scenarioId}`));
7356
+ console.error(chalk5.red(`Scenario not found: ${scenarioId}`));
6617
7357
  process.exit(1);
6618
7358
  }
6619
7359
  const dep = getScenario(opts.from) ?? getScenarioByShortId(opts.from);
6620
7360
  if (!dep) {
6621
- console.error(chalk4.red(`Dependency not found: ${opts.from}`));
7361
+ console.error(chalk5.red(`Dependency not found: ${opts.from}`));
6622
7362
  process.exit(1);
6623
7363
  }
6624
7364
  removeDependency(scenario.id, dep.id);
6625
- console.log(chalk4.green(`Removed dependency: ${scenario.shortId} no longer depends on ${dep.shortId}`));
7365
+ console.log(chalk5.green(`Removed dependency: ${scenario.shortId} no longer depends on ${dep.shortId}`));
6626
7366
  } catch (error) {
6627
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7367
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6628
7368
  process.exit(1);
6629
7369
  }
6630
7370
  });
@@ -6632,26 +7372,26 @@ program2.command("deps <scenario-id>").description("Show dependencies for a scen
6632
7372
  try {
6633
7373
  const scenario = getScenario(scenarioId) ?? getScenarioByShortId(scenarioId);
6634
7374
  if (!scenario) {
6635
- console.error(chalk4.red(`Scenario not found: ${scenarioId}`));
7375
+ console.error(chalk5.red(`Scenario not found: ${scenarioId}`));
6636
7376
  process.exit(1);
6637
7377
  }
6638
7378
  const deps = getDependencies(scenario.id);
6639
7379
  const dependents = getDependents(scenario.id);
6640
7380
  console.log("");
6641
- console.log(chalk4.bold(` Dependencies for ${scenario.shortId}: ${scenario.name}`));
7381
+ console.log(chalk5.bold(` Dependencies for ${scenario.shortId}: ${scenario.name}`));
6642
7382
  console.log("");
6643
7383
  if (deps.length > 0) {
6644
- console.log(chalk4.dim(" Depends on:"));
7384
+ console.log(chalk5.dim(" Depends on:"));
6645
7385
  for (const depId of deps) {
6646
7386
  const s = getScenario(depId);
6647
7387
  console.log(` \u2192 ${s ? `${s.shortId}: ${s.name}` : depId.slice(0, 8)}`);
6648
7388
  }
6649
7389
  } else {
6650
- console.log(chalk4.dim(" No dependencies"));
7390
+ console.log(chalk5.dim(" No dependencies"));
6651
7391
  }
6652
7392
  if (dependents.length > 0) {
6653
7393
  console.log("");
6654
- console.log(chalk4.dim(" Required by:"));
7394
+ console.log(chalk5.dim(" Required by:"));
6655
7395
  for (const depId of dependents) {
6656
7396
  const s = getScenario(depId);
6657
7397
  console.log(` \u2190 ${s ? `${s.shortId}: ${s.name}` : depId.slice(0, 8)}`);
@@ -6659,7 +7399,7 @@ program2.command("deps <scenario-id>").description("Show dependencies for a scen
6659
7399
  }
6660
7400
  console.log("");
6661
7401
  } catch (error) {
6662
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7402
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6663
7403
  process.exit(1);
6664
7404
  }
6665
7405
  });
@@ -6669,7 +7409,7 @@ flowCmd.command("create <name>").description("Create a flow from scenario IDs").
6669
7409
  const ids = opts.chain.split(",").map((id) => {
6670
7410
  const s = getScenario(id.trim()) ?? getScenarioByShortId(id.trim());
6671
7411
  if (!s) {
6672
- console.error(chalk4.red(`Scenario not found: ${id.trim()}`));
7412
+ console.error(chalk5.red(`Scenario not found: ${id.trim()}`));
6673
7413
  process.exit(1);
6674
7414
  }
6675
7415
  return s.id;
@@ -6680,37 +7420,37 @@ flowCmd.command("create <name>").description("Create a flow from scenario IDs").
6680
7420
  } catch {}
6681
7421
  }
6682
7422
  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)`));
7423
+ console.log(chalk5.green(`Flow created: ${flow.id.slice(0, 8)} \u2014 ${flow.name} (${ids.length} scenarios)`));
6684
7424
  } catch (error) {
6685
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7425
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6686
7426
  process.exit(1);
6687
7427
  }
6688
7428
  });
6689
7429
  flowCmd.command("list").description("List all flows").option("--project <id>", "Project ID").action((opts) => {
6690
7430
  const flows = listFlows(resolveProject(opts.project) ?? undefined);
6691
7431
  if (flows.length === 0) {
6692
- console.log(chalk4.dim(`
7432
+ console.log(chalk5.dim(`
6693
7433
  No flows found.
6694
7434
  `));
6695
7435
  return;
6696
7436
  }
6697
7437
  console.log("");
6698
- console.log(chalk4.bold(" Flows"));
7438
+ console.log(chalk5.bold(" Flows"));
6699
7439
  console.log("");
6700
7440
  for (const f of flows) {
6701
- console.log(` ${chalk4.dim(f.id.slice(0, 8))} ${f.name} ${chalk4.dim(`(${f.scenarioIds.length} scenarios)`)}`);
7441
+ console.log(` ${chalk5.dim(f.id.slice(0, 8))} ${f.name} ${chalk5.dim(`(${f.scenarioIds.length} scenarios)`)}`);
6702
7442
  }
6703
7443
  console.log("");
6704
7444
  });
6705
7445
  flowCmd.command("show <id>").description("Show flow details").action((id) => {
6706
7446
  const flow = getFlow(id);
6707
7447
  if (!flow) {
6708
- console.error(chalk4.red(`Flow not found: ${id}`));
7448
+ console.error(chalk5.red(`Flow not found: ${id}`));
6709
7449
  process.exit(1);
6710
7450
  }
6711
7451
  console.log("");
6712
- console.log(chalk4.bold(` Flow: ${flow.name}`));
6713
- console.log(` ID: ${chalk4.dim(flow.id)}`);
7452
+ console.log(chalk5.bold(` Flow: ${flow.name}`));
7453
+ console.log(` ID: ${chalk5.dim(flow.id)}`);
6714
7454
  console.log(` Scenarios (in order):`);
6715
7455
  for (let i = 0;i < flow.scenarioIds.length; i++) {
6716
7456
  const s = getScenario(flow.scenarioIds[i]);
@@ -6720,9 +7460,9 @@ flowCmd.command("show <id>").description("Show flow details").action((id) => {
6720
7460
  });
6721
7461
  flowCmd.command("delete <id>").description("Delete a flow").action((id) => {
6722
7462
  if (deleteFlow(id))
6723
- console.log(chalk4.green("Flow deleted."));
7463
+ console.log(chalk5.green("Flow deleted."));
6724
7464
  else {
6725
- console.error(chalk4.red("Flow not found."));
7465
+ console.error(chalk5.red("Flow not found."));
6726
7466
  process.exit(1);
6727
7467
  }
6728
7468
  });
@@ -6730,14 +7470,14 @@ flowCmd.command("run <id>").description("Run a flow (scenarios in dependency ord
6730
7470
  try {
6731
7471
  const flow = getFlow(id);
6732
7472
  if (!flow) {
6733
- console.error(chalk4.red(`Flow not found: ${id}`));
7473
+ console.error(chalk5.red(`Flow not found: ${id}`));
6734
7474
  process.exit(1);
6735
7475
  }
6736
7476
  if (!opts.url) {
6737
- console.error(chalk4.red("--url is required for flow run"));
7477
+ console.error(chalk5.red("--url is required for flow run"));
6738
7478
  process.exit(1);
6739
7479
  }
6740
- console.log(chalk4.blue(`Running flow: ${flow.name} (${flow.scenarioIds.length} scenarios)`));
7480
+ console.log(chalk5.blue(`Running flow: ${flow.name} (${flow.scenarioIds.length} scenarios)`));
6741
7481
  const { run, results } = await runByFilter({
6742
7482
  url: opts.url,
6743
7483
  scenarioIds: flow.scenarioIds,
@@ -6751,7 +7491,103 @@ flowCmd.command("run <id>").description("Run a flow (scenarios in dependency ord
6751
7491
  console.log(formatTerminal(run, results));
6752
7492
  process.exit(getExitCode(run));
6753
7493
  } catch (error) {
6754
- console.error(chalk4.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7494
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7495
+ process.exit(1);
7496
+ }
7497
+ });
7498
+ var envCmd = program2.command("env").description("Manage environments");
7499
+ 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) => {
7500
+ try {
7501
+ const env = createEnvironment({
7502
+ name,
7503
+ url: opts.url,
7504
+ authPresetName: opts.auth,
7505
+ projectId: opts.project,
7506
+ isDefault: opts.default
7507
+ });
7508
+ console.log(chalk5.green(`Environment added: ${env.name} \u2192 ${env.url}${env.isDefault ? " (default)" : ""}`));
7509
+ } catch (error) {
7510
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7511
+ process.exit(1);
7512
+ }
7513
+ });
7514
+ envCmd.command("list").description("List all environments").option("--project <id>", "Filter by project ID").action((opts) => {
7515
+ try {
7516
+ const envs = listEnvironments(opts.project);
7517
+ if (envs.length === 0) {
7518
+ console.log(chalk5.dim("No environments configured. Add one with: testers env add <name> --url <url>"));
7519
+ return;
7520
+ }
7521
+ for (const env of envs) {
7522
+ const marker = env.isDefault ? chalk5.green(" \u2605 default") : "";
7523
+ const auth = env.authPresetName ? chalk5.dim(` (auth: ${env.authPresetName})`) : "";
7524
+ console.log(` ${chalk5.bold(env.name)} ${env.url}${auth}${marker}`);
7525
+ }
7526
+ } catch (error) {
7527
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7528
+ process.exit(1);
7529
+ }
7530
+ });
7531
+ envCmd.command("use <name>").description("Set an environment as the default").action((name) => {
7532
+ try {
7533
+ setDefaultEnvironment(name);
7534
+ const env = getEnvironment(name);
7535
+ console.log(chalk5.green(`Default environment set: ${env.name} \u2192 ${env.url}`));
7536
+ } catch (error) {
7537
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7538
+ process.exit(1);
7539
+ }
7540
+ });
7541
+ envCmd.command("delete <name>").description("Delete an environment").action((name) => {
7542
+ try {
7543
+ const deleted = deleteEnvironment(name);
7544
+ if (deleted) {
7545
+ console.log(chalk5.green(`Environment deleted: ${name}`));
7546
+ } else {
7547
+ console.error(chalk5.red(`Environment not found: ${name}`));
7548
+ process.exit(1);
7549
+ }
7550
+ } catch (error) {
7551
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7552
+ process.exit(1);
7553
+ }
7554
+ });
7555
+ program2.command("baseline <run-id>").description("Set a run as the visual baseline").action((runId) => {
7556
+ try {
7557
+ setBaseline(runId);
7558
+ const run = getRun(runId);
7559
+ console.log(chalk5.green(`Baseline set: ${chalk5.bold(runId.slice(0, 8))}${run ? ` (${run.status}, ${run.total} scenarios)` : ""}`));
7560
+ } catch (error) {
7561
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7562
+ process.exit(1);
7563
+ }
7564
+ });
7565
+ program2.command("import-api <spec>").description("Import test scenarios from an OpenAPI/Swagger spec file").option("--project <id>", "Project ID").action(async (spec, opts) => {
7566
+ try {
7567
+ const { importFromOpenAPI: importFromOpenAPI2 } = await Promise.resolve().then(() => (init_openapi_import(), exports_openapi_import));
7568
+ const { imported, scenarios } = importFromOpenAPI2(spec, resolveProject(opts.project) ?? undefined);
7569
+ console.log(chalk5.green(`
7570
+ Imported ${imported} scenarios from API spec:`));
7571
+ for (const s of scenarios) {
7572
+ console.log(` ${chalk5.cyan(s.shortId)} ${s.name} ${chalk5.dim(`[${s.tags.join(", ")}]`)}`);
7573
+ }
7574
+ console.log("");
7575
+ } catch (error) {
7576
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
7577
+ process.exit(1);
7578
+ }
7579
+ });
7580
+ program2.command("record <url>").description("Record a browser session and generate a test scenario").option("-n, --name <name>", "Scenario name", "Recorded session").option("--project <id>", "Project ID").action(async (url, opts) => {
7581
+ try {
7582
+ const { recordAndSave: recordAndSave2 } = await Promise.resolve().then(() => (init_recorder(), exports_recorder));
7583
+ console.log(chalk5.blue("Opening browser for recording..."));
7584
+ const { recording, scenario } = await recordAndSave2(url, opts.name, resolveProject(opts.project) ?? undefined);
7585
+ console.log("");
7586
+ console.log(chalk5.green(`Recording saved as scenario ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
7587
+ console.log(chalk5.dim(` ${recording.actions.length} actions recorded in ${(recording.duration / 1000).toFixed(0)}s`));
7588
+ console.log(chalk5.dim(` ${scenario.steps.length} steps generated`));
7589
+ } catch (error) {
7590
+ console.error(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
6755
7591
  process.exit(1);
6756
7592
  }
6757
7593
  });