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