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