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