@hasna/testers 0.0.8 → 0.0.11
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-DyXKnBM8.css +1 -0
- package/dashboard/dist/assets/index-jNG_Nd_Q.js +49 -0
- package/dashboard/dist/index.html +2 -2
- package/dist/cli/index.js +1437 -361
- 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 +5 -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.js +301 -25
- package/dist/lib/costs.d.ts +13 -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 +9 -1
- package/dist/lib/reporter.d.ts.map +1 -1
- package/dist/lib/runner.d.ts +7 -1
- package/dist/lib/runner.d.ts.map +1 -1
- package/dist/mcp/index.js +78 -5
- package/dist/server/index.js +4257 -19
- package/dist/types/index.d.ts +5 -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
|
@@ -2560,7 +2560,10 @@ function listScenarios(filter) {
|
|
|
2560
2560
|
if (conditions.length > 0) {
|
|
2561
2561
|
sql += " WHERE " + conditions.join(" AND ");
|
|
2562
2562
|
}
|
|
2563
|
-
|
|
2563
|
+
const sortField = filter?.sort ?? "date";
|
|
2564
|
+
const sortDir = filter?.desc === false ? "ASC" : "DESC";
|
|
2565
|
+
const orderByCol = sortField === "name" ? "name" : sortField === "priority" ? "CASE priority WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END" : "created_at";
|
|
2566
|
+
sql += ` ORDER BY ${orderByCol} ${sortDir}`;
|
|
2564
2567
|
if (filter?.limit) {
|
|
2565
2568
|
sql += " LIMIT ?";
|
|
2566
2569
|
params.push(filter.limit);
|
|
@@ -2666,7 +2669,8 @@ __export(exports_runs, {
|
|
|
2666
2669
|
listRuns: () => listRuns,
|
|
2667
2670
|
getRun: () => getRun,
|
|
2668
2671
|
deleteRun: () => deleteRun,
|
|
2669
|
-
createRun: () => createRun
|
|
2672
|
+
createRun: () => createRun,
|
|
2673
|
+
countRuns: () => countRuns
|
|
2670
2674
|
});
|
|
2671
2675
|
function createRun(input) {
|
|
2672
2676
|
const db2 = getDatabase();
|
|
@@ -2707,7 +2711,10 @@ function listRuns(filter) {
|
|
|
2707
2711
|
if (conditions.length > 0) {
|
|
2708
2712
|
sql += " WHERE " + conditions.join(" AND ");
|
|
2709
2713
|
}
|
|
2710
|
-
|
|
2714
|
+
const sortField = filter?.sort ?? "date";
|
|
2715
|
+
const sortDir = filter?.desc === false ? "ASC" : "DESC";
|
|
2716
|
+
const orderByCol = sortField === "duration" ? "(CASE WHEN finished_at IS NULL THEN NULL ELSE (julianday(finished_at) - julianday(started_at)) * 86400000 END)" : sortField === "cost" ? "(SELECT COALESCE(SUM(cost_cents), 0) FROM results WHERE run_id = runs.id)" : "started_at";
|
|
2717
|
+
sql += ` ORDER BY ${orderByCol} ${sortDir}`;
|
|
2711
2718
|
if (filter?.limit) {
|
|
2712
2719
|
sql += " LIMIT ?";
|
|
2713
2720
|
params.push(filter.limit);
|
|
@@ -2719,6 +2726,24 @@ function listRuns(filter) {
|
|
|
2719
2726
|
const rows = db2.query(sql).all(...params);
|
|
2720
2727
|
return rows.map(runFromRow);
|
|
2721
2728
|
}
|
|
2729
|
+
function countRuns(filter) {
|
|
2730
|
+
const db2 = getDatabase();
|
|
2731
|
+
const conditions = [];
|
|
2732
|
+
const params = [];
|
|
2733
|
+
if (filter?.projectId) {
|
|
2734
|
+
conditions.push("project_id = ?");
|
|
2735
|
+
params.push(filter.projectId);
|
|
2736
|
+
}
|
|
2737
|
+
if (filter?.status) {
|
|
2738
|
+
conditions.push("status = ?");
|
|
2739
|
+
params.push(filter.status);
|
|
2740
|
+
}
|
|
2741
|
+
let sql = "SELECT COUNT(*) as count FROM runs";
|
|
2742
|
+
if (conditions.length > 0)
|
|
2743
|
+
sql += " WHERE " + conditions.join(" AND ");
|
|
2744
|
+
const row = db2.query(sql).get(...params);
|
|
2745
|
+
return row.count;
|
|
2746
|
+
}
|
|
2722
2747
|
function updateRun(id, updates) {
|
|
2723
2748
|
const db2 = getDatabase();
|
|
2724
2749
|
const existing = getRun(id);
|
|
@@ -3383,11 +3408,95 @@ var {
|
|
|
3383
3408
|
Help
|
|
3384
3409
|
} = import__.default;
|
|
3385
3410
|
|
|
3411
|
+
// src/cli/index.tsx
|
|
3412
|
+
import chalk5 from "chalk";
|
|
3413
|
+
// package.json
|
|
3414
|
+
var package_default = {
|
|
3415
|
+
name: "@hasna/testers",
|
|
3416
|
+
version: "0.0.11",
|
|
3417
|
+
description: "AI-powered QA testing CLI \u2014 spawns cheap AI agents to test web apps with headless browsers",
|
|
3418
|
+
type: "module",
|
|
3419
|
+
main: "dist/index.js",
|
|
3420
|
+
types: "dist/index.d.ts",
|
|
3421
|
+
bin: {
|
|
3422
|
+
testers: "dist/cli/index.js",
|
|
3423
|
+
"testers-mcp": "dist/mcp/index.js",
|
|
3424
|
+
"testers-serve": "dist/server/index.js"
|
|
3425
|
+
},
|
|
3426
|
+
exports: {
|
|
3427
|
+
".": {
|
|
3428
|
+
types: "./dist/index.d.ts",
|
|
3429
|
+
import: "./dist/index.js"
|
|
3430
|
+
}
|
|
3431
|
+
},
|
|
3432
|
+
files: [
|
|
3433
|
+
"dist/",
|
|
3434
|
+
"dashboard/dist/",
|
|
3435
|
+
"LICENSE",
|
|
3436
|
+
"README.md"
|
|
3437
|
+
],
|
|
3438
|
+
scripts: {
|
|
3439
|
+
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",
|
|
3440
|
+
"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",
|
|
3441
|
+
"build:mcp": "bun build src/mcp/index.ts --outdir dist/mcp --target bun --external @modelcontextprotocol/sdk --external @anthropic-ai/sdk --external playwright",
|
|
3442
|
+
"build:server": "bun build src/server/index.ts --outdir dist/server --target bun --external @anthropic-ai/sdk --external playwright",
|
|
3443
|
+
"build:lib": "bun build src/index.ts --outdir dist --target bun --external playwright --external @anthropic-ai/sdk --external @modelcontextprotocol/sdk",
|
|
3444
|
+
"build:types": "NODE_OPTIONS='--max-old-space-size=8192' tsc --emitDeclarationOnly --outDir dist --skipLibCheck",
|
|
3445
|
+
"build:dashboard": "cd dashboard && bun run build",
|
|
3446
|
+
typecheck: "tsc --noEmit",
|
|
3447
|
+
test: "bun test",
|
|
3448
|
+
"dev:cli": "bun run src/cli/index.tsx",
|
|
3449
|
+
"dev:mcp": "bun run src/mcp/index.ts",
|
|
3450
|
+
"dev:serve": "bun run src/server/index.ts",
|
|
3451
|
+
prepublishOnly: "bun run build"
|
|
3452
|
+
},
|
|
3453
|
+
dependencies: {
|
|
3454
|
+
"@anthropic-ai/sdk": "^0.52.0",
|
|
3455
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
3456
|
+
chalk: "^5.4.1",
|
|
3457
|
+
commander: "^13.1.0",
|
|
3458
|
+
ink: "^5.2.0",
|
|
3459
|
+
playwright: "^1.50.0",
|
|
3460
|
+
react: "^18.3.1",
|
|
3461
|
+
zod: "^3.24.2"
|
|
3462
|
+
},
|
|
3463
|
+
devDependencies: {
|
|
3464
|
+
"@types/bun": "latest",
|
|
3465
|
+
"@types/react": "^18.3.18",
|
|
3466
|
+
typescript: "^5.7.3"
|
|
3467
|
+
},
|
|
3468
|
+
engines: {
|
|
3469
|
+
bun: ">=1.0.0"
|
|
3470
|
+
},
|
|
3471
|
+
publishConfig: {
|
|
3472
|
+
access: "public",
|
|
3473
|
+
registry: "https://registry.npmjs.org/"
|
|
3474
|
+
},
|
|
3475
|
+
repository: {
|
|
3476
|
+
type: "git",
|
|
3477
|
+
url: "https://github.com/hasna/open-testers.git"
|
|
3478
|
+
},
|
|
3479
|
+
license: "MIT",
|
|
3480
|
+
keywords: [
|
|
3481
|
+
"testing",
|
|
3482
|
+
"qa",
|
|
3483
|
+
"ai",
|
|
3484
|
+
"playwright",
|
|
3485
|
+
"browser",
|
|
3486
|
+
"screenshot",
|
|
3487
|
+
"automation",
|
|
3488
|
+
"cli",
|
|
3489
|
+
"mcp"
|
|
3490
|
+
]
|
|
3491
|
+
};
|
|
3492
|
+
|
|
3386
3493
|
// src/cli/index.tsx
|
|
3387
3494
|
init_scenarios();
|
|
3388
3495
|
init_runs();
|
|
3389
|
-
import
|
|
3496
|
+
import { render, Box, Text, useInput, useApp } from "ink";
|
|
3497
|
+
import React, { useState } from "react";
|
|
3390
3498
|
import { readFileSync as readFileSync6, readdirSync, writeFileSync as writeFileSync3 } from "fs";
|
|
3499
|
+
import { createInterface } from "readline";
|
|
3391
3500
|
import { join as join6, resolve } from "path";
|
|
3392
3501
|
|
|
3393
3502
|
// src/db/results.ts
|
|
@@ -4595,6 +4704,38 @@ async function dispatchWebhooks(event, run, schedule) {
|
|
|
4595
4704
|
}
|
|
4596
4705
|
}
|
|
4597
4706
|
|
|
4707
|
+
// src/lib/logs-integration.ts
|
|
4708
|
+
async function pushFailedRunToLogs(run, failedResults, scenarios) {
|
|
4709
|
+
const logsUrl = process.env.LOGS_URL;
|
|
4710
|
+
if (!logsUrl)
|
|
4711
|
+
return;
|
|
4712
|
+
const scenarioMap = new Map(scenarios.map((s) => [s.id, s]));
|
|
4713
|
+
const entries = failedResults.map((result) => {
|
|
4714
|
+
const scenario = scenarioMap.get(result.scenarioId);
|
|
4715
|
+
return {
|
|
4716
|
+
level: "error",
|
|
4717
|
+
source: "sdk",
|
|
4718
|
+
service: "testers",
|
|
4719
|
+
message: `[testers] Scenario failed: ${scenario?.name ?? result.scenarioId}${result.error ? ` \u2014 ${result.error}` : ""}`,
|
|
4720
|
+
metadata: {
|
|
4721
|
+
run_id: run.id,
|
|
4722
|
+
scenario_id: result.scenarioId,
|
|
4723
|
+
scenario_name: scenario?.name,
|
|
4724
|
+
url: run.url,
|
|
4725
|
+
status: result.status,
|
|
4726
|
+
duration_ms: result.durationMs
|
|
4727
|
+
}
|
|
4728
|
+
};
|
|
4729
|
+
});
|
|
4730
|
+
try {
|
|
4731
|
+
await fetch(`${logsUrl.replace(/\/$/, "")}/api/logs`, {
|
|
4732
|
+
method: "POST",
|
|
4733
|
+
headers: { "Content-Type": "application/json" },
|
|
4734
|
+
body: JSON.stringify(entries)
|
|
4735
|
+
});
|
|
4736
|
+
} catch {}
|
|
4737
|
+
}
|
|
4738
|
+
|
|
4598
4739
|
// src/lib/runner.ts
|
|
4599
4740
|
var eventHandler = null;
|
|
4600
4741
|
function onRunEvent(handler) {
|
|
@@ -4606,14 +4747,26 @@ function emit(event) {
|
|
|
4606
4747
|
}
|
|
4607
4748
|
function withTimeout(promise, ms, label) {
|
|
4608
4749
|
return new Promise((resolve, reject) => {
|
|
4750
|
+
const warningAt = Math.floor(ms * 0.8);
|
|
4751
|
+
const warningTimer = setTimeout(() => {
|
|
4752
|
+
emit({
|
|
4753
|
+
type: "scenario:timeout_warning",
|
|
4754
|
+
scenarioName: label,
|
|
4755
|
+
timeoutMs: ms,
|
|
4756
|
+
elapsedMs: warningAt
|
|
4757
|
+
});
|
|
4758
|
+
}, warningAt);
|
|
4609
4759
|
const timer = setTimeout(() => {
|
|
4610
|
-
|
|
4760
|
+
clearTimeout(warningTimer);
|
|
4761
|
+
reject(new Error(`Scenario '${label}' timed out after ${ms}ms. Try: testers run --timeout ${ms * 2} or simplify the scenario steps.`));
|
|
4611
4762
|
}, ms);
|
|
4612
4763
|
promise.then((val) => {
|
|
4613
4764
|
clearTimeout(timer);
|
|
4765
|
+
clearTimeout(warningTimer);
|
|
4614
4766
|
resolve(val);
|
|
4615
4767
|
}, (err) => {
|
|
4616
4768
|
clearTimeout(timer);
|
|
4769
|
+
clearTimeout(warningTimer);
|
|
4617
4770
|
reject(err);
|
|
4618
4771
|
});
|
|
4619
4772
|
});
|
|
@@ -4642,6 +4795,7 @@ async function runSingleScenario(scenario, runId, options) {
|
|
|
4642
4795
|
const targetUrl = scenario.targetPath ? `${options.url.replace(/\/$/, "")}${scenario.targetPath}` : options.url;
|
|
4643
4796
|
const scenarioTimeout = scenario.timeoutMs ?? options.timeout ?? config.browser.timeout ?? 60000;
|
|
4644
4797
|
await page.goto(targetUrl, { timeout: Math.min(scenarioTimeout, 30000) });
|
|
4798
|
+
const stepStartTimes = new Map;
|
|
4645
4799
|
const agentResult = await withTimeout(runAgentLoop({
|
|
4646
4800
|
client,
|
|
4647
4801
|
page,
|
|
@@ -4651,6 +4805,16 @@ async function runSingleScenario(scenario, runId, options) {
|
|
|
4651
4805
|
runId,
|
|
4652
4806
|
maxTurns: 30,
|
|
4653
4807
|
onStep: (stepEvent) => {
|
|
4808
|
+
let stepDurationMs;
|
|
4809
|
+
if (stepEvent.type === "tool_call") {
|
|
4810
|
+
stepStartTimes.set(stepEvent.stepNumber, Date.now());
|
|
4811
|
+
} else if (stepEvent.type === "tool_result") {
|
|
4812
|
+
const startTime = stepStartTimes.get(stepEvent.stepNumber);
|
|
4813
|
+
if (startTime !== undefined) {
|
|
4814
|
+
stepDurationMs = Date.now() - startTime;
|
|
4815
|
+
stepStartTimes.delete(stepEvent.stepNumber);
|
|
4816
|
+
}
|
|
4817
|
+
}
|
|
4654
4818
|
emit({
|
|
4655
4819
|
type: `step:${stepEvent.type}`,
|
|
4656
4820
|
scenarioId: scenario.id,
|
|
@@ -4660,7 +4824,8 @@ async function runSingleScenario(scenario, runId, options) {
|
|
|
4660
4824
|
toolInput: stepEvent.toolInput,
|
|
4661
4825
|
toolResult: stepEvent.toolResult,
|
|
4662
4826
|
thinking: stepEvent.thinking,
|
|
4663
|
-
stepNumber: stepEvent.stepNumber
|
|
4827
|
+
stepNumber: stepEvent.stepNumber,
|
|
4828
|
+
stepDurationMs
|
|
4664
4829
|
});
|
|
4665
4830
|
}
|
|
4666
4831
|
}), scenarioTimeout, scenario.name);
|
|
@@ -4740,6 +4905,7 @@ async function runBatch(scenarios, options) {
|
|
|
4740
4905
|
} catch {}
|
|
4741
4906
|
return true;
|
|
4742
4907
|
};
|
|
4908
|
+
const maxRetries = options.retry ?? 0;
|
|
4743
4909
|
if (parallel <= 1) {
|
|
4744
4910
|
for (const scenario of sortedScenarios) {
|
|
4745
4911
|
if (!await canRun(scenario)) {
|
|
@@ -4750,7 +4916,13 @@ async function runBatch(scenarios, options) {
|
|
|
4750
4916
|
emit({ type: "scenario:error", scenarioId: scenario.id, scenarioName: scenario.name, error: "Dependency failed \u2014 skipped", runId: run.id });
|
|
4751
4917
|
continue;
|
|
4752
4918
|
}
|
|
4753
|
-
|
|
4919
|
+
let result = await runSingleScenario(scenario, run.id, options);
|
|
4920
|
+
let attempt = 1;
|
|
4921
|
+
while ((result.status === "failed" || result.status === "error") && attempt <= maxRetries) {
|
|
4922
|
+
emit({ type: "scenario:start", scenarioId: scenario.id, scenarioName: scenario.name, runId: run.id, retryAttempt: attempt + 1, maxRetries: maxRetries + 1 });
|
|
4923
|
+
result = await runSingleScenario(scenario, run.id, options);
|
|
4924
|
+
attempt++;
|
|
4925
|
+
}
|
|
4754
4926
|
results.push(result);
|
|
4755
4927
|
if (result.status === "failed" || result.status === "error") {
|
|
4756
4928
|
failedScenarioIds.add(scenario.id);
|
|
@@ -4797,6 +4969,10 @@ async function runBatch(scenarios, options) {
|
|
|
4797
4969
|
emit({ type: "run:complete", runId: run.id });
|
|
4798
4970
|
const eventType = finalRun.status === "failed" ? "failed" : "completed";
|
|
4799
4971
|
dispatchWebhooks(eventType, finalRun).catch(() => {});
|
|
4972
|
+
if (finalRun.status === "failed") {
|
|
4973
|
+
const failedResults = results.filter((r) => r.status === "failed" || r.status === "error");
|
|
4974
|
+
pushFailedRunToLogs(finalRun, failedResults, scenarios).catch(() => {});
|
|
4975
|
+
}
|
|
4800
4976
|
return { run: finalRun, results };
|
|
4801
4977
|
}
|
|
4802
4978
|
async function runByFilter(options) {
|
|
@@ -4912,13 +5088,28 @@ function estimateCost(model, tokens) {
|
|
|
4912
5088
|
// src/lib/reporter.ts
|
|
4913
5089
|
import chalk from "chalk";
|
|
4914
5090
|
init_scenarios();
|
|
4915
|
-
|
|
5091
|
+
init_database();
|
|
5092
|
+
function useEmoji() {
|
|
5093
|
+
return !process.env["NO_COLOR"] && process.argv.indexOf("--no-color") === -1;
|
|
5094
|
+
}
|
|
5095
|
+
function formatTerminal(run, results, options) {
|
|
4916
5096
|
const lines = [];
|
|
5097
|
+
const failedOnly = options?.failedOnly ?? false;
|
|
4917
5098
|
lines.push("");
|
|
4918
5099
|
lines.push(chalk.bold(` Run ${run.id.slice(0, 8)} \u2014 ${run.url}`));
|
|
4919
5100
|
lines.push(chalk.dim(` Model: ${run.model} | Parallel: ${run.parallel} | Headed: ${run.headed ? "yes" : "no"}`));
|
|
4920
5101
|
lines.push("");
|
|
5102
|
+
if (failedOnly) {
|
|
5103
|
+
const passedCount = results.filter((r) => r.status === "passed").length;
|
|
5104
|
+
if (passedCount > 0) {
|
|
5105
|
+
lines.push(chalk.dim(` (${passedCount} passed scenario${passedCount !== 1 ? "s" : ""} hidden \u2014 use without --failed-only to see all)`));
|
|
5106
|
+
lines.push("");
|
|
5107
|
+
}
|
|
5108
|
+
}
|
|
4921
5109
|
for (const result of results) {
|
|
5110
|
+
if (failedOnly && result.status !== "failed" && result.status !== "error") {
|
|
5111
|
+
continue;
|
|
5112
|
+
}
|
|
4922
5113
|
const scenario = getScenario(result.scenarioId);
|
|
4923
5114
|
const name = scenario ? `${scenario.shortId}: ${scenario.name}` : result.scenarioId.slice(0, 8);
|
|
4924
5115
|
const screenshots = listScreenshots(result.id);
|
|
@@ -4926,21 +5117,22 @@ function formatTerminal(run, results) {
|
|
|
4926
5117
|
const screenshotCount = screenshots.length;
|
|
4927
5118
|
let statusIcon;
|
|
4928
5119
|
let statusColor;
|
|
5120
|
+
const emoji = useEmoji();
|
|
4929
5121
|
switch (result.status) {
|
|
4930
5122
|
case "passed":
|
|
4931
|
-
statusIcon = chalk.green("PASS");
|
|
5123
|
+
statusIcon = emoji ? "\u2705" : chalk.green("PASS");
|
|
4932
5124
|
statusColor = chalk.green;
|
|
4933
5125
|
break;
|
|
4934
5126
|
case "failed":
|
|
4935
|
-
statusIcon = chalk.red("FAIL");
|
|
5127
|
+
statusIcon = emoji ? "\u274C" : chalk.red("FAIL");
|
|
4936
5128
|
statusColor = chalk.red;
|
|
4937
5129
|
break;
|
|
4938
5130
|
case "error":
|
|
4939
|
-
statusIcon = chalk.yellow("ERR ");
|
|
5131
|
+
statusIcon = emoji ? "\u26A0\uFE0F " : chalk.yellow("ERR ");
|
|
4940
5132
|
statusColor = chalk.yellow;
|
|
4941
5133
|
break;
|
|
4942
5134
|
default:
|
|
4943
|
-
statusIcon = chalk.dim("SKIP");
|
|
5135
|
+
statusIcon = emoji ? "\u23ED\uFE0F " : chalk.dim("SKIP");
|
|
4944
5136
|
statusColor = chalk.dim;
|
|
4945
5137
|
break;
|
|
4946
5138
|
}
|
|
@@ -4953,17 +5145,34 @@ function formatTerminal(run, results) {
|
|
|
4953
5145
|
}
|
|
4954
5146
|
}
|
|
4955
5147
|
lines.push("");
|
|
4956
|
-
lines.push(
|
|
5148
|
+
lines.push(formatActionableSummary(run, results));
|
|
4957
5149
|
lines.push("");
|
|
4958
5150
|
return lines.join(`
|
|
4959
5151
|
`);
|
|
4960
5152
|
}
|
|
4961
|
-
function
|
|
4962
|
-
const
|
|
4963
|
-
const
|
|
4964
|
-
const
|
|
4965
|
-
const
|
|
4966
|
-
|
|
5153
|
+
function formatActionableSummary(run, results) {
|
|
5154
|
+
const emoji = useEmoji();
|
|
5155
|
+
const passedCount = results.filter((r) => r.status === "passed").length;
|
|
5156
|
+
const failedCount = results.filter((r) => r.status === "failed" || r.status === "error").length;
|
|
5157
|
+
const shortId = run.id.slice(0, 8);
|
|
5158
|
+
const passStr = `${emoji ? "\u2705" : "PASS"} ${passedCount} passed`;
|
|
5159
|
+
const failStr = failedCount > 0 ? ` ${emoji ? "\u274C" : "FAIL"} ${failedCount} failed` : "";
|
|
5160
|
+
const lines = [];
|
|
5161
|
+
lines.push(` ${chalk.bold(passStr)}${failedCount > 0 ? chalk.bold(failStr) : ""}`);
|
|
5162
|
+
if (failedCount > 0) {
|
|
5163
|
+
lines.push(chalk.dim(` retry failed: testers retry ${shortId} | view: testers results ${shortId}`));
|
|
5164
|
+
} else {
|
|
5165
|
+
lines.push(chalk.dim(` view: testers results ${shortId}`));
|
|
5166
|
+
}
|
|
5167
|
+
const totalCostCents = results.reduce((sum, r) => sum + (r.costCents ?? 0), 0);
|
|
5168
|
+
const totalTokens = results.reduce((sum, r) => sum + (r.tokensUsed ?? 0), 0);
|
|
5169
|
+
if (totalTokens > 0) {
|
|
5170
|
+
const costStr = `$${(totalCostCents / 100).toFixed(4)}`;
|
|
5171
|
+
const tokensStr = totalTokens.toLocaleString();
|
|
5172
|
+
lines.push(chalk.dim(` ${emoji ? "\uD83D\uDCB0" : "cost:"} Cost: ${costStr} (${tokensStr} tokens)`));
|
|
5173
|
+
}
|
|
5174
|
+
return lines.join(`
|
|
5175
|
+
`);
|
|
4967
5176
|
}
|
|
4968
5177
|
function formatJSON(run, results) {
|
|
4969
5178
|
const output = {
|
|
@@ -5043,6 +5252,15 @@ function formatRunList(runs) {
|
|
|
5043
5252
|
return lines.join(`
|
|
5044
5253
|
`);
|
|
5045
5254
|
}
|
|
5255
|
+
function getScenarioRunStats(scenarioId) {
|
|
5256
|
+
const db2 = getDatabase();
|
|
5257
|
+
const lastRow = db2.query("SELECT status FROM results WHERE scenario_id = ? ORDER BY created_at DESC LIMIT 1").get(scenarioId);
|
|
5258
|
+
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);
|
|
5259
|
+
return {
|
|
5260
|
+
lastStatus: lastRow ? lastRow.status : null,
|
|
5261
|
+
passRate: statsRow && statsRow.total > 0 ? `${statsRow.passed}/${statsRow.total}` : "\u2014"
|
|
5262
|
+
};
|
|
5263
|
+
}
|
|
5046
5264
|
function formatScenarioList(scenarios) {
|
|
5047
5265
|
const lines = [];
|
|
5048
5266
|
lines.push("");
|
|
@@ -5057,7 +5275,21 @@ function formatScenarioList(scenarios) {
|
|
|
5057
5275
|
for (const s of scenarios) {
|
|
5058
5276
|
const priorityColor = s.priority === "critical" ? chalk.red : s.priority === "high" ? chalk.yellow : s.priority === "medium" ? chalk.blue : chalk.dim;
|
|
5059
5277
|
const tags = s.tags.length > 0 ? chalk.dim(` [${s.tags.join(", ")}]`) : "";
|
|
5060
|
-
|
|
5278
|
+
let lastStatusIcon = chalk.dim("\u2014");
|
|
5279
|
+
let passRateStr = chalk.dim("\u2014");
|
|
5280
|
+
if (s.id) {
|
|
5281
|
+
const stats = getScenarioRunStats(s.id);
|
|
5282
|
+
if (stats.lastStatus === "passed")
|
|
5283
|
+
lastStatusIcon = chalk.green("\u2713");
|
|
5284
|
+
else if (stats.lastStatus === "failed")
|
|
5285
|
+
lastStatusIcon = chalk.red("\u2717");
|
|
5286
|
+
else if (stats.lastStatus === "error")
|
|
5287
|
+
lastStatusIcon = chalk.yellow("!");
|
|
5288
|
+
else if (stats.lastStatus === "skipped")
|
|
5289
|
+
lastStatusIcon = chalk.dim("~");
|
|
5290
|
+
passRateStr = stats.passRate === "\u2014" ? chalk.dim("\u2014") : chalk.dim(stats.passRate);
|
|
5291
|
+
}
|
|
5292
|
+
lines.push(` ${chalk.cyan(s.shortId)} ${s.name} ${priorityColor(s.priority)}${tags} ${lastStatusIcon} ${passRateStr}`);
|
|
5061
5293
|
}
|
|
5062
5294
|
lines.push("");
|
|
5063
5295
|
return lines.join(`
|
|
@@ -5251,26 +5483,146 @@ function detectFramework(dir) {
|
|
|
5251
5483
|
return null;
|
|
5252
5484
|
}
|
|
5253
5485
|
function getStarterScenarios(framework, projectId) {
|
|
5486
|
+
if (framework.name === "Next.js") {
|
|
5487
|
+
const scenarios2 = [
|
|
5488
|
+
{
|
|
5489
|
+
name: "Homepage loads",
|
|
5490
|
+
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.",
|
|
5491
|
+
tags: ["smoke"],
|
|
5492
|
+
priority: "high",
|
|
5493
|
+
projectId
|
|
5494
|
+
},
|
|
5495
|
+
{
|
|
5496
|
+
name: "404 page works",
|
|
5497
|
+
description: "Navigate to a non-existent URL (e.g. /this-page-does-not-exist) and verify the Next.js 404 page renders correctly.",
|
|
5498
|
+
tags: ["smoke"],
|
|
5499
|
+
priority: "medium",
|
|
5500
|
+
projectId
|
|
5501
|
+
},
|
|
5502
|
+
{
|
|
5503
|
+
name: "Navigation links work",
|
|
5504
|
+
description: "Click through the main navigation links and verify each page loads without errors. Check that client-side routing is working correctly.",
|
|
5505
|
+
tags: ["smoke"],
|
|
5506
|
+
priority: "medium",
|
|
5507
|
+
projectId
|
|
5508
|
+
}
|
|
5509
|
+
];
|
|
5510
|
+
if (framework.features.includes("hasAuth")) {
|
|
5511
|
+
scenarios2.push({
|
|
5512
|
+
name: "Login flow",
|
|
5513
|
+
description: "Navigate to the login page, enter valid credentials, and verify successful authentication and redirect.",
|
|
5514
|
+
tags: ["auth"],
|
|
5515
|
+
priority: "critical",
|
|
5516
|
+
projectId
|
|
5517
|
+
}, {
|
|
5518
|
+
name: "Protected route redirect",
|
|
5519
|
+
description: "Try to access a protected route without authentication and verify you are redirected to the login page.",
|
|
5520
|
+
tags: ["auth"],
|
|
5521
|
+
priority: "high",
|
|
5522
|
+
projectId
|
|
5523
|
+
});
|
|
5524
|
+
}
|
|
5525
|
+
if (framework.features.includes("hasForms")) {
|
|
5526
|
+
scenarios2.push({
|
|
5527
|
+
name: "Form validation",
|
|
5528
|
+
description: "Submit forms with empty/invalid data and verify validation errors appear correctly.",
|
|
5529
|
+
tags: ["forms"],
|
|
5530
|
+
priority: "medium",
|
|
5531
|
+
projectId
|
|
5532
|
+
});
|
|
5533
|
+
}
|
|
5534
|
+
return scenarios2;
|
|
5535
|
+
}
|
|
5536
|
+
if (framework.name === "Vite" || framework.name === "SvelteKit") {
|
|
5537
|
+
const scenarios2 = [
|
|
5538
|
+
{
|
|
5539
|
+
name: "Homepage loads",
|
|
5540
|
+
description: "Navigate to the homepage and verify it loads correctly with no console errors.",
|
|
5541
|
+
tags: ["smoke"],
|
|
5542
|
+
priority: "high",
|
|
5543
|
+
projectId
|
|
5544
|
+
},
|
|
5545
|
+
{
|
|
5546
|
+
name: "Mobile viewport check",
|
|
5547
|
+
description: "Set the viewport to 375x812 (iPhone) and verify the homepage renders correctly without horizontal scrolling or layout issues.",
|
|
5548
|
+
tags: ["responsive"],
|
|
5549
|
+
priority: "medium",
|
|
5550
|
+
projectId
|
|
5551
|
+
},
|
|
5552
|
+
{
|
|
5553
|
+
name: "No console errors",
|
|
5554
|
+
description: "Navigate through the app and verify there are no JavaScript errors or warnings in the browser console.",
|
|
5555
|
+
tags: ["smoke"],
|
|
5556
|
+
priority: "high",
|
|
5557
|
+
projectId
|
|
5558
|
+
}
|
|
5559
|
+
];
|
|
5560
|
+
if (framework.features.includes("hasAuth")) {
|
|
5561
|
+
scenarios2.push({
|
|
5562
|
+
name: "Login flow",
|
|
5563
|
+
description: "Navigate to the login page, enter valid credentials, and verify successful authentication.",
|
|
5564
|
+
tags: ["auth"],
|
|
5565
|
+
priority: "critical",
|
|
5566
|
+
projectId
|
|
5567
|
+
});
|
|
5568
|
+
}
|
|
5569
|
+
return scenarios2;
|
|
5570
|
+
}
|
|
5571
|
+
if (framework.name === "Nuxt") {
|
|
5572
|
+
const scenarios2 = [
|
|
5573
|
+
{
|
|
5574
|
+
name: "Homepage loads",
|
|
5575
|
+
description: "Navigate to the homepage and verify it loads correctly. Check that the main heading and content are visible.",
|
|
5576
|
+
tags: ["smoke"],
|
|
5577
|
+
priority: "high",
|
|
5578
|
+
projectId
|
|
5579
|
+
},
|
|
5580
|
+
{
|
|
5581
|
+
name: "Navigation works",
|
|
5582
|
+
description: "Click through main navigation links and verify each page loads without errors.",
|
|
5583
|
+
tags: ["smoke"],
|
|
5584
|
+
priority: "medium",
|
|
5585
|
+
projectId
|
|
5586
|
+
},
|
|
5587
|
+
{
|
|
5588
|
+
name: "Mobile viewport check",
|
|
5589
|
+
description: "Set the viewport to 375x812 and verify the homepage renders correctly on mobile.",
|
|
5590
|
+
tags: ["responsive"],
|
|
5591
|
+
priority: "medium",
|
|
5592
|
+
projectId
|
|
5593
|
+
}
|
|
5594
|
+
];
|
|
5595
|
+
if (framework.features.includes("hasAuth")) {
|
|
5596
|
+
scenarios2.push({
|
|
5597
|
+
name: "Login flow",
|
|
5598
|
+
description: "Navigate to the login page, enter valid credentials, and verify successful authentication.",
|
|
5599
|
+
tags: ["auth"],
|
|
5600
|
+
priority: "critical",
|
|
5601
|
+
projectId
|
|
5602
|
+
});
|
|
5603
|
+
}
|
|
5604
|
+
return scenarios2;
|
|
5605
|
+
}
|
|
5254
5606
|
const scenarios = [
|
|
5255
5607
|
{
|
|
5256
|
-
name: "
|
|
5257
|
-
description: "Navigate to the
|
|
5608
|
+
name: "Homepage loads",
|
|
5609
|
+
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.",
|
|
5258
5610
|
tags: ["smoke"],
|
|
5259
5611
|
priority: "high",
|
|
5260
5612
|
projectId
|
|
5261
5613
|
},
|
|
5262
5614
|
{
|
|
5263
|
-
name: "
|
|
5264
|
-
description: "
|
|
5615
|
+
name: "Form submit works",
|
|
5616
|
+
description: "Find the main form on the page, fill it in with valid test data, submit it, and verify the success state.",
|
|
5265
5617
|
tags: ["smoke"],
|
|
5266
5618
|
priority: "medium",
|
|
5267
5619
|
projectId
|
|
5268
5620
|
},
|
|
5269
5621
|
{
|
|
5270
|
-
name: "
|
|
5271
|
-
description: "
|
|
5272
|
-
tags: ["
|
|
5273
|
-
priority: "
|
|
5622
|
+
name: "Mobile viewport check",
|
|
5623
|
+
description: "Set the viewport to 375x812 (iPhone) and verify the homepage renders correctly without horizontal scrolling or layout issues.",
|
|
5624
|
+
tags: ["responsive"],
|
|
5625
|
+
priority: "medium",
|
|
5274
5626
|
projectId
|
|
5275
5627
|
}
|
|
5276
5628
|
];
|
|
@@ -6051,6 +6403,15 @@ function getPeriodDays(period) {
|
|
|
6051
6403
|
return 30;
|
|
6052
6404
|
}
|
|
6053
6405
|
}
|
|
6406
|
+
function loadBudgetConfig() {
|
|
6407
|
+
const config = loadConfig();
|
|
6408
|
+
const budget = config.budget;
|
|
6409
|
+
return {
|
|
6410
|
+
maxPerRunCents: budget?.maxPerRunCents ?? 50,
|
|
6411
|
+
maxPerDayCents: budget?.maxPerDayCents ?? 500,
|
|
6412
|
+
warnAtPercent: budget?.warnAtPercent ?? 0.8
|
|
6413
|
+
};
|
|
6414
|
+
}
|
|
6054
6415
|
function getCostSummary(options) {
|
|
6055
6416
|
const db2 = getDatabase();
|
|
6056
6417
|
const period = options?.period ?? "month";
|
|
@@ -6118,6 +6479,77 @@ function getCostSummary(options) {
|
|
|
6118
6479
|
estimatedMonthlyCents
|
|
6119
6480
|
};
|
|
6120
6481
|
}
|
|
6482
|
+
function getCostsByScenario(options) {
|
|
6483
|
+
const db2 = getDatabase();
|
|
6484
|
+
const period = options?.period ?? "month";
|
|
6485
|
+
const projectId = options?.projectId;
|
|
6486
|
+
const dateFilter = getDateFilter(period);
|
|
6487
|
+
const projectFilter = projectId ? "AND ru.project_id = ?" : "";
|
|
6488
|
+
const projectParams = projectId ? [projectId] : [];
|
|
6489
|
+
const rows = db2.query(`SELECT
|
|
6490
|
+
r.scenario_id,
|
|
6491
|
+
COALESCE(s.name, r.scenario_id) as name,
|
|
6492
|
+
COUNT(DISTINCT r.run_id) as run_count,
|
|
6493
|
+
COALESCE(SUM(r.cost_cents), 0) as total_cost_cents
|
|
6494
|
+
FROM results r
|
|
6495
|
+
JOIN runs ru ON r.run_id = ru.id
|
|
6496
|
+
LEFT JOIN scenarios s ON r.scenario_id = s.id
|
|
6497
|
+
WHERE 1=1 ${dateFilter} ${projectFilter}
|
|
6498
|
+
GROUP BY r.scenario_id
|
|
6499
|
+
ORDER BY total_cost_cents DESC`).all(...projectParams);
|
|
6500
|
+
return rows.map((row) => ({
|
|
6501
|
+
scenarioId: row.scenario_id,
|
|
6502
|
+
name: row.name,
|
|
6503
|
+
runCount: row.run_count,
|
|
6504
|
+
totalCostCents: row.total_cost_cents,
|
|
6505
|
+
avgCostPerRunCents: row.run_count > 0 ? row.total_cost_cents / row.run_count : 0
|
|
6506
|
+
}));
|
|
6507
|
+
}
|
|
6508
|
+
function formatCostsByScenarioTerminal(rows, period) {
|
|
6509
|
+
const lines = [];
|
|
6510
|
+
lines.push("");
|
|
6511
|
+
lines.push(chalk4.bold(` Cost by Scenario (${period})`));
|
|
6512
|
+
lines.push("");
|
|
6513
|
+
if (rows.length === 0) {
|
|
6514
|
+
lines.push(chalk4.dim(" No cost data found."));
|
|
6515
|
+
lines.push("");
|
|
6516
|
+
return lines.join(`
|
|
6517
|
+
`);
|
|
6518
|
+
}
|
|
6519
|
+
lines.push(` ${"Scenario".padEnd(40)} ${"Runs".padEnd(8)} ${"Total Cost".padEnd(14)} Avg/Run`);
|
|
6520
|
+
lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(8)} ${"\u2500".repeat(14)} ${"\u2500".repeat(10)}`);
|
|
6521
|
+
for (const row of rows) {
|
|
6522
|
+
const label = row.name.length > 38 ? row.name.slice(0, 35) + "..." : row.name;
|
|
6523
|
+
lines.push(` ${label.padEnd(40)} ${String(row.runCount).padEnd(8)} ${formatDollars(row.totalCostCents).padEnd(14)} ${formatDollars(row.avgCostPerRunCents)}`);
|
|
6524
|
+
}
|
|
6525
|
+
lines.push("");
|
|
6526
|
+
return lines.join(`
|
|
6527
|
+
`);
|
|
6528
|
+
}
|
|
6529
|
+
function checkBudget(estimatedCostCents) {
|
|
6530
|
+
const budget = loadBudgetConfig();
|
|
6531
|
+
if (estimatedCostCents > budget.maxPerRunCents) {
|
|
6532
|
+
return {
|
|
6533
|
+
allowed: false,
|
|
6534
|
+
warning: `Estimated cost (${formatDollars(estimatedCostCents)}) exceeds per-run limit (${formatDollars(budget.maxPerRunCents)})`
|
|
6535
|
+
};
|
|
6536
|
+
}
|
|
6537
|
+
const todaySummary = getCostSummary({ period: "day" });
|
|
6538
|
+
const projectedDaily = todaySummary.totalCostCents + estimatedCostCents;
|
|
6539
|
+
if (projectedDaily > budget.maxPerDayCents) {
|
|
6540
|
+
return {
|
|
6541
|
+
allowed: false,
|
|
6542
|
+
warning: `Daily spending (${formatDollars(todaySummary.totalCostCents)}) + this run (${formatDollars(estimatedCostCents)}) would exceed daily limit (${formatDollars(budget.maxPerDayCents)})`
|
|
6543
|
+
};
|
|
6544
|
+
}
|
|
6545
|
+
if (projectedDaily > budget.maxPerDayCents * budget.warnAtPercent) {
|
|
6546
|
+
return {
|
|
6547
|
+
allowed: true,
|
|
6548
|
+
warning: `Approaching daily limit: ${formatDollars(projectedDaily)} of ${formatDollars(budget.maxPerDayCents)} (${Math.round(projectedDaily / budget.maxPerDayCents * 100)}%)`
|
|
6549
|
+
};
|
|
6550
|
+
}
|
|
6551
|
+
return { allowed: true };
|
|
6552
|
+
}
|
|
6121
6553
|
function formatDollars(cents) {
|
|
6122
6554
|
return `$${(cents / 100).toFixed(2)}`;
|
|
6123
6555
|
}
|
|
@@ -6148,12 +6580,13 @@ function formatCostsTerminal(summary) {
|
|
|
6148
6580
|
}
|
|
6149
6581
|
if (summary.byScenario.length > 0) {
|
|
6150
6582
|
lines.push("");
|
|
6151
|
-
lines.push(chalk4.bold("
|
|
6152
|
-
lines.push(` ${"Scenario".padEnd(40)} ${"Cost".padEnd(12)} ${"
|
|
6153
|
-
lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(12)} ${"\u2500".repeat(12)} ${"\u2500".repeat(6)}`);
|
|
6583
|
+
lines.push(chalk4.bold(" Scenarios by Cost (most expensive first)"));
|
|
6584
|
+
lines.push(` ${"Scenario".padEnd(40)} ${"Total Cost".padEnd(12)} ${"Avg/Run".padEnd(12)} ${"Runs".padEnd(6)} Tokens`);
|
|
6585
|
+
lines.push(` ${"\u2500".repeat(40)} ${"\u2500".repeat(12)} ${"\u2500".repeat(12)} ${"\u2500".repeat(6)} ${"\u2500".repeat(10)}`);
|
|
6154
6586
|
for (const s of summary.byScenario) {
|
|
6155
6587
|
const label = s.name.length > 38 ? s.name.slice(0, 35) + "..." : s.name;
|
|
6156
|
-
|
|
6588
|
+
const avgPerRun = s.runs > 0 ? s.costCents / s.runs : 0;
|
|
6589
|
+
lines.push(` ${label.padEnd(40)} ${formatDollars(s.costCents).padEnd(12)} ${formatDollars(avgPerRun).padEnd(12)} ${String(s.runs).padEnd(6)} ${formatTokens(s.tokens)}`);
|
|
6157
6590
|
}
|
|
6158
6591
|
}
|
|
6159
6592
|
lines.push("");
|
|
@@ -6163,6 +6596,17 @@ function formatCostsTerminal(summary) {
|
|
|
6163
6596
|
function formatCostsJSON(summary) {
|
|
6164
6597
|
return JSON.stringify(summary, null, 2);
|
|
6165
6598
|
}
|
|
6599
|
+
function formatCostsCsv(summary) {
|
|
6600
|
+
const lines = [];
|
|
6601
|
+
lines.push("scenario,runs,total_cost_cents,avg_cost_cents,tokens");
|
|
6602
|
+
for (const s of summary.byScenario) {
|
|
6603
|
+
const avgCostCents = s.runs > 0 ? s.costCents / s.runs : 0;
|
|
6604
|
+
const name = s.name.includes(",") ? `"${s.name.replace(/"/g, '""')}"` : s.name;
|
|
6605
|
+
lines.push(`${name},${s.runs},${s.costCents},${avgCostCents.toFixed(2)},${s.tokens}`);
|
|
6606
|
+
}
|
|
6607
|
+
return lines.join(`
|
|
6608
|
+
`);
|
|
6609
|
+
}
|
|
6166
6610
|
|
|
6167
6611
|
// src/db/schedules.ts
|
|
6168
6612
|
init_database();
|
|
@@ -6505,6 +6949,241 @@ function parseAssertionString(str) {
|
|
|
6505
6949
|
|
|
6506
6950
|
// src/cli/index.tsx
|
|
6507
6951
|
import { existsSync as existsSync8, mkdirSync as mkdirSync4 } from "fs";
|
|
6952
|
+
import { jsxDEV } from "react/jsx-dev-runtime";
|
|
6953
|
+
var PRIORITIES = ["low", "medium", "high", "critical"];
|
|
6954
|
+
function AddForm({ onComplete }) {
|
|
6955
|
+
const { exit } = useApp();
|
|
6956
|
+
const [state, setState] = useState({
|
|
6957
|
+
name: "",
|
|
6958
|
+
url: "",
|
|
6959
|
+
description: "",
|
|
6960
|
+
priority: "medium",
|
|
6961
|
+
tags: "",
|
|
6962
|
+
field: "name",
|
|
6963
|
+
buffer: ""
|
|
6964
|
+
});
|
|
6965
|
+
useInput((input, key) => {
|
|
6966
|
+
if (key.escape) {
|
|
6967
|
+
onComplete(null);
|
|
6968
|
+
exit();
|
|
6969
|
+
return;
|
|
6970
|
+
}
|
|
6971
|
+
if (key.return) {
|
|
6972
|
+
if (state.field === "name") {
|
|
6973
|
+
const val = state.buffer.trim();
|
|
6974
|
+
if (!val)
|
|
6975
|
+
return;
|
|
6976
|
+
setState((s) => ({ ...s, name: val, buffer: "", field: "url" }));
|
|
6977
|
+
} else if (state.field === "url") {
|
|
6978
|
+
setState((s) => ({ ...s, url: s.buffer.trim(), buffer: "", field: "description" }));
|
|
6979
|
+
} else if (state.field === "description") {
|
|
6980
|
+
setState((s) => ({ ...s, description: s.buffer.trim(), buffer: "", field: "priority" }));
|
|
6981
|
+
} else if (state.field === "priority") {
|
|
6982
|
+
setState((s) => ({ ...s, buffer: "", field: "tags" }));
|
|
6983
|
+
} else if (state.field === "tags") {
|
|
6984
|
+
setState((s) => ({ ...s, tags: s.buffer.trim(), buffer: "", field: "confirm" }));
|
|
6985
|
+
} else if (state.field === "confirm") {
|
|
6986
|
+
onComplete(state);
|
|
6987
|
+
exit();
|
|
6988
|
+
}
|
|
6989
|
+
return;
|
|
6990
|
+
}
|
|
6991
|
+
if (key.backspace || key.delete) {
|
|
6992
|
+
if (state.field === "priority")
|
|
6993
|
+
return;
|
|
6994
|
+
setState((s) => ({ ...s, buffer: s.buffer.slice(0, -1) }));
|
|
6995
|
+
return;
|
|
6996
|
+
}
|
|
6997
|
+
if (state.field === "priority") {
|
|
6998
|
+
if (key.leftArrow || key.rightArrow) {
|
|
6999
|
+
const idx = PRIORITIES.indexOf(state.priority);
|
|
7000
|
+
const next = key.rightArrow ? PRIORITIES[(idx + 1) % PRIORITIES.length] : PRIORITIES[(idx - 1 + PRIORITIES.length) % PRIORITIES.length];
|
|
7001
|
+
setState((s) => ({ ...s, priority: next }));
|
|
7002
|
+
}
|
|
7003
|
+
return;
|
|
7004
|
+
}
|
|
7005
|
+
if (!key.ctrl && !key.meta && input) {
|
|
7006
|
+
setState((s) => ({ ...s, buffer: s.buffer + input }));
|
|
7007
|
+
}
|
|
7008
|
+
});
|
|
7009
|
+
const fieldLabel = (label, field, value, hint) => {
|
|
7010
|
+
const active = state.field === field;
|
|
7011
|
+
const displayValue = active ? state.buffer + (active ? "\u2588" : "") : value;
|
|
7012
|
+
return /* @__PURE__ */ jsxDEV(Box, {
|
|
7013
|
+
flexDirection: "row",
|
|
7014
|
+
gap: 1,
|
|
7015
|
+
children: [
|
|
7016
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7017
|
+
color: active ? "cyan" : "gray",
|
|
7018
|
+
children: active ? "\u2192" : " "
|
|
7019
|
+
}, undefined, false, undefined, this),
|
|
7020
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7021
|
+
color: active ? "white" : "gray",
|
|
7022
|
+
bold: active,
|
|
7023
|
+
children: [
|
|
7024
|
+
label,
|
|
7025
|
+
":"
|
|
7026
|
+
]
|
|
7027
|
+
}, undefined, true, undefined, this),
|
|
7028
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7029
|
+
color: active ? "white" : "gray",
|
|
7030
|
+
children: displayValue || (hint ? hint : "")
|
|
7031
|
+
}, undefined, false, undefined, this),
|
|
7032
|
+
!displayValue && hint && /* @__PURE__ */ jsxDEV(Text, {
|
|
7033
|
+
color: "gray",
|
|
7034
|
+
children: " "
|
|
7035
|
+
}, undefined, false, undefined, this)
|
|
7036
|
+
]
|
|
7037
|
+
}, field, true, undefined, this);
|
|
7038
|
+
};
|
|
7039
|
+
return /* @__PURE__ */ jsxDEV(Box, {
|
|
7040
|
+
flexDirection: "column",
|
|
7041
|
+
gap: 0,
|
|
7042
|
+
paddingY: 1,
|
|
7043
|
+
children: [
|
|
7044
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7045
|
+
bold: true,
|
|
7046
|
+
color: "cyan",
|
|
7047
|
+
children: " New Test Scenario"
|
|
7048
|
+
}, undefined, false, undefined, this),
|
|
7049
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7050
|
+
color: "gray",
|
|
7051
|
+
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"
|
|
7052
|
+
}, undefined, false, undefined, this),
|
|
7053
|
+
fieldLabel(" Name ", "name", state.name),
|
|
7054
|
+
fieldLabel(" URL ", "url", state.url, "(optional)"),
|
|
7055
|
+
fieldLabel(" Description", "description", state.description, "(optional)"),
|
|
7056
|
+
/* @__PURE__ */ jsxDEV(Box, {
|
|
7057
|
+
flexDirection: "row",
|
|
7058
|
+
gap: 1,
|
|
7059
|
+
children: [
|
|
7060
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7061
|
+
color: state.field === "priority" ? "cyan" : "gray",
|
|
7062
|
+
children: state.field === "priority" ? "\u2192" : " "
|
|
7063
|
+
}, undefined, false, undefined, this),
|
|
7064
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7065
|
+
color: state.field === "priority" ? "white" : "gray",
|
|
7066
|
+
bold: state.field === "priority",
|
|
7067
|
+
children: " Priority :"
|
|
7068
|
+
}, undefined, false, undefined, this),
|
|
7069
|
+
PRIORITIES.map((p) => /* @__PURE__ */ jsxDEV(Text, {
|
|
7070
|
+
color: p === state.priority ? "cyan" : "gray",
|
|
7071
|
+
bold: p === state.priority,
|
|
7072
|
+
children: p === state.priority ? `[${p}]` : ` ${p} `
|
|
7073
|
+
}, p, false, undefined, this)),
|
|
7074
|
+
state.field === "priority" && /* @__PURE__ */ jsxDEV(Text, {
|
|
7075
|
+
color: "gray",
|
|
7076
|
+
children: " \u2190 \u2192"
|
|
7077
|
+
}, undefined, false, undefined, this)
|
|
7078
|
+
]
|
|
7079
|
+
}, undefined, true, undefined, this),
|
|
7080
|
+
fieldLabel(" Tags ", "tags", state.tags, "comma-separated, optional"),
|
|
7081
|
+
state.field === "confirm" && /* @__PURE__ */ jsxDEV(Box, {
|
|
7082
|
+
flexDirection: "column",
|
|
7083
|
+
marginTop: 1,
|
|
7084
|
+
children: [
|
|
7085
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7086
|
+
color: "gray",
|
|
7087
|
+
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"
|
|
7088
|
+
}, undefined, false, undefined, this),
|
|
7089
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7090
|
+
bold: true,
|
|
7091
|
+
color: "white",
|
|
7092
|
+
children: " Preview:"
|
|
7093
|
+
}, undefined, false, undefined, this),
|
|
7094
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7095
|
+
color: "gray",
|
|
7096
|
+
children: [
|
|
7097
|
+
" name: ",
|
|
7098
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7099
|
+
color: "white",
|
|
7100
|
+
children: state.name
|
|
7101
|
+
}, undefined, false, undefined, this)
|
|
7102
|
+
]
|
|
7103
|
+
}, undefined, true, undefined, this),
|
|
7104
|
+
state.url && /* @__PURE__ */ jsxDEV(Text, {
|
|
7105
|
+
color: "gray",
|
|
7106
|
+
children: [
|
|
7107
|
+
" url: ",
|
|
7108
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7109
|
+
color: "white",
|
|
7110
|
+
children: state.url
|
|
7111
|
+
}, undefined, false, undefined, this)
|
|
7112
|
+
]
|
|
7113
|
+
}, undefined, true, undefined, this),
|
|
7114
|
+
state.description && /* @__PURE__ */ jsxDEV(Text, {
|
|
7115
|
+
color: "gray",
|
|
7116
|
+
children: [
|
|
7117
|
+
" description: ",
|
|
7118
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7119
|
+
color: "white",
|
|
7120
|
+
children: state.description
|
|
7121
|
+
}, undefined, false, undefined, this)
|
|
7122
|
+
]
|
|
7123
|
+
}, undefined, true, undefined, this),
|
|
7124
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7125
|
+
color: "gray",
|
|
7126
|
+
children: [
|
|
7127
|
+
" priority: ",
|
|
7128
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7129
|
+
color: "cyan",
|
|
7130
|
+
children: state.priority
|
|
7131
|
+
}, undefined, false, undefined, this)
|
|
7132
|
+
]
|
|
7133
|
+
}, undefined, true, undefined, this),
|
|
7134
|
+
state.tags && /* @__PURE__ */ jsxDEV(Text, {
|
|
7135
|
+
color: "gray",
|
|
7136
|
+
children: [
|
|
7137
|
+
" tags: ",
|
|
7138
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7139
|
+
color: "white",
|
|
7140
|
+
children: state.tags
|
|
7141
|
+
}, undefined, false, undefined, this)
|
|
7142
|
+
]
|
|
7143
|
+
}, undefined, true, undefined, this),
|
|
7144
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7145
|
+
children: " "
|
|
7146
|
+
}, undefined, false, undefined, this),
|
|
7147
|
+
/* @__PURE__ */ jsxDEV(Text, {
|
|
7148
|
+
color: "green",
|
|
7149
|
+
children: " Press Enter to save, Escape to cancel"
|
|
7150
|
+
}, undefined, false, undefined, this)
|
|
7151
|
+
]
|
|
7152
|
+
}, undefined, true, undefined, this),
|
|
7153
|
+
state.field !== "confirm" && /* @__PURE__ */ jsxDEV(Text, {
|
|
7154
|
+
color: "gray",
|
|
7155
|
+
dimColor: true,
|
|
7156
|
+
children: " Tab/Enter to advance \xB7 Escape to cancel"
|
|
7157
|
+
}, undefined, false, undefined, this)
|
|
7158
|
+
]
|
|
7159
|
+
}, undefined, true, undefined, this);
|
|
7160
|
+
}
|
|
7161
|
+
async function runInteractiveAdd(projectId) {
|
|
7162
|
+
let savedResult = null;
|
|
7163
|
+
const { waitUntilExit } = render(React.createElement(AddForm, {
|
|
7164
|
+
onComplete: (data) => {
|
|
7165
|
+
savedResult = data;
|
|
7166
|
+
}
|
|
7167
|
+
}));
|
|
7168
|
+
await waitUntilExit();
|
|
7169
|
+
if (savedResult) {
|
|
7170
|
+
const result = savedResult;
|
|
7171
|
+
const tags = result.tags ? result.tags.split(",").map((t) => t.trim()).filter(Boolean) : [];
|
|
7172
|
+
const scenario = createScenario({
|
|
7173
|
+
name: result.name,
|
|
7174
|
+
description: result.description || result.name,
|
|
7175
|
+
steps: [],
|
|
7176
|
+
tags,
|
|
7177
|
+
priority: result.priority,
|
|
7178
|
+
projectId
|
|
7179
|
+
});
|
|
7180
|
+
log(chalk5.green(`
|
|
7181
|
+
Created scenario ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
|
|
7182
|
+
} else {
|
|
7183
|
+
log(chalk5.dim(`
|
|
7184
|
+
Cancelled.`));
|
|
7185
|
+
}
|
|
7186
|
+
}
|
|
6508
7187
|
function formatToolInput(input) {
|
|
6509
7188
|
const parts = [];
|
|
6510
7189
|
for (const [key, value] of Object.entries(input)) {
|
|
@@ -6515,7 +7194,19 @@ function formatToolInput(input) {
|
|
|
6515
7194
|
return parts.join(" ");
|
|
6516
7195
|
}
|
|
6517
7196
|
var program2 = new Command;
|
|
6518
|
-
|
|
7197
|
+
var QUIET = false;
|
|
7198
|
+
var NO_COLOR = false;
|
|
7199
|
+
function log(...args) {
|
|
7200
|
+
if (QUIET)
|
|
7201
|
+
return;
|
|
7202
|
+
console.log(...args);
|
|
7203
|
+
}
|
|
7204
|
+
function logError(...args) {
|
|
7205
|
+
if (QUIET)
|
|
7206
|
+
return;
|
|
7207
|
+
console.error(...args);
|
|
7208
|
+
}
|
|
7209
|
+
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");
|
|
6519
7210
|
var CONFIG_DIR2 = join6(process.env["HOME"] ?? "~", ".testers");
|
|
6520
7211
|
var CONFIG_PATH2 = join6(CONFIG_DIR2, "config.json");
|
|
6521
7212
|
function getActiveProject() {
|
|
@@ -6530,7 +7221,7 @@ function getActiveProject() {
|
|
|
6530
7221
|
function resolveProject(optProject) {
|
|
6531
7222
|
return optProject ?? getActiveProject();
|
|
6532
7223
|
}
|
|
6533
|
-
program2.command("add
|
|
7224
|
+
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) => {
|
|
6534
7225
|
acc.push(val);
|
|
6535
7226
|
return acc;
|
|
6536
7227
|
}, []).option("-t, --tag <tag>", "Tag (repeatable)", (val, acc) => {
|
|
@@ -6539,18 +7230,28 @@ program2.command("add <name>").description("Create a new test scenario").option(
|
|
|
6539
7230
|
}, []).option("-p, --priority <level>", "Priority level", "medium").option("-m, --model <model>", "AI model to use").option("--path <path>", "Target path on the URL").option("--auth", "Requires authentication", false).option("--timeout <ms>", "Timeout in milliseconds").option("--project <id>", "Project ID").option("--template <name>", "Seed scenarios from a template (auth, crud, forms, nav, a11y)").option("--assert <assertion>", "Structured assertion (repeatable). Formats: selector:<sel> visible, text:<sel> contains:<text>, no-console-errors, url:contains:<path>, title:contains:<text>, count:<sel> eq:<n>", (val, acc) => {
|
|
6540
7231
|
acc.push(val);
|
|
6541
7232
|
return acc;
|
|
6542
|
-
}, []).action((name, opts) => {
|
|
7233
|
+
}, []).action(async (name, opts) => {
|
|
6543
7234
|
try {
|
|
7235
|
+
const hasFlags = opts.description || opts.steps?.length || opts.tag?.length || opts.model || opts.path || opts.auth || opts.timeout || opts.template || opts.assert?.length;
|
|
7236
|
+
if (!name && !hasFlags) {
|
|
7237
|
+
const projectId2 = resolveProject(opts.project);
|
|
7238
|
+
await runInteractiveAdd(projectId2);
|
|
7239
|
+
return;
|
|
7240
|
+
}
|
|
7241
|
+
if (!name) {
|
|
7242
|
+
logError(chalk5.red("Error: scenario name is required"));
|
|
7243
|
+
process.exit(1);
|
|
7244
|
+
}
|
|
6544
7245
|
if (opts.template) {
|
|
6545
7246
|
const template = getTemplate(opts.template);
|
|
6546
7247
|
if (!template) {
|
|
6547
|
-
|
|
7248
|
+
logError(chalk5.red(`Unknown template: ${opts.template}. Available: ${listTemplateNames().join(", ")}`));
|
|
6548
7249
|
process.exit(1);
|
|
6549
7250
|
}
|
|
6550
7251
|
const projectId2 = resolveProject(opts.project);
|
|
6551
7252
|
for (const input of template) {
|
|
6552
7253
|
const s = createScenario({ ...input, projectId: projectId2 });
|
|
6553
|
-
|
|
7254
|
+
log(chalk5.green(` Created ${s.shortId}: ${s.name}`));
|
|
6554
7255
|
}
|
|
6555
7256
|
return;
|
|
6556
7257
|
}
|
|
@@ -6569,117 +7270,169 @@ program2.command("add <name>").description("Create a new test scenario").option(
|
|
|
6569
7270
|
assertions: assertions.length > 0 ? assertions : undefined,
|
|
6570
7271
|
projectId
|
|
6571
7272
|
});
|
|
6572
|
-
|
|
7273
|
+
log(chalk5.green(`Created scenario ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
|
|
6573
7274
|
} catch (error) {
|
|
6574
|
-
|
|
7275
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6575
7276
|
process.exit(1);
|
|
6576
7277
|
}
|
|
6577
7278
|
});
|
|
6578
|
-
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) => {
|
|
7279
|
+
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("--search <text>", "Filter by name or description (case-insensitive substring match)").option("--sort <field>", "Sort field: date, priority, name (default: date)").option("--asc", "Sort ascending instead of descending", false).option("-l, --limit <n>", "Limit results", "50").option("--offset <n>", "Skip first N results", "0").option("--json", "Output as JSON", false).action((opts) => {
|
|
6579
7280
|
try {
|
|
6580
7281
|
const scenarios = listScenarios({
|
|
6581
7282
|
tags: opts.tag ? [opts.tag] : undefined,
|
|
6582
7283
|
priority: opts.priority,
|
|
6583
7284
|
projectId: opts.project,
|
|
6584
|
-
|
|
7285
|
+
search: opts.search,
|
|
7286
|
+
sort: opts.sort,
|
|
7287
|
+
desc: !opts.asc,
|
|
7288
|
+
limit: parseInt(opts.limit, 10),
|
|
7289
|
+
offset: parseInt(opts.offset, 10) || undefined
|
|
6585
7290
|
});
|
|
6586
|
-
|
|
7291
|
+
if (opts.json) {
|
|
7292
|
+
log(JSON.stringify(scenarios, null, 2));
|
|
7293
|
+
} else {
|
|
7294
|
+
log(formatScenarioList(scenarios));
|
|
7295
|
+
}
|
|
6587
7296
|
} catch (error) {
|
|
6588
|
-
|
|
7297
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6589
7298
|
process.exit(1);
|
|
6590
7299
|
}
|
|
6591
7300
|
});
|
|
6592
|
-
program2.command("show <id>").description("Show scenario details").action((id) => {
|
|
7301
|
+
program2.command("show <id>").description("Show scenario details").option("--json", "Output as JSON", false).action((id, opts) => {
|
|
6593
7302
|
try {
|
|
6594
7303
|
const scenario = getScenario(id) ?? getScenarioByShortId(id);
|
|
6595
7304
|
if (!scenario) {
|
|
6596
|
-
|
|
7305
|
+
logError(chalk5.red(`Scenario not found: ${id}`));
|
|
6597
7306
|
process.exit(1);
|
|
6598
7307
|
}
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
6602
|
-
|
|
6603
|
-
|
|
6604
|
-
|
|
6605
|
-
|
|
6606
|
-
|
|
6607
|
-
|
|
6608
|
-
|
|
6609
|
-
|
|
6610
|
-
|
|
6611
|
-
|
|
6612
|
-
|
|
7308
|
+
if (opts.json) {
|
|
7309
|
+
log(JSON.stringify(scenario, null, 2));
|
|
7310
|
+
return;
|
|
7311
|
+
}
|
|
7312
|
+
log("");
|
|
7313
|
+
log(chalk5.bold(` Scenario ${scenario.shortId}`));
|
|
7314
|
+
log(` Name: ${scenario.name}`);
|
|
7315
|
+
log(` ID: ${chalk5.dim(scenario.id)}`);
|
|
7316
|
+
log(` Description: ${scenario.description}`);
|
|
7317
|
+
log(` Priority: ${scenario.priority}`);
|
|
7318
|
+
log(` Model: ${scenario.model ?? chalk5.dim("default")}`);
|
|
7319
|
+
log(` Tags: ${scenario.tags.length > 0 ? scenario.tags.join(", ") : chalk5.dim("none")}`);
|
|
7320
|
+
log(` Path: ${scenario.targetPath ?? chalk5.dim("none")}`);
|
|
7321
|
+
log(` Auth: ${scenario.requiresAuth ? "yes" : "no"}`);
|
|
7322
|
+
log(` Timeout: ${scenario.timeoutMs ? `${scenario.timeoutMs}ms` : chalk5.dim("default")}`);
|
|
7323
|
+
log(` Version: ${scenario.version}`);
|
|
7324
|
+
log(` Created: ${scenario.createdAt}`);
|
|
7325
|
+
log(` Updated: ${scenario.updatedAt}`);
|
|
6613
7326
|
if (scenario.steps.length > 0) {
|
|
6614
|
-
|
|
6615
|
-
|
|
7327
|
+
log("");
|
|
7328
|
+
log(chalk5.bold(" Steps:"));
|
|
6616
7329
|
for (let i = 0;i < scenario.steps.length; i++) {
|
|
6617
|
-
|
|
7330
|
+
log(` ${i + 1}. ${scenario.steps[i]}`);
|
|
6618
7331
|
}
|
|
6619
7332
|
}
|
|
6620
|
-
|
|
7333
|
+
log("");
|
|
6621
7334
|
} catch (error) {
|
|
6622
|
-
|
|
7335
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6623
7336
|
process.exit(1);
|
|
6624
7337
|
}
|
|
6625
7338
|
});
|
|
6626
7339
|
program2.command("update <id>").description("Update a scenario").option("-n, --name <name>", "New name").option("-d, --description <text>", "New description").option("-s, --steps <step>", "Replace steps (repeatable)", (val, acc) => {
|
|
6627
7340
|
acc.push(val);
|
|
6628
7341
|
return acc;
|
|
6629
|
-
}, []).option("-t, --tag <tag>", "Replace tags (repeatable)", (val, acc) => {
|
|
7342
|
+
}, []).option("-t, --tag <tag>", "Replace all tags (repeatable)", (val, acc) => {
|
|
7343
|
+
acc.push(val);
|
|
7344
|
+
return acc;
|
|
7345
|
+
}, []).option("--tag-add <tag>", "Add a tag to existing tags (repeatable)", (val, acc) => {
|
|
7346
|
+
acc.push(val);
|
|
7347
|
+
return acc;
|
|
7348
|
+
}, []).option("--tag-remove <tag>", "Remove a tag from existing tags (repeatable)", (val, acc) => {
|
|
6630
7349
|
acc.push(val);
|
|
6631
7350
|
return acc;
|
|
6632
7351
|
}, []).option("-p, --priority <level>", "New priority").option("-m, --model <model>", "New model").action((id, opts) => {
|
|
6633
7352
|
try {
|
|
6634
7353
|
const scenario = getScenario(id) ?? getScenarioByShortId(id);
|
|
6635
7354
|
if (!scenario) {
|
|
6636
|
-
|
|
7355
|
+
logError(chalk5.red(`Scenario not found: ${id}`));
|
|
6637
7356
|
process.exit(1);
|
|
6638
7357
|
}
|
|
7358
|
+
let newTags;
|
|
7359
|
+
if (opts.tag.length > 0) {
|
|
7360
|
+
newTags = opts.tag;
|
|
7361
|
+
} else if (opts.tagAdd.length > 0 || opts.tagRemove.length > 0) {
|
|
7362
|
+
const existing = new Set(scenario.tags);
|
|
7363
|
+
for (const t of opts.tagAdd)
|
|
7364
|
+
existing.add(t);
|
|
7365
|
+
for (const t of opts.tagRemove)
|
|
7366
|
+
existing.delete(t);
|
|
7367
|
+
newTags = [...existing];
|
|
7368
|
+
}
|
|
6639
7369
|
const updated = updateScenario(scenario.id, {
|
|
6640
7370
|
name: opts.name,
|
|
6641
7371
|
description: opts.description,
|
|
6642
7372
|
steps: opts.steps.length > 0 ? opts.steps : undefined,
|
|
6643
|
-
tags:
|
|
7373
|
+
tags: newTags,
|
|
6644
7374
|
priority: opts.priority,
|
|
6645
7375
|
model: opts.model
|
|
6646
7376
|
}, scenario.version);
|
|
6647
|
-
|
|
7377
|
+
log(chalk5.green(`Updated scenario ${chalk5.bold(updated.shortId)}: ${updated.name}`));
|
|
7378
|
+
if (newTags !== undefined) {
|
|
7379
|
+
log(chalk5.dim(` Tags: [${updated.tags.join(", ")}]`));
|
|
7380
|
+
}
|
|
6648
7381
|
} catch (error) {
|
|
6649
|
-
|
|
7382
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6650
7383
|
process.exit(1);
|
|
6651
7384
|
}
|
|
6652
7385
|
});
|
|
6653
|
-
program2.command("delete <id>").description("Delete a scenario").action((id) => {
|
|
7386
|
+
program2.command("delete <id>").description("Delete a scenario").option("-y, --yes", "Skip confirmation prompt", false).action(async (id, opts) => {
|
|
6654
7387
|
try {
|
|
6655
7388
|
const scenario = getScenario(id) ?? getScenarioByShortId(id);
|
|
6656
7389
|
if (!scenario) {
|
|
6657
|
-
|
|
7390
|
+
logError(chalk5.red(`Scenario not found: ${id}`));
|
|
6658
7391
|
process.exit(1);
|
|
6659
7392
|
}
|
|
7393
|
+
if (!opts.yes) {
|
|
7394
|
+
process.stdout.write(chalk5.yellow(`Delete scenario ${scenario.shortId} "${scenario.name}"? [y/N] `));
|
|
7395
|
+
const answer = await new Promise((resolve2) => {
|
|
7396
|
+
let buf = "";
|
|
7397
|
+
process.stdin.setRawMode?.(true);
|
|
7398
|
+
process.stdin.resume();
|
|
7399
|
+
process.stdin.once("data", (chunk) => {
|
|
7400
|
+
buf = chunk.toString().trim().toLowerCase();
|
|
7401
|
+
process.stdin.setRawMode?.(false);
|
|
7402
|
+
process.stdin.pause();
|
|
7403
|
+
process.stdout.write(`
|
|
7404
|
+
`);
|
|
7405
|
+
resolve2(buf);
|
|
7406
|
+
});
|
|
7407
|
+
});
|
|
7408
|
+
if (answer !== "y" && answer !== "yes") {
|
|
7409
|
+
log(chalk5.dim("Cancelled."));
|
|
7410
|
+
return;
|
|
7411
|
+
}
|
|
7412
|
+
}
|
|
6660
7413
|
const deleted = deleteScenario(scenario.id);
|
|
6661
7414
|
if (deleted) {
|
|
6662
|
-
|
|
7415
|
+
log(chalk5.green(`Deleted scenario ${scenario.shortId}: ${scenario.name}`));
|
|
6663
7416
|
} else {
|
|
6664
|
-
|
|
7417
|
+
logError(chalk5.red(`Failed to delete scenario: ${id}`));
|
|
6665
7418
|
process.exit(1);
|
|
6666
7419
|
}
|
|
6667
7420
|
} catch (error) {
|
|
6668
|
-
|
|
7421
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6669
7422
|
process.exit(1);
|
|
6670
7423
|
}
|
|
6671
7424
|
});
|
|
6672
|
-
program2.command("run [url] [description]").description("Run test scenarios against a URL").option("-t, --tag <tag>", "Filter by tag (repeatable)", (val, acc) => {
|
|
7425
|
+
program2.command("run [url] [description]").alias("test").description("Run test scenarios against a URL").option("-t, --tag <tag>", "Filter by tag (repeatable)", (val, acc) => {
|
|
6673
7426
|
acc.push(val);
|
|
6674
7427
|
return acc;
|
|
6675
|
-
}, []).option("-s, --scenario <id>", "Run specific scenario ID").option("-p, --priority <level>", "Filter by priority").option("--headed", "Run browser in headed mode", false).option("-m, --model <model>", "AI model to use").option("--parallel <n>", "Number of parallel browsers", "1").option("--json", "Output results as JSON", false).option("-o, --output <filepath>", "Write JSON results to file").option("--timeout <ms>", "Timeout in milliseconds").option("--from-todos", "Import scenarios from todos before running", false).option("--project <id>", "Project ID").option("-b, --background", "Start run in background and return immediately", false).option("--browser <engine>", "Browser engine: playwright or lightpanda", "playwright").option("--env <name>", "Use a named environment for the URL").action(async (urlArg, description, opts) => {
|
|
7428
|
+
}, []).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").option("--verbose", "Show per-step timing and full tool results", false).option("--watch-results", "When used with --background, poll and display live results table until run completes", false).option("--failed-only", "Only show failed/error scenarios in output (passed count shown as summary)", false).action(async (urlArg, description, opts) => {
|
|
6676
7429
|
try {
|
|
6677
7430
|
const projectId = resolveProject(opts.project);
|
|
6678
7431
|
let url = urlArg;
|
|
6679
7432
|
if (!url && opts.env) {
|
|
6680
7433
|
const env = getEnvironment(opts.env);
|
|
6681
7434
|
if (!env) {
|
|
6682
|
-
|
|
7435
|
+
logError(chalk5.red(`Environment not found: ${opts.env}`));
|
|
6683
7436
|
process.exit(1);
|
|
6684
7437
|
}
|
|
6685
7438
|
url = env.url;
|
|
@@ -6688,16 +7441,76 @@ program2.command("run [url] [description]").description("Run test scenarios agai
|
|
|
6688
7441
|
const defaultEnv = getDefaultEnvironment();
|
|
6689
7442
|
if (defaultEnv) {
|
|
6690
7443
|
url = defaultEnv.url;
|
|
6691
|
-
|
|
7444
|
+
log(chalk5.dim(`Using default environment: ${defaultEnv.name} (${defaultEnv.url})`));
|
|
6692
7445
|
}
|
|
6693
7446
|
}
|
|
6694
7447
|
if (!url) {
|
|
6695
|
-
|
|
7448
|
+
logError(chalk5.red("No URL provided. Pass a URL argument, use --env <name>, or set a default environment with 'testers env use <name>'."));
|
|
6696
7449
|
process.exit(1);
|
|
6697
7450
|
}
|
|
7451
|
+
if (!opts.dryRun && !opts.background) {
|
|
7452
|
+
const budgetResult = checkBudget(0);
|
|
7453
|
+
if (budgetResult.warning) {
|
|
7454
|
+
log(chalk5.yellow(` \u26A0\uFE0F Budget warning: ${budgetResult.warning}`));
|
|
7455
|
+
if (!budgetResult.allowed) {
|
|
7456
|
+
if (!opts.yes) {
|
|
7457
|
+
log(chalk5.yellow(" Use --yes to run anyway, or check your budget config."));
|
|
7458
|
+
process.exit(1);
|
|
7459
|
+
}
|
|
7460
|
+
log(chalk5.yellow(" --yes passed, proceeding despite budget limit."));
|
|
7461
|
+
}
|
|
7462
|
+
}
|
|
7463
|
+
}
|
|
6698
7464
|
if (opts.fromTodos) {
|
|
6699
7465
|
const result = importFromTodos({ projectId });
|
|
6700
|
-
|
|
7466
|
+
log(chalk5.blue(`Imported ${result.imported} scenarios from todos (${result.skipped} skipped)`));
|
|
7467
|
+
}
|
|
7468
|
+
if (opts.dryRun) {
|
|
7469
|
+
const dryScenarios = listScenarios({
|
|
7470
|
+
tags: opts.tag.length > 0 ? opts.tag : undefined,
|
|
7471
|
+
projectId
|
|
7472
|
+
}).filter((s) => {
|
|
7473
|
+
if (opts.scenario && s.id !== opts.scenario && s.shortId !== opts.scenario)
|
|
7474
|
+
return false;
|
|
7475
|
+
if (opts.priority && s.priority !== opts.priority)
|
|
7476
|
+
return false;
|
|
7477
|
+
return true;
|
|
7478
|
+
});
|
|
7479
|
+
log("");
|
|
7480
|
+
log(chalk5.bold(" Dry Run \u2014 scenarios that would execute:"));
|
|
7481
|
+
log("");
|
|
7482
|
+
if (dryScenarios.length === 0) {
|
|
7483
|
+
log(chalk5.yellow(" No matching scenarios found."));
|
|
7484
|
+
} else {
|
|
7485
|
+
for (const s of dryScenarios) {
|
|
7486
|
+
const assertionErrors = [];
|
|
7487
|
+
for (const a of s.assertions ?? []) {
|
|
7488
|
+
try {
|
|
7489
|
+
parseAssertionString(a);
|
|
7490
|
+
} catch {
|
|
7491
|
+
assertionErrors.push(a);
|
|
7492
|
+
}
|
|
7493
|
+
}
|
|
7494
|
+
let authOk = true;
|
|
7495
|
+
if (s.authPreset) {
|
|
7496
|
+
const presets = listAuthPresets();
|
|
7497
|
+
authOk = presets.some((p) => p.name === s.authPreset);
|
|
7498
|
+
}
|
|
7499
|
+
const statusIcon = assertionErrors.length === 0 && authOk ? chalk5.green("\u2713") : chalk5.red("\u2717");
|
|
7500
|
+
log(` ${statusIcon} ${chalk5.cyan(s.shortId)} ${s.name} ${chalk5.dim(`[${s.tags.join(", ")}]`)}`);
|
|
7501
|
+
if (assertionErrors.length > 0) {
|
|
7502
|
+
log(chalk5.red(` Invalid assertions: ${assertionErrors.join(", ")}`));
|
|
7503
|
+
}
|
|
7504
|
+
if (!authOk) {
|
|
7505
|
+
log(chalk5.red(` Auth preset not found: ${s.authPreset}`));
|
|
7506
|
+
}
|
|
7507
|
+
}
|
|
7508
|
+
}
|
|
7509
|
+
log("");
|
|
7510
|
+
log(chalk5.dim(` URL: ${url}`));
|
|
7511
|
+
log(chalk5.dim(` Total: ${dryScenarios.length} scenarios`));
|
|
7512
|
+
log("");
|
|
7513
|
+
process.exit(0);
|
|
6701
7514
|
}
|
|
6702
7515
|
if (opts.background) {
|
|
6703
7516
|
if (description) {
|
|
@@ -6715,52 +7528,106 @@ program2.command("run [url] [description]").description("Run test scenarios agai
|
|
|
6715
7528
|
projectId,
|
|
6716
7529
|
engine: opts.browser
|
|
6717
7530
|
});
|
|
6718
|
-
|
|
6719
|
-
|
|
6720
|
-
|
|
6721
|
-
|
|
7531
|
+
log(chalk5.green(`Run started in background: ${chalk5.bold(runId.slice(0, 8))}`));
|
|
7532
|
+
log(chalk5.dim(` Scenarios: ${scenarioCount}`));
|
|
7533
|
+
log(chalk5.dim(` URL: ${url}`));
|
|
7534
|
+
if (opts.watchResults) {
|
|
7535
|
+
log(chalk5.dim(` Watching results (polling every 3s)...`));
|
|
7536
|
+
log("");
|
|
7537
|
+
const POLL_INTERVAL = 3000;
|
|
7538
|
+
const DONE_STATUSES = new Set(["passed", "failed", "cancelled"]);
|
|
7539
|
+
const renderTable = () => {
|
|
7540
|
+
const run2 = getRun(runId);
|
|
7541
|
+
if (!run2)
|
|
7542
|
+
return;
|
|
7543
|
+
const results2 = getResultsByRun(runId);
|
|
7544
|
+
const statusIcon = run2.status === "passed" ? chalk5.green("PASS") : run2.status === "failed" ? chalk5.red("FAIL") : chalk5.blue("RUN ");
|
|
7545
|
+
process.stdout.write(`\r ${statusIcon} ${run2.passed} passed ${run2.failed} failed ${run2.total - run2.passed - run2.failed} running (${results2.length}/${run2.total})
|
|
7546
|
+
`);
|
|
7547
|
+
for (const r of results2) {
|
|
7548
|
+
const scenario = getScenario(r.scenarioId);
|
|
7549
|
+
const name = scenario ? scenario.name : r.scenarioId.slice(0, 8);
|
|
7550
|
+
const icon = r.status === "passed" ? chalk5.green("\u2713") : r.status === "failed" ? chalk5.red("\u2717") : r.status === "error" ? chalk5.yellow("!") : chalk5.blue("\u2026");
|
|
7551
|
+
const dur = r.durationMs > 0 ? chalk5.dim(` ${(r.durationMs / 1000).toFixed(1)}s`) : "";
|
|
7552
|
+
process.stdout.write(` ${icon} ${name}${dur}
|
|
7553
|
+
`);
|
|
7554
|
+
}
|
|
7555
|
+
};
|
|
7556
|
+
await new Promise((resolve2) => {
|
|
7557
|
+
const poll = setInterval(() => {
|
|
7558
|
+
const run2 = getRun(runId);
|
|
7559
|
+
if (!run2)
|
|
7560
|
+
return;
|
|
7561
|
+
renderTable();
|
|
7562
|
+
if (DONE_STATUSES.has(run2.status)) {
|
|
7563
|
+
clearInterval(poll);
|
|
7564
|
+
resolve2();
|
|
7565
|
+
}
|
|
7566
|
+
}, POLL_INTERVAL);
|
|
7567
|
+
});
|
|
7568
|
+
const finalRun = getRun(runId);
|
|
7569
|
+
if (finalRun) {
|
|
7570
|
+
log("");
|
|
7571
|
+
const results2 = getResultsByRun(runId);
|
|
7572
|
+
log(formatTerminal(finalRun, results2));
|
|
7573
|
+
}
|
|
7574
|
+
process.exit(finalRun ? getExitCode(finalRun) : 0);
|
|
7575
|
+
}
|
|
7576
|
+
log(chalk5.dim(` Check progress: testers results ${runId.slice(0, 8)}`));
|
|
6722
7577
|
process.exit(0);
|
|
6723
7578
|
}
|
|
6724
7579
|
if (!opts.json && !opts.output) {
|
|
7580
|
+
const verbose = !!opts.verbose;
|
|
6725
7581
|
onRunEvent((event) => {
|
|
6726
7582
|
switch (event.type) {
|
|
6727
7583
|
case "scenario:start":
|
|
6728
|
-
|
|
7584
|
+
if (event.retryAttempt) {
|
|
7585
|
+
log(chalk5.yellow(` [retry] Retrying scenario ${event.scenarioName ?? event.scenarioId} (attempt ${event.retryAttempt}/${event.maxRetries})...`));
|
|
7586
|
+
} else {
|
|
7587
|
+
log(chalk5.blue(` [start] ${event.scenarioName ?? event.scenarioId}`));
|
|
7588
|
+
}
|
|
7589
|
+
break;
|
|
7590
|
+
case "scenario:timeout_warning": {
|
|
7591
|
+
const elapsedS = ((event.elapsedMs ?? 0) / 1000).toFixed(0);
|
|
7592
|
+
const totalS = ((event.timeoutMs ?? 0) / 1000).toFixed(0);
|
|
7593
|
+
log(chalk5.yellow(` \u26A0\uFE0F Scenario '${event.scenarioName}' at 80% timeout (${elapsedS}s/${totalS}s) \u2014 still running`));
|
|
6729
7594
|
break;
|
|
7595
|
+
}
|
|
6730
7596
|
case "step:thinking":
|
|
6731
7597
|
if (event.thinking) {
|
|
6732
7598
|
const preview = event.thinking.length > 120 ? event.thinking.slice(0, 120) + "..." : event.thinking;
|
|
6733
|
-
|
|
7599
|
+
log(chalk5.dim(` [think] ${preview}`));
|
|
6734
7600
|
}
|
|
6735
7601
|
break;
|
|
6736
7602
|
case "step:tool_call":
|
|
6737
|
-
|
|
7603
|
+
log(chalk5.cyan(` [step ${event.stepNumber}] ${event.toolName}${event.toolInput ? ` ${formatToolInput(event.toolInput)}` : ""}`));
|
|
6738
7604
|
break;
|
|
6739
7605
|
case "step:tool_result":
|
|
6740
7606
|
if (event.toolName === "report_result") {
|
|
6741
|
-
|
|
7607
|
+
log(chalk5.bold(` [result] ${event.toolResult}`));
|
|
6742
7608
|
} else {
|
|
7609
|
+
const durationStr = verbose && event.stepDurationMs !== undefined ? chalk5.dim(`[${(event.stepDurationMs / 1000).toFixed(1)}s] `) : "";
|
|
6743
7610
|
const resultPreview = (event.toolResult ?? "").length > 100 ? (event.toolResult ?? "").slice(0, 100) + "..." : event.toolResult ?? "";
|
|
6744
|
-
|
|
7611
|
+
log(chalk5.dim(` [done] ${durationStr}${resultPreview}`));
|
|
6745
7612
|
}
|
|
6746
7613
|
break;
|
|
6747
7614
|
case "screenshot:captured":
|
|
6748
|
-
|
|
7615
|
+
log(chalk5.dim(` [screenshot] ${event.screenshotPath}`));
|
|
6749
7616
|
break;
|
|
6750
7617
|
case "scenario:pass":
|
|
6751
|
-
|
|
7618
|
+
log(chalk5.green(` [PASS] ${event.scenarioName}`));
|
|
6752
7619
|
break;
|
|
6753
7620
|
case "scenario:fail":
|
|
6754
|
-
|
|
7621
|
+
log(chalk5.red(` [FAIL] ${event.scenarioName}`));
|
|
6755
7622
|
break;
|
|
6756
7623
|
case "scenario:error":
|
|
6757
|
-
|
|
7624
|
+
log(chalk5.yellow(` [ERR] ${event.scenarioName}: ${event.error}`));
|
|
6758
7625
|
break;
|
|
6759
7626
|
}
|
|
6760
7627
|
});
|
|
6761
|
-
|
|
6762
|
-
|
|
6763
|
-
|
|
7628
|
+
log("");
|
|
7629
|
+
log(chalk5.bold(` Running tests against ${url}`));
|
|
7630
|
+
log("");
|
|
6764
7631
|
}
|
|
6765
7632
|
if (description) {
|
|
6766
7633
|
const scenario = createScenario({
|
|
@@ -6776,6 +7643,7 @@ program2.command("run [url] [description]").description("Run test scenarios agai
|
|
|
6776
7643
|
headed: opts.headed,
|
|
6777
7644
|
parallel: parseInt(opts.parallel, 10),
|
|
6778
7645
|
timeout: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
|
|
7646
|
+
retry: parseInt(opts.retry ?? "0", 10),
|
|
6779
7647
|
projectId,
|
|
6780
7648
|
engine: opts.browser
|
|
6781
7649
|
});
|
|
@@ -6783,16 +7651,22 @@ program2.command("run [url] [description]").description("Run test scenarios agai
|
|
|
6783
7651
|
const jsonOutput = formatJSON(run2, results2);
|
|
6784
7652
|
if (opts.output) {
|
|
6785
7653
|
writeFileSync3(opts.output, jsonOutput, "utf-8");
|
|
6786
|
-
|
|
7654
|
+
log(chalk5.green(`Results written to ${opts.output}`));
|
|
6787
7655
|
}
|
|
6788
7656
|
if (opts.json) {
|
|
6789
|
-
|
|
7657
|
+
log(jsonOutput);
|
|
6790
7658
|
}
|
|
6791
7659
|
} else {
|
|
6792
|
-
|
|
7660
|
+
log(formatTerminal(run2, results2, { failedOnly: opts.failedOnly }));
|
|
6793
7661
|
}
|
|
6794
7662
|
process.exit(getExitCode(run2));
|
|
6795
7663
|
}
|
|
7664
|
+
const noFilters = !opts.scenario && opts.tag.length === 0 && !opts.priority;
|
|
7665
|
+
if (noFilters && !opts.json && !opts.output) {
|
|
7666
|
+
const allScenarios = listScenarios({ projectId });
|
|
7667
|
+
log(chalk5.bold(` Running all ${allScenarios.length} scenarios...`));
|
|
7668
|
+
log("");
|
|
7669
|
+
}
|
|
6796
7670
|
const { run, results } = await runByFilter({
|
|
6797
7671
|
url,
|
|
6798
7672
|
tags: opts.tag.length > 0 ? opts.tag : undefined,
|
|
@@ -6802,6 +7676,7 @@ program2.command("run [url] [description]").description("Run test scenarios agai
|
|
|
6802
7676
|
headed: opts.headed,
|
|
6803
7677
|
parallel: parseInt(opts.parallel, 10),
|
|
6804
7678
|
timeout: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
|
|
7679
|
+
retry: parseInt(opts.retry ?? "0", 10),
|
|
6805
7680
|
projectId,
|
|
6806
7681
|
engine: opts.browser
|
|
6807
7682
|
});
|
|
@@ -6809,43 +7684,54 @@ program2.command("run [url] [description]").description("Run test scenarios agai
|
|
|
6809
7684
|
const jsonOutput = formatJSON(run, results);
|
|
6810
7685
|
if (opts.output) {
|
|
6811
7686
|
writeFileSync3(opts.output, jsonOutput, "utf-8");
|
|
6812
|
-
|
|
7687
|
+
log(chalk5.green(`Results written to ${opts.output}`));
|
|
6813
7688
|
}
|
|
6814
7689
|
if (opts.json) {
|
|
6815
|
-
|
|
7690
|
+
log(jsonOutput);
|
|
6816
7691
|
}
|
|
6817
7692
|
} else {
|
|
6818
|
-
|
|
7693
|
+
log(formatTerminal(run, results, { failedOnly: opts.failedOnly }));
|
|
6819
7694
|
}
|
|
6820
7695
|
process.exit(getExitCode(run));
|
|
6821
7696
|
} catch (error) {
|
|
6822
|
-
|
|
7697
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6823
7698
|
process.exit(1);
|
|
6824
7699
|
}
|
|
6825
7700
|
});
|
|
6826
|
-
program2.command("runs").description("List past test runs").option("--status <status>", "Filter by status").option("-l, --limit <n>", "Limit results", "20").action((opts) => {
|
|
7701
|
+
program2.command("runs").description("List past test runs").option("--status <status>", "Filter by status").option("--sort <field>", "Sort field: date, duration, cost (default: date)").option("--asc", "Sort ascending instead of descending", false).option("-l, --limit <n>", "Limit results", "20").option("--offset <n>", "Skip first N results", "0").option("--json", "Output as JSON", false).action((opts) => {
|
|
6827
7702
|
try {
|
|
6828
7703
|
const runs = listRuns({
|
|
6829
7704
|
status: opts.status,
|
|
6830
|
-
|
|
7705
|
+
sort: opts.sort,
|
|
7706
|
+
desc: !opts.asc,
|
|
7707
|
+
limit: parseInt(opts.limit, 10),
|
|
7708
|
+
offset: parseInt(opts.offset, 10) || undefined
|
|
6831
7709
|
});
|
|
6832
|
-
|
|
7710
|
+
if (opts.json) {
|
|
7711
|
+
log(JSON.stringify(runs, null, 2));
|
|
7712
|
+
} else {
|
|
7713
|
+
log(formatRunList(runs));
|
|
7714
|
+
}
|
|
6833
7715
|
} catch (error) {
|
|
6834
|
-
|
|
7716
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6835
7717
|
process.exit(1);
|
|
6836
7718
|
}
|
|
6837
7719
|
});
|
|
6838
|
-
program2.command("results <run-id>").description("Show results for a test run").action((runId) => {
|
|
7720
|
+
program2.command("results <run-id>").description("Show results for a test run").option("--json", "Output as JSON", false).action((runId, opts) => {
|
|
6839
7721
|
try {
|
|
6840
7722
|
const run = getRun(runId);
|
|
6841
7723
|
if (!run) {
|
|
6842
|
-
|
|
7724
|
+
logError(chalk5.red(`Run not found: ${runId}`));
|
|
6843
7725
|
process.exit(1);
|
|
6844
7726
|
}
|
|
6845
7727
|
const results = getResultsByRun(run.id);
|
|
6846
|
-
|
|
7728
|
+
if (opts.json) {
|
|
7729
|
+
log(formatJSON(run, results));
|
|
7730
|
+
} else {
|
|
7731
|
+
log(formatTerminal(run, results));
|
|
7732
|
+
}
|
|
6847
7733
|
} catch (error) {
|
|
6848
|
-
|
|
7734
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6849
7735
|
process.exit(1);
|
|
6850
7736
|
}
|
|
6851
7737
|
});
|
|
@@ -6855,43 +7741,43 @@ program2.command("screenshots <id>").description("List screenshots for a run or
|
|
|
6855
7741
|
if (run) {
|
|
6856
7742
|
const results = getResultsByRun(run.id);
|
|
6857
7743
|
let total = 0;
|
|
6858
|
-
|
|
6859
|
-
|
|
6860
|
-
|
|
7744
|
+
log("");
|
|
7745
|
+
log(chalk5.bold(` Screenshots for run ${run.id.slice(0, 8)}`));
|
|
7746
|
+
log("");
|
|
6861
7747
|
for (const result of results) {
|
|
6862
7748
|
const screenshots2 = listScreenshots(result.id);
|
|
6863
7749
|
if (screenshots2.length > 0) {
|
|
6864
7750
|
const scenario = getScenario(result.scenarioId);
|
|
6865
7751
|
const label = scenario ? `${scenario.shortId}: ${scenario.name}` : result.scenarioId.slice(0, 8);
|
|
6866
|
-
|
|
7752
|
+
log(chalk5.bold(` ${label}`));
|
|
6867
7753
|
for (const ss of screenshots2) {
|
|
6868
|
-
|
|
7754
|
+
log(` ${chalk5.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk5.dim(ss.filePath)}`);
|
|
6869
7755
|
total++;
|
|
6870
7756
|
}
|
|
6871
|
-
|
|
7757
|
+
log("");
|
|
6872
7758
|
}
|
|
6873
7759
|
}
|
|
6874
7760
|
if (total === 0) {
|
|
6875
|
-
|
|
6876
|
-
|
|
7761
|
+
log(chalk5.dim(" No screenshots found."));
|
|
7762
|
+
log("");
|
|
6877
7763
|
}
|
|
6878
7764
|
return;
|
|
6879
7765
|
}
|
|
6880
7766
|
const screenshots = listScreenshots(id);
|
|
6881
7767
|
if (screenshots.length > 0) {
|
|
6882
|
-
|
|
6883
|
-
|
|
6884
|
-
|
|
7768
|
+
log("");
|
|
7769
|
+
log(chalk5.bold(` Screenshots for result ${id.slice(0, 8)}`));
|
|
7770
|
+
log("");
|
|
6885
7771
|
for (const ss of screenshots) {
|
|
6886
|
-
|
|
7772
|
+
log(` ${chalk5.dim(String(ss.stepNumber).padStart(3, "0"))} ${ss.action} \u2014 ${chalk5.dim(ss.filePath)}`);
|
|
6887
7773
|
}
|
|
6888
|
-
|
|
7774
|
+
log("");
|
|
6889
7775
|
return;
|
|
6890
7776
|
}
|
|
6891
|
-
|
|
7777
|
+
logError(chalk5.red(`No screenshots found for: ${id}`));
|
|
6892
7778
|
process.exit(1);
|
|
6893
7779
|
} catch (error) {
|
|
6894
|
-
|
|
7780
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6895
7781
|
process.exit(1);
|
|
6896
7782
|
}
|
|
6897
7783
|
});
|
|
@@ -6900,7 +7786,7 @@ program2.command("import <dir>").description("Import markdown test files as scen
|
|
|
6900
7786
|
const absDir = resolve(dir);
|
|
6901
7787
|
const files = readdirSync(absDir).filter((f) => f.endsWith(".md"));
|
|
6902
7788
|
if (files.length === 0) {
|
|
6903
|
-
|
|
7789
|
+
log(chalk5.dim("No .md files found in directory."));
|
|
6904
7790
|
return;
|
|
6905
7791
|
}
|
|
6906
7792
|
let imported = 0;
|
|
@@ -6930,22 +7816,87 @@ program2.command("import <dir>").description("Import markdown test files as scen
|
|
|
6930
7816
|
description: descriptionLines.join(" ") || name,
|
|
6931
7817
|
steps
|
|
6932
7818
|
});
|
|
6933
|
-
|
|
7819
|
+
log(chalk5.green(` Imported ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
|
|
6934
7820
|
imported++;
|
|
6935
7821
|
}
|
|
6936
|
-
|
|
6937
|
-
|
|
7822
|
+
log("");
|
|
7823
|
+
log(chalk5.green(`Imported ${imported} scenario(s) from ${absDir}`));
|
|
6938
7824
|
} catch (error) {
|
|
6939
|
-
|
|
7825
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7826
|
+
process.exit(1);
|
|
7827
|
+
}
|
|
7828
|
+
});
|
|
7829
|
+
program2.command("export [format]").description("Export scenarios as JSON (default) or markdown files").option("-o, --output <path>", "Output file (JSON) or directory (markdown)").option("-t, --tag <tag>", "Filter by tag").option("-p, --priority <level>", "Filter by priority").option("--project <id>", "Filter by project ID").action((format, opts) => {
|
|
7830
|
+
try {
|
|
7831
|
+
const fmt = (format ?? "json").toLowerCase();
|
|
7832
|
+
if (fmt !== "json" && fmt !== "markdown") {
|
|
7833
|
+
logError(chalk5.red(`Unknown format: ${fmt}. Supported: json, markdown`));
|
|
7834
|
+
process.exit(1);
|
|
7835
|
+
}
|
|
7836
|
+
const projectId = resolveProject(opts.project);
|
|
7837
|
+
const scenarios = listScenarios({
|
|
7838
|
+
tags: opts.tag ? [opts.tag] : undefined,
|
|
7839
|
+
priority: opts.priority,
|
|
7840
|
+
projectId
|
|
7841
|
+
});
|
|
7842
|
+
if (scenarios.length === 0) {
|
|
7843
|
+
log(chalk5.dim("No scenarios found to export."));
|
|
7844
|
+
return;
|
|
7845
|
+
}
|
|
7846
|
+
if (fmt === "json") {
|
|
7847
|
+
const outputPath = opts.output ?? "testers-export.json";
|
|
7848
|
+
const data = JSON.stringify(scenarios, null, 2);
|
|
7849
|
+
writeFileSync3(outputPath, data, "utf-8");
|
|
7850
|
+
log(chalk5.green(`Exported ${scenarios.length} scenario(s) to ${resolve(outputPath)}`));
|
|
7851
|
+
return;
|
|
7852
|
+
}
|
|
7853
|
+
const outputDir = opts.output ?? ".";
|
|
7854
|
+
if (!existsSync8(outputDir)) {
|
|
7855
|
+
mkdirSync4(outputDir, { recursive: true });
|
|
7856
|
+
}
|
|
7857
|
+
for (const s of scenarios) {
|
|
7858
|
+
const lines = [];
|
|
7859
|
+
lines.push(`# ${s.name}`);
|
|
7860
|
+
lines.push("");
|
|
7861
|
+
if (s.description && s.description !== s.name) {
|
|
7862
|
+
lines.push(s.description);
|
|
7863
|
+
lines.push("");
|
|
7864
|
+
}
|
|
7865
|
+
if (s.tags.length > 0) {
|
|
7866
|
+
lines.push(`**Tags:** ${s.tags.join(", ")}`);
|
|
7867
|
+
}
|
|
7868
|
+
lines.push(`**Priority:** ${s.priority}`);
|
|
7869
|
+
if (s.targetPath) {
|
|
7870
|
+
lines.push(`**Path:** ${s.targetPath}`);
|
|
7871
|
+
}
|
|
7872
|
+
lines.push("");
|
|
7873
|
+
if (s.steps.length > 0) {
|
|
7874
|
+
lines.push("## Steps");
|
|
7875
|
+
lines.push("");
|
|
7876
|
+
for (const step of s.steps) {
|
|
7877
|
+
lines.push(`- [ ] ${step}`);
|
|
7878
|
+
}
|
|
7879
|
+
lines.push("");
|
|
7880
|
+
}
|
|
7881
|
+
const safeFilename = s.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 80);
|
|
7882
|
+
const filePath = join6(outputDir, `${s.shortId}-${safeFilename}.md`);
|
|
7883
|
+
writeFileSync3(filePath, lines.join(`
|
|
7884
|
+
`), "utf-8");
|
|
7885
|
+
log(chalk5.dim(` ${s.shortId}: ${s.name} \u2192 ${filePath}`));
|
|
7886
|
+
}
|
|
7887
|
+
log(chalk5.green(`
|
|
7888
|
+
Exported ${scenarios.length} scenario(s) as markdown to ${resolve(outputDir)}`));
|
|
7889
|
+
} catch (error) {
|
|
7890
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6940
7891
|
process.exit(1);
|
|
6941
7892
|
}
|
|
6942
7893
|
});
|
|
6943
7894
|
program2.command("config").description("Show current configuration").action(() => {
|
|
6944
7895
|
try {
|
|
6945
7896
|
const config = loadConfig();
|
|
6946
|
-
|
|
7897
|
+
log(JSON.stringify(config, null, 2));
|
|
6947
7898
|
} catch (error) {
|
|
6948
|
-
|
|
7899
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6949
7900
|
process.exit(1);
|
|
6950
7901
|
}
|
|
6951
7902
|
});
|
|
@@ -6954,33 +7905,33 @@ program2.command("status").description("Show database and auth status").action((
|
|
|
6954
7905
|
const config = loadConfig();
|
|
6955
7906
|
const hasApiKey = !!config.anthropicApiKey || !!process.env["ANTHROPIC_API_KEY"];
|
|
6956
7907
|
const dbPath = join6(process.env["HOME"] ?? "~", ".testers", "testers.db");
|
|
6957
|
-
|
|
6958
|
-
|
|
6959
|
-
|
|
6960
|
-
|
|
6961
|
-
|
|
6962
|
-
|
|
6963
|
-
|
|
6964
|
-
|
|
7908
|
+
log("");
|
|
7909
|
+
log(chalk5.bold(" Open Testers Status"));
|
|
7910
|
+
log("");
|
|
7911
|
+
log(` ANTHROPIC_API_KEY: ${hasApiKey ? chalk5.green("set") : chalk5.red("not set")}`);
|
|
7912
|
+
log(` Database: ${dbPath}`);
|
|
7913
|
+
log(` Default model: ${config.defaultModel}`);
|
|
7914
|
+
log(` Screenshots dir: ${config.screenshots.dir}`);
|
|
7915
|
+
log("");
|
|
6965
7916
|
} catch (error) {
|
|
6966
|
-
|
|
7917
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6967
7918
|
process.exit(1);
|
|
6968
7919
|
}
|
|
6969
7920
|
});
|
|
6970
7921
|
program2.command("install-browser").description("Install browser engine").option("--engine <engine>", "Engine to install: playwright, lightpanda, or all", "playwright").action(async (opts) => {
|
|
6971
7922
|
try {
|
|
6972
7923
|
if (opts.engine === "all" || opts.engine === "playwright") {
|
|
6973
|
-
|
|
7924
|
+
log(chalk5.blue("Installing Playwright Chromium..."));
|
|
6974
7925
|
await installBrowser("playwright");
|
|
6975
|
-
|
|
7926
|
+
log(chalk5.green("Playwright Chromium installed."));
|
|
6976
7927
|
}
|
|
6977
7928
|
if (opts.engine === "all" || opts.engine === "lightpanda") {
|
|
6978
|
-
|
|
7929
|
+
log(chalk5.blue("Installing Lightpanda..."));
|
|
6979
7930
|
await installBrowser("lightpanda");
|
|
6980
|
-
|
|
7931
|
+
log(chalk5.green("Lightpanda installed."));
|
|
6981
7932
|
}
|
|
6982
7933
|
} catch (error) {
|
|
6983
|
-
|
|
7934
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6984
7935
|
process.exit(1);
|
|
6985
7936
|
}
|
|
6986
7937
|
});
|
|
@@ -6992,9 +7943,9 @@ projectCmd.command("create <name>").description("Create a new project").option("
|
|
|
6992
7943
|
path: opts.path,
|
|
6993
7944
|
description: opts.description
|
|
6994
7945
|
});
|
|
6995
|
-
|
|
7946
|
+
log(chalk5.green(`Created project ${chalk5.bold(project.name)} (${project.id})`));
|
|
6996
7947
|
} catch (error) {
|
|
6997
|
-
|
|
7948
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
6998
7949
|
process.exit(1);
|
|
6999
7950
|
}
|
|
7000
7951
|
});
|
|
@@ -7002,20 +7953,20 @@ projectCmd.command("list").description("List all projects").action(() => {
|
|
|
7002
7953
|
try {
|
|
7003
7954
|
const projects = listProjects();
|
|
7004
7955
|
if (projects.length === 0) {
|
|
7005
|
-
|
|
7956
|
+
log(chalk5.dim("No projects found."));
|
|
7006
7957
|
return;
|
|
7007
7958
|
}
|
|
7008
|
-
|
|
7009
|
-
|
|
7010
|
-
|
|
7011
|
-
|
|
7012
|
-
|
|
7959
|
+
log("");
|
|
7960
|
+
log(chalk5.bold(" Projects"));
|
|
7961
|
+
log("");
|
|
7962
|
+
log(` ${"ID".padEnd(38)} ${"Name".padEnd(24)} ${"Path".padEnd(30)} Created`);
|
|
7963
|
+
log(` ${"\u2500".repeat(38)} ${"\u2500".repeat(24)} ${"\u2500".repeat(30)} ${"\u2500".repeat(20)}`);
|
|
7013
7964
|
for (const p of projects) {
|
|
7014
|
-
|
|
7965
|
+
log(` ${p.id.padEnd(38)} ${p.name.padEnd(24)} ${(p.path ?? chalk5.dim("\u2014")).toString().padEnd(30)} ${p.createdAt}`);
|
|
7015
7966
|
}
|
|
7016
|
-
|
|
7967
|
+
log("");
|
|
7017
7968
|
} catch (error) {
|
|
7018
|
-
|
|
7969
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7019
7970
|
process.exit(1);
|
|
7020
7971
|
}
|
|
7021
7972
|
});
|
|
@@ -7023,19 +7974,19 @@ projectCmd.command("show <id>").description("Show project details").action((id)
|
|
|
7023
7974
|
try {
|
|
7024
7975
|
const project = getProject(id);
|
|
7025
7976
|
if (!project) {
|
|
7026
|
-
|
|
7977
|
+
logError(chalk5.red(`Project not found: ${id}`));
|
|
7027
7978
|
process.exit(1);
|
|
7028
7979
|
}
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7980
|
+
log("");
|
|
7981
|
+
log(chalk5.bold(` Project: ${project.name}`));
|
|
7982
|
+
log(` ID: ${project.id}`);
|
|
7983
|
+
log(` Path: ${project.path ?? chalk5.dim("none")}`);
|
|
7984
|
+
log(` Description: ${project.description ?? chalk5.dim("none")}`);
|
|
7985
|
+
log(` Created: ${project.createdAt}`);
|
|
7986
|
+
log(` Updated: ${project.updatedAt}`);
|
|
7987
|
+
log("");
|
|
7037
7988
|
} catch (error) {
|
|
7038
|
-
|
|
7989
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7039
7990
|
process.exit(1);
|
|
7040
7991
|
}
|
|
7041
7992
|
});
|
|
@@ -7053,9 +8004,9 @@ projectCmd.command("use <name>").description("Set active project (find or create
|
|
|
7053
8004
|
}
|
|
7054
8005
|
config.activeProject = project.id;
|
|
7055
8006
|
writeFileSync3(CONFIG_PATH2, JSON.stringify(config, null, 2), "utf-8");
|
|
7056
|
-
|
|
8007
|
+
log(chalk5.green(`Active project set to ${chalk5.bold(project.name)} (${project.id})`));
|
|
7057
8008
|
} catch (error) {
|
|
7058
|
-
|
|
8009
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7059
8010
|
process.exit(1);
|
|
7060
8011
|
}
|
|
7061
8012
|
});
|
|
@@ -7080,40 +8031,44 @@ scheduleCmd.command("create <name>").description("Create a new schedule").requir
|
|
|
7080
8031
|
timeoutMs: opts.timeout ? parseInt(opts.timeout, 10) : undefined,
|
|
7081
8032
|
projectId
|
|
7082
8033
|
});
|
|
7083
|
-
|
|
8034
|
+
log(chalk5.green(`Created schedule ${chalk5.bold(schedule.name)} (${schedule.id})`));
|
|
7084
8035
|
if (schedule.nextRunAt) {
|
|
7085
|
-
|
|
8036
|
+
log(chalk5.dim(` Next run at: ${schedule.nextRunAt}`));
|
|
7086
8037
|
}
|
|
7087
8038
|
} catch (error) {
|
|
7088
|
-
|
|
8039
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7089
8040
|
process.exit(1);
|
|
7090
8041
|
}
|
|
7091
8042
|
});
|
|
7092
|
-
scheduleCmd.command("list").description("List schedules").option("--project <id>", "Filter by project ID").option("--enabled", "Show only enabled schedules").action((opts) => {
|
|
8043
|
+
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) => {
|
|
7093
8044
|
try {
|
|
7094
8045
|
const projectId = resolveProject(opts.project);
|
|
7095
8046
|
const schedules = listSchedules({
|
|
7096
8047
|
projectId,
|
|
7097
8048
|
enabled: opts.enabled ? true : undefined
|
|
7098
8049
|
});
|
|
8050
|
+
if (opts.json) {
|
|
8051
|
+
log(JSON.stringify(schedules, null, 2));
|
|
8052
|
+
return;
|
|
8053
|
+
}
|
|
7099
8054
|
if (schedules.length === 0) {
|
|
7100
|
-
|
|
8055
|
+
log(chalk5.dim("No schedules found."));
|
|
7101
8056
|
return;
|
|
7102
8057
|
}
|
|
7103
|
-
|
|
7104
|
-
|
|
7105
|
-
|
|
7106
|
-
|
|
7107
|
-
|
|
8058
|
+
log("");
|
|
8059
|
+
log(chalk5.bold(" Schedules"));
|
|
8060
|
+
log("");
|
|
8061
|
+
log(` ${"Name".padEnd(20)} ${"Cron".padEnd(18)} ${"URL".padEnd(30)} ${"Enabled".padEnd(9)} ${"Next Run".padEnd(22)} Last Run`);
|
|
8062
|
+
log(` ${"\u2500".repeat(20)} ${"\u2500".repeat(18)} ${"\u2500".repeat(30)} ${"\u2500".repeat(9)} ${"\u2500".repeat(22)} ${"\u2500".repeat(22)}`);
|
|
7108
8063
|
for (const s of schedules) {
|
|
7109
8064
|
const enabled = s.enabled ? chalk5.green("yes") : chalk5.red("no");
|
|
7110
8065
|
const nextRun = s.nextRunAt ?? chalk5.dim("\u2014");
|
|
7111
8066
|
const lastRun = s.lastRunAt ?? chalk5.dim("\u2014");
|
|
7112
|
-
|
|
8067
|
+
log(` ${s.name.padEnd(20)} ${s.cronExpression.padEnd(18)} ${s.url.padEnd(30)} ${enabled.toString().padEnd(9)} ${nextRun.toString().padEnd(22)} ${lastRun}`);
|
|
7113
8068
|
}
|
|
7114
|
-
|
|
8069
|
+
log("");
|
|
7115
8070
|
} catch (error) {
|
|
7116
|
-
|
|
8071
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7117
8072
|
process.exit(1);
|
|
7118
8073
|
}
|
|
7119
8074
|
});
|
|
@@ -7121,47 +8076,47 @@ scheduleCmd.command("show <id>").description("Show schedule details").action((id
|
|
|
7121
8076
|
try {
|
|
7122
8077
|
const schedule = getSchedule(id);
|
|
7123
8078
|
if (!schedule) {
|
|
7124
|
-
|
|
8079
|
+
logError(chalk5.red(`Schedule not found: ${id}`));
|
|
7125
8080
|
process.exit(1);
|
|
7126
8081
|
}
|
|
7127
|
-
|
|
7128
|
-
|
|
7129
|
-
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
|
|
7134
|
-
|
|
7135
|
-
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
7143
|
-
|
|
7144
|
-
|
|
8082
|
+
log("");
|
|
8083
|
+
log(chalk5.bold(` Schedule: ${schedule.name}`));
|
|
8084
|
+
log(` ID: ${schedule.id}`);
|
|
8085
|
+
log(` Cron: ${schedule.cronExpression}`);
|
|
8086
|
+
log(` URL: ${schedule.url}`);
|
|
8087
|
+
log(` Enabled: ${schedule.enabled ? chalk5.green("yes") : chalk5.red("no")}`);
|
|
8088
|
+
log(` Model: ${schedule.model ?? chalk5.dim("default")}`);
|
|
8089
|
+
log(` Headed: ${schedule.headed ? "yes" : "no"}`);
|
|
8090
|
+
log(` Parallel: ${schedule.parallel}`);
|
|
8091
|
+
log(` Timeout: ${schedule.timeoutMs ? `${schedule.timeoutMs}ms` : chalk5.dim("default")}`);
|
|
8092
|
+
log(` Project: ${schedule.projectId ?? chalk5.dim("none")}`);
|
|
8093
|
+
log(` Filter: ${JSON.stringify(schedule.scenarioFilter)}`);
|
|
8094
|
+
log(` Next run: ${schedule.nextRunAt ?? chalk5.dim("not scheduled")}`);
|
|
8095
|
+
log(` Last run: ${schedule.lastRunAt ?? chalk5.dim("never")}`);
|
|
8096
|
+
log(` Last run ID: ${schedule.lastRunId ?? chalk5.dim("none")}`);
|
|
8097
|
+
log(` Created: ${schedule.createdAt}`);
|
|
8098
|
+
log(` Updated: ${schedule.updatedAt}`);
|
|
8099
|
+
log("");
|
|
7145
8100
|
} catch (error) {
|
|
7146
|
-
|
|
8101
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7147
8102
|
process.exit(1);
|
|
7148
8103
|
}
|
|
7149
8104
|
});
|
|
7150
8105
|
scheduleCmd.command("enable <id>").description("Enable a schedule").action((id) => {
|
|
7151
8106
|
try {
|
|
7152
8107
|
const schedule = updateSchedule(id, { enabled: true });
|
|
7153
|
-
|
|
8108
|
+
log(chalk5.green(`Enabled schedule ${chalk5.bold(schedule.name)}`));
|
|
7154
8109
|
} catch (error) {
|
|
7155
|
-
|
|
8110
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7156
8111
|
process.exit(1);
|
|
7157
8112
|
}
|
|
7158
8113
|
});
|
|
7159
8114
|
scheduleCmd.command("disable <id>").description("Disable a schedule").action((id) => {
|
|
7160
8115
|
try {
|
|
7161
8116
|
const schedule = updateSchedule(id, { enabled: false });
|
|
7162
|
-
|
|
8117
|
+
log(chalk5.green(`Disabled schedule ${chalk5.bold(schedule.name)}`));
|
|
7163
8118
|
} catch (error) {
|
|
7164
|
-
|
|
8119
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7165
8120
|
process.exit(1);
|
|
7166
8121
|
}
|
|
7167
8122
|
});
|
|
@@ -7169,13 +8124,13 @@ scheduleCmd.command("delete <id>").description("Delete a schedule").action((id)
|
|
|
7169
8124
|
try {
|
|
7170
8125
|
const deleted = deleteSchedule(id);
|
|
7171
8126
|
if (deleted) {
|
|
7172
|
-
|
|
8127
|
+
log(chalk5.green(`Deleted schedule: ${id}`));
|
|
7173
8128
|
} else {
|
|
7174
|
-
|
|
8129
|
+
logError(chalk5.red(`Schedule not found: ${id}`));
|
|
7175
8130
|
process.exit(1);
|
|
7176
8131
|
}
|
|
7177
8132
|
} catch (error) {
|
|
7178
|
-
|
|
8133
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7179
8134
|
process.exit(1);
|
|
7180
8135
|
}
|
|
7181
8136
|
});
|
|
@@ -7183,11 +8138,11 @@ scheduleCmd.command("run <id>").description("Manually trigger a schedule").optio
|
|
|
7183
8138
|
try {
|
|
7184
8139
|
const schedule = getSchedule(id);
|
|
7185
8140
|
if (!schedule) {
|
|
7186
|
-
|
|
8141
|
+
logError(chalk5.red(`Schedule not found: ${id}`));
|
|
7187
8142
|
process.exit(1);
|
|
7188
8143
|
return;
|
|
7189
8144
|
}
|
|
7190
|
-
|
|
8145
|
+
log(chalk5.blue(`Running schedule ${chalk5.bold(schedule.name)} against ${schedule.url}...`));
|
|
7191
8146
|
const { run, results } = await runByFilter({
|
|
7192
8147
|
url: schedule.url,
|
|
7193
8148
|
tags: schedule.scenarioFilter.tags,
|
|
@@ -7200,21 +8155,21 @@ scheduleCmd.command("run <id>").description("Manually trigger a schedule").optio
|
|
|
7200
8155
|
projectId: schedule.projectId ?? undefined
|
|
7201
8156
|
});
|
|
7202
8157
|
if (opts.json) {
|
|
7203
|
-
|
|
8158
|
+
log(formatJSON(run, results));
|
|
7204
8159
|
} else {
|
|
7205
|
-
|
|
8160
|
+
log(formatTerminal(run, results));
|
|
7206
8161
|
}
|
|
7207
8162
|
process.exit(getExitCode(run));
|
|
7208
8163
|
} catch (error) {
|
|
7209
|
-
|
|
8164
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7210
8165
|
process.exit(1);
|
|
7211
8166
|
}
|
|
7212
8167
|
});
|
|
7213
8168
|
program2.command("daemon").description("Start the scheduler daemon").option("--interval <seconds>", "Check interval in seconds", "60").action(async (opts) => {
|
|
7214
8169
|
try {
|
|
7215
8170
|
const intervalMs = parseInt(opts.interval, 10) * 1000;
|
|
7216
|
-
|
|
7217
|
-
|
|
8171
|
+
log(chalk5.blue("Scheduler daemon started. Press Ctrl+C to stop."));
|
|
8172
|
+
log(chalk5.dim(` Check interval: ${opts.interval}s`));
|
|
7218
8173
|
let running = true;
|
|
7219
8174
|
const checkAndRun = async () => {
|
|
7220
8175
|
while (running) {
|
|
@@ -7223,7 +8178,7 @@ program2.command("daemon").description("Start the scheduler daemon").option("--i
|
|
|
7223
8178
|
const now2 = new Date().toISOString();
|
|
7224
8179
|
for (const schedule of schedules) {
|
|
7225
8180
|
if (schedule.nextRunAt && schedule.nextRunAt <= now2) {
|
|
7226
|
-
|
|
8181
|
+
log(chalk5.blue(`[${new Date().toISOString()}] Triggering schedule: ${schedule.name}`));
|
|
7227
8182
|
try {
|
|
7228
8183
|
const { run } = await runByFilter({
|
|
7229
8184
|
url: schedule.url,
|
|
@@ -7237,60 +8192,60 @@ program2.command("daemon").description("Start the scheduler daemon").option("--i
|
|
|
7237
8192
|
projectId: schedule.projectId ?? undefined
|
|
7238
8193
|
});
|
|
7239
8194
|
const statusColor = run.status === "passed" ? chalk5.green : chalk5.red;
|
|
7240
|
-
|
|
8195
|
+
log(` ${statusColor(run.status)} \u2014 ${run.passed}/${run.total} passed`);
|
|
7241
8196
|
updateSchedule(schedule.id, {});
|
|
7242
8197
|
} catch (err) {
|
|
7243
|
-
|
|
8198
|
+
logError(chalk5.red(` Error running schedule ${schedule.name}: ${err instanceof Error ? err.message : String(err)}`));
|
|
7244
8199
|
}
|
|
7245
8200
|
}
|
|
7246
8201
|
}
|
|
7247
8202
|
} catch (err) {
|
|
7248
|
-
|
|
8203
|
+
logError(chalk5.red(`Daemon error: ${err instanceof Error ? err.message : String(err)}`));
|
|
7249
8204
|
}
|
|
7250
8205
|
await new Promise((resolve2) => setTimeout(resolve2, intervalMs));
|
|
7251
8206
|
}
|
|
7252
8207
|
};
|
|
7253
8208
|
process.on("SIGINT", () => {
|
|
7254
|
-
|
|
8209
|
+
log(chalk5.yellow(`
|
|
7255
8210
|
Shutting down scheduler daemon...`));
|
|
7256
8211
|
running = false;
|
|
7257
8212
|
process.exit(0);
|
|
7258
8213
|
});
|
|
7259
8214
|
process.on("SIGTERM", () => {
|
|
7260
|
-
|
|
8215
|
+
log(chalk5.yellow(`
|
|
7261
8216
|
Shutting down scheduler daemon...`));
|
|
7262
8217
|
running = false;
|
|
7263
8218
|
process.exit(0);
|
|
7264
8219
|
});
|
|
7265
8220
|
await checkAndRun();
|
|
7266
8221
|
} catch (error) {
|
|
7267
|
-
|
|
8222
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7268
8223
|
process.exit(1);
|
|
7269
8224
|
}
|
|
7270
8225
|
});
|
|
7271
|
-
program2.command("init").description("Initialize a new testing project").option("-n, --name <name>", "Project name").option("-u, --url <url>", "Base URL").option("-p, --path <path>", "Project path").option("--ci <provider>", "Generate CI workflow (github)").action((opts) => {
|
|
8226
|
+
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) => {
|
|
7272
8227
|
try {
|
|
7273
|
-
const { project, scenarios, framework } = initProject({
|
|
8228
|
+
const { project, scenarios, framework, url } = initProject({
|
|
7274
8229
|
name: opts.name,
|
|
7275
8230
|
url: opts.url,
|
|
7276
8231
|
path: opts.path
|
|
7277
8232
|
});
|
|
7278
|
-
|
|
7279
|
-
|
|
7280
|
-
|
|
8233
|
+
log("");
|
|
8234
|
+
log(chalk5.bold(" Project initialized!"));
|
|
8235
|
+
log("");
|
|
7281
8236
|
if (framework) {
|
|
7282
|
-
|
|
8237
|
+
log(` Framework: ${chalk5.cyan(framework.name)}`);
|
|
7283
8238
|
if (framework.features.length > 0) {
|
|
7284
|
-
|
|
8239
|
+
log(` Features: ${chalk5.dim(framework.features.join(", "))}`);
|
|
7285
8240
|
}
|
|
7286
8241
|
} else {
|
|
7287
|
-
|
|
8242
|
+
log(` Framework: ${chalk5.dim("not detected")}`);
|
|
7288
8243
|
}
|
|
7289
|
-
|
|
7290
|
-
|
|
7291
|
-
|
|
8244
|
+
log(` Project: ${chalk5.green(project.name)} ${chalk5.dim(`(${project.id})`)}`);
|
|
8245
|
+
log(` Scenarios: ${chalk5.green(String(scenarios.length))} starter scenarios created`);
|
|
8246
|
+
log("");
|
|
7292
8247
|
for (const s of scenarios) {
|
|
7293
|
-
|
|
8248
|
+
log(` ${chalk5.dim(s.shortId)} ${s.name} ${chalk5.dim(`[${s.tags.join(", ")}]`)}`);
|
|
7294
8249
|
}
|
|
7295
8250
|
if (opts.ci === "github") {
|
|
7296
8251
|
const workflowDir = join6(process.cwd(), ".github", "workflows");
|
|
@@ -7299,18 +8254,51 @@ program2.command("init").description("Initialize a new testing project").option(
|
|
|
7299
8254
|
}
|
|
7300
8255
|
const workflowPath = join6(workflowDir, "testers.yml");
|
|
7301
8256
|
writeFileSync3(workflowPath, generateGitHubActionsWorkflow(), "utf-8");
|
|
7302
|
-
|
|
8257
|
+
log(` CI: ${chalk5.green("GitHub Actions workflow written to .github/workflows/testers.yml")}`);
|
|
7303
8258
|
} else if (opts.ci) {
|
|
7304
|
-
|
|
7305
|
-
}
|
|
7306
|
-
|
|
7307
|
-
|
|
7308
|
-
|
|
7309
|
-
|
|
7310
|
-
|
|
7311
|
-
|
|
8259
|
+
log(chalk5.yellow(` Unknown CI provider: ${opts.ci}. Supported: github`));
|
|
8260
|
+
}
|
|
8261
|
+
log("");
|
|
8262
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
8263
|
+
const ask = (q) => new Promise((resolve2) => rl.question(q, resolve2));
|
|
8264
|
+
try {
|
|
8265
|
+
const envAnswer = await ask(" Would you like to configure environments? [y/N] ");
|
|
8266
|
+
if (envAnswer.trim().toLowerCase() === "y") {
|
|
8267
|
+
const envName = await ask(" Environment name (default: staging): ");
|
|
8268
|
+
const envUrl = await ask(` Base URL (default: ${url}): `);
|
|
8269
|
+
const resolvedEnvName = envName.trim() || "staging";
|
|
8270
|
+
const resolvedEnvUrl = envUrl.trim() || url;
|
|
8271
|
+
createEnvironment({ name: resolvedEnvName, url: resolvedEnvUrl, projectId: project.id, isDefault: true });
|
|
8272
|
+
log(chalk5.green(` \u2713 Environment '${resolvedEnvName}' created (${resolvedEnvUrl})`));
|
|
8273
|
+
log("");
|
|
8274
|
+
}
|
|
8275
|
+
const scenarioAnswer = await ask(" Would you like to create your first test scenario? [y/N] ");
|
|
8276
|
+
if (scenarioAnswer.trim().toLowerCase() === "y") {
|
|
8277
|
+
const scenarioName = await ask(" Scenario name: ");
|
|
8278
|
+
const scenarioUrl = await ask(` URL to test (default: ${url}): `);
|
|
8279
|
+
const resolvedScenarioName = scenarioName.trim() || "My first scenario";
|
|
8280
|
+
const resolvedScenarioUrl = scenarioUrl.trim() || url;
|
|
8281
|
+
const newScenario = createScenario({
|
|
8282
|
+
name: resolvedScenarioName,
|
|
8283
|
+
description: `Navigate to ${resolvedScenarioUrl} and verify it loads correctly.`,
|
|
8284
|
+
projectId: project.id,
|
|
8285
|
+
targetPath: resolvedScenarioUrl,
|
|
8286
|
+
tags: ["smoke"],
|
|
8287
|
+
priority: "high"
|
|
8288
|
+
});
|
|
8289
|
+
log(chalk5.green(` \u2713 Scenario '${newScenario.name}' created ${chalk5.dim(`(${newScenario.shortId})`)}`));
|
|
8290
|
+
log("");
|
|
8291
|
+
}
|
|
8292
|
+
} finally {
|
|
8293
|
+
rl.close();
|
|
8294
|
+
}
|
|
8295
|
+
log(chalk5.bold(" Next steps:"));
|
|
8296
|
+
log(` 1. Start your dev server`);
|
|
8297
|
+
log(` 2. Run ${chalk5.cyan("testers run <url>")} to execute tests`);
|
|
8298
|
+
log(` 3. Add more scenarios with ${chalk5.cyan("testers add <name>")}`);
|
|
8299
|
+
log("");
|
|
7312
8300
|
} catch (error) {
|
|
7313
|
-
|
|
8301
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7314
8302
|
process.exit(1);
|
|
7315
8303
|
}
|
|
7316
8304
|
});
|
|
@@ -7318,16 +8306,16 @@ program2.command("replay <run-id>").description("Re-run all scenarios from a pre
|
|
|
7318
8306
|
try {
|
|
7319
8307
|
const originalRun = getRun(runId);
|
|
7320
8308
|
if (!originalRun) {
|
|
7321
|
-
|
|
8309
|
+
logError(chalk5.red(`Run not found: ${runId}`));
|
|
7322
8310
|
process.exit(1);
|
|
7323
8311
|
}
|
|
7324
8312
|
const originalResults = getResultsByRun(originalRun.id);
|
|
7325
8313
|
const scenarioIds = originalResults.map((r) => r.scenarioId);
|
|
7326
8314
|
if (scenarioIds.length === 0) {
|
|
7327
|
-
|
|
8315
|
+
log(chalk5.dim("No scenarios to replay."));
|
|
7328
8316
|
return;
|
|
7329
8317
|
}
|
|
7330
|
-
|
|
8318
|
+
log(chalk5.blue(`Replaying ${scenarioIds.length} scenarios from run ${originalRun.id.slice(0, 8)}...`));
|
|
7331
8319
|
const { run, results } = await runByFilter({
|
|
7332
8320
|
url: opts.url ?? originalRun.url,
|
|
7333
8321
|
scenarioIds,
|
|
@@ -7336,13 +8324,13 @@ program2.command("replay <run-id>").description("Re-run all scenarios from a pre
|
|
|
7336
8324
|
parallel: parseInt(opts.parallel, 10)
|
|
7337
8325
|
});
|
|
7338
8326
|
if (opts.json) {
|
|
7339
|
-
|
|
8327
|
+
log(formatJSON(run, results));
|
|
7340
8328
|
} else {
|
|
7341
|
-
|
|
8329
|
+
log(formatTerminal(run, results));
|
|
7342
8330
|
}
|
|
7343
8331
|
process.exit(getExitCode(run));
|
|
7344
8332
|
} catch (error) {
|
|
7345
|
-
|
|
8333
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7346
8334
|
process.exit(1);
|
|
7347
8335
|
}
|
|
7348
8336
|
});
|
|
@@ -7350,16 +8338,16 @@ program2.command("retry <run-id>").description("Re-run only failed scenarios fro
|
|
|
7350
8338
|
try {
|
|
7351
8339
|
const originalRun = getRun(runId);
|
|
7352
8340
|
if (!originalRun) {
|
|
7353
|
-
|
|
8341
|
+
logError(chalk5.red(`Run not found: ${runId}`));
|
|
7354
8342
|
process.exit(1);
|
|
7355
8343
|
}
|
|
7356
8344
|
const originalResults = getResultsByRun(originalRun.id);
|
|
7357
8345
|
const failedScenarioIds = originalResults.filter((r) => r.status === "failed" || r.status === "error").map((r) => r.scenarioId);
|
|
7358
8346
|
if (failedScenarioIds.length === 0) {
|
|
7359
|
-
|
|
8347
|
+
log(chalk5.green("No failed scenarios to retry. All passed!"));
|
|
7360
8348
|
return;
|
|
7361
8349
|
}
|
|
7362
|
-
|
|
8350
|
+
log(chalk5.blue(`Retrying ${failedScenarioIds.length} failed scenarios from run ${originalRun.id.slice(0, 8)}...`));
|
|
7363
8351
|
const { run, results } = await runByFilter({
|
|
7364
8352
|
url: opts.url ?? originalRun.url,
|
|
7365
8353
|
scenarioIds: failedScenarioIds,
|
|
@@ -7368,35 +8356,35 @@ program2.command("retry <run-id>").description("Re-run only failed scenarios fro
|
|
|
7368
8356
|
parallel: parseInt(opts.parallel, 10)
|
|
7369
8357
|
});
|
|
7370
8358
|
if (!opts.json) {
|
|
7371
|
-
|
|
7372
|
-
|
|
8359
|
+
log("");
|
|
8360
|
+
log(chalk5.bold(" Comparison with original run:"));
|
|
7373
8361
|
for (const result of results) {
|
|
7374
8362
|
const original = originalResults.find((r) => r.scenarioId === result.scenarioId);
|
|
7375
8363
|
if (original) {
|
|
7376
8364
|
const changed = original.status !== result.status;
|
|
7377
8365
|
const arrow = changed ? chalk5.yellow(`${original.status} \u2192 ${result.status}`) : chalk5.dim(`${result.status} (unchanged)`);
|
|
7378
8366
|
const icon = result.status === "passed" ? chalk5.green("\u2713") : chalk5.red("\u2717");
|
|
7379
|
-
|
|
8367
|
+
log(` ${icon} ${result.scenarioId.slice(0, 8)}: ${arrow}`);
|
|
7380
8368
|
}
|
|
7381
8369
|
}
|
|
7382
|
-
|
|
8370
|
+
log("");
|
|
7383
8371
|
}
|
|
7384
8372
|
if (opts.json) {
|
|
7385
|
-
|
|
8373
|
+
log(formatJSON(run, results));
|
|
7386
8374
|
} else {
|
|
7387
|
-
|
|
8375
|
+
log(formatTerminal(run, results));
|
|
7388
8376
|
}
|
|
7389
8377
|
process.exit(getExitCode(run));
|
|
7390
8378
|
} catch (error) {
|
|
7391
|
-
|
|
8379
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7392
8380
|
process.exit(1);
|
|
7393
8381
|
}
|
|
7394
8382
|
});
|
|
7395
8383
|
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) => {
|
|
7396
8384
|
try {
|
|
7397
8385
|
const projectId = resolveProject(opts.project);
|
|
7398
|
-
|
|
7399
|
-
|
|
8386
|
+
log(chalk5.blue(`Running smoke test against ${chalk5.bold(url)}...`));
|
|
8387
|
+
log("");
|
|
7400
8388
|
const smokeResult = await runSmoke({
|
|
7401
8389
|
url,
|
|
7402
8390
|
model: opts.model,
|
|
@@ -7405,19 +8393,19 @@ program2.command("smoke <url>").description("Run autonomous smoke test").option(
|
|
|
7405
8393
|
projectId
|
|
7406
8394
|
});
|
|
7407
8395
|
if (opts.json) {
|
|
7408
|
-
|
|
8396
|
+
log(JSON.stringify({
|
|
7409
8397
|
run: smokeResult.run,
|
|
7410
8398
|
result: smokeResult.result,
|
|
7411
8399
|
pagesVisited: smokeResult.pagesVisited,
|
|
7412
8400
|
issues: smokeResult.issuesFound
|
|
7413
8401
|
}, null, 2));
|
|
7414
8402
|
} else {
|
|
7415
|
-
|
|
8403
|
+
log(formatSmokeReport(smokeResult));
|
|
7416
8404
|
}
|
|
7417
8405
|
const hasCritical = smokeResult.issuesFound.some((i) => i.severity === "critical" || i.severity === "high");
|
|
7418
8406
|
process.exit(hasCritical ? 1 : 0);
|
|
7419
8407
|
} catch (error) {
|
|
7420
|
-
|
|
8408
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7421
8409
|
process.exit(1);
|
|
7422
8410
|
}
|
|
7423
8411
|
});
|
|
@@ -7425,27 +8413,27 @@ program2.command("diff <run1> <run2>").description("Compare two test runs").opti
|
|
|
7425
8413
|
try {
|
|
7426
8414
|
const diff = diffRuns(run1, run2);
|
|
7427
8415
|
if (opts.json) {
|
|
7428
|
-
|
|
8416
|
+
log(formatDiffJSON(diff));
|
|
7429
8417
|
} else {
|
|
7430
|
-
|
|
8418
|
+
log(formatDiffTerminal(diff));
|
|
7431
8419
|
}
|
|
7432
8420
|
const threshold = parseFloat(opts.threshold);
|
|
7433
8421
|
const visualResults = compareRunScreenshots(run2, run1, threshold);
|
|
7434
8422
|
if (visualResults.length > 0) {
|
|
7435
8423
|
if (opts.json) {
|
|
7436
|
-
|
|
8424
|
+
log(JSON.stringify({ visualDiff: visualResults }, null, 2));
|
|
7437
8425
|
} else {
|
|
7438
|
-
|
|
8426
|
+
log(formatVisualDiffTerminal(visualResults, threshold));
|
|
7439
8427
|
}
|
|
7440
8428
|
}
|
|
7441
8429
|
const hasVisualRegressions = visualResults.some((r) => r.isRegression);
|
|
7442
8430
|
process.exit(diff.regressions.length > 0 || hasVisualRegressions ? 1 : 0);
|
|
7443
8431
|
} catch (error) {
|
|
7444
|
-
|
|
8432
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7445
8433
|
process.exit(1);
|
|
7446
8434
|
}
|
|
7447
8435
|
});
|
|
7448
|
-
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) => {
|
|
8436
|
+
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) => {
|
|
7449
8437
|
try {
|
|
7450
8438
|
let html;
|
|
7451
8439
|
if (opts.latest || !runId) {
|
|
@@ -7454,9 +8442,14 @@ program2.command("report [run-id]").description("Generate HTML test report").opt
|
|
|
7454
8442
|
html = generateHtmlReport(runId);
|
|
7455
8443
|
}
|
|
7456
8444
|
writeFileSync3(opts.output, html, "utf-8");
|
|
7457
|
-
|
|
8445
|
+
const absPath = resolve(opts.output);
|
|
8446
|
+
log(chalk5.green(`Report generated: ${absPath}`));
|
|
8447
|
+
if (opts.open) {
|
|
8448
|
+
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
8449
|
+
Bun.spawn([openCmd, absPath]);
|
|
8450
|
+
}
|
|
7458
8451
|
} catch (error) {
|
|
7459
|
-
|
|
8452
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7460
8453
|
process.exit(1);
|
|
7461
8454
|
}
|
|
7462
8455
|
});
|
|
@@ -7469,9 +8462,9 @@ authCmd.command("add <name>").description("Create an auth preset").requiredOptio
|
|
|
7469
8462
|
password: opts.password,
|
|
7470
8463
|
loginPath: opts.loginPath
|
|
7471
8464
|
});
|
|
7472
|
-
|
|
8465
|
+
log(chalk5.green(`Created auth preset ${chalk5.bold(preset.name)} (${preset.email})`));
|
|
7473
8466
|
} catch (error) {
|
|
7474
|
-
|
|
8467
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7475
8468
|
process.exit(1);
|
|
7476
8469
|
}
|
|
7477
8470
|
});
|
|
@@ -7479,20 +8472,20 @@ authCmd.command("list").description("List auth presets").action(() => {
|
|
|
7479
8472
|
try {
|
|
7480
8473
|
const presets = listAuthPresets();
|
|
7481
8474
|
if (presets.length === 0) {
|
|
7482
|
-
|
|
8475
|
+
log(chalk5.dim("No auth presets found."));
|
|
7483
8476
|
return;
|
|
7484
8477
|
}
|
|
7485
|
-
|
|
7486
|
-
|
|
7487
|
-
|
|
7488
|
-
|
|
7489
|
-
|
|
8478
|
+
log("");
|
|
8479
|
+
log(chalk5.bold(" Auth Presets"));
|
|
8480
|
+
log("");
|
|
8481
|
+
log(` ${"Name".padEnd(20)} ${"Email".padEnd(30)} ${"Login Path".padEnd(15)} Created`);
|
|
8482
|
+
log(` ${"\u2500".repeat(20)} ${"\u2500".repeat(30)} ${"\u2500".repeat(15)} ${"\u2500".repeat(22)}`);
|
|
7490
8483
|
for (const p of presets) {
|
|
7491
|
-
|
|
8484
|
+
log(` ${p.name.padEnd(20)} ${p.email.padEnd(30)} ${p.loginPath.padEnd(15)} ${p.createdAt}`);
|
|
7492
8485
|
}
|
|
7493
|
-
|
|
8486
|
+
log("");
|
|
7494
8487
|
} catch (error) {
|
|
7495
|
-
|
|
8488
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7496
8489
|
process.exit(1);
|
|
7497
8490
|
}
|
|
7498
8491
|
});
|
|
@@ -7500,26 +8493,39 @@ authCmd.command("delete <name>").description("Delete an auth preset").action((na
|
|
|
7500
8493
|
try {
|
|
7501
8494
|
const deleted = deleteAuthPreset(name);
|
|
7502
8495
|
if (deleted) {
|
|
7503
|
-
|
|
8496
|
+
log(chalk5.green(`Deleted auth preset: ${name}`));
|
|
7504
8497
|
} else {
|
|
7505
|
-
|
|
8498
|
+
logError(chalk5.red(`Auth preset not found: ${name}`));
|
|
7506
8499
|
process.exit(1);
|
|
7507
8500
|
}
|
|
7508
8501
|
} catch (error) {
|
|
7509
|
-
|
|
8502
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7510
8503
|
process.exit(1);
|
|
7511
8504
|
}
|
|
7512
8505
|
});
|
|
7513
|
-
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) => {
|
|
8506
|
+
program2.command("costs").description("Show cost tracking and budget status").option("--project <id>", "Project ID").option("--period <period>", "Time period: day, week, month, all (default: month)", "month").option("--by-scenario", "Group cost breakdown by scenario, sorted by total cost", false).option("--json", "JSON output", false).option("--csv", "CSV output", false).action((opts) => {
|
|
7514
8507
|
try {
|
|
7515
|
-
const
|
|
7516
|
-
|
|
7517
|
-
|
|
8508
|
+
const projectId = resolveProject(opts.project);
|
|
8509
|
+
const period = opts.period;
|
|
8510
|
+
if (opts.byScenario) {
|
|
8511
|
+
const rows = getCostsByScenario({ projectId, period });
|
|
8512
|
+
if (opts.json) {
|
|
8513
|
+
log(JSON.stringify(rows, null, 2));
|
|
8514
|
+
} else {
|
|
8515
|
+
log(formatCostsByScenarioTerminal(rows, period));
|
|
8516
|
+
}
|
|
8517
|
+
return;
|
|
8518
|
+
}
|
|
8519
|
+
const summary = getCostSummary({ projectId, period });
|
|
8520
|
+
if (opts.csv) {
|
|
8521
|
+
log(formatCostsCsv(summary));
|
|
8522
|
+
} else if (opts.json) {
|
|
8523
|
+
log(formatCostsJSON(summary));
|
|
7518
8524
|
} else {
|
|
7519
|
-
|
|
8525
|
+
log(formatCostsTerminal(summary));
|
|
7520
8526
|
}
|
|
7521
8527
|
} catch (error) {
|
|
7522
|
-
|
|
8528
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7523
8529
|
process.exit(1);
|
|
7524
8530
|
}
|
|
7525
8531
|
});
|
|
@@ -7527,18 +8533,18 @@ program2.command("chain <scenario-id>").description("Add a dependency to a scena
|
|
|
7527
8533
|
try {
|
|
7528
8534
|
const scenario = getScenario(scenarioId) ?? getScenarioByShortId(scenarioId);
|
|
7529
8535
|
if (!scenario) {
|
|
7530
|
-
|
|
8536
|
+
logError(chalk5.red(`Scenario not found: ${scenarioId}`));
|
|
7531
8537
|
process.exit(1);
|
|
7532
8538
|
}
|
|
7533
8539
|
const dep = getScenario(opts.dependsOn) ?? getScenarioByShortId(opts.dependsOn);
|
|
7534
8540
|
if (!dep) {
|
|
7535
|
-
|
|
8541
|
+
logError(chalk5.red(`Dependency scenario not found: ${opts.dependsOn}`));
|
|
7536
8542
|
process.exit(1);
|
|
7537
8543
|
}
|
|
7538
8544
|
addDependency(scenario.id, dep.id);
|
|
7539
|
-
|
|
8545
|
+
log(chalk5.green(`${scenario.shortId} now depends on ${dep.shortId}`));
|
|
7540
8546
|
} catch (error) {
|
|
7541
|
-
|
|
8547
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7542
8548
|
process.exit(1);
|
|
7543
8549
|
}
|
|
7544
8550
|
});
|
|
@@ -7546,18 +8552,18 @@ program2.command("unchain <scenario-id>").description("Remove a dependency from
|
|
|
7546
8552
|
try {
|
|
7547
8553
|
const scenario = getScenario(scenarioId) ?? getScenarioByShortId(scenarioId);
|
|
7548
8554
|
if (!scenario) {
|
|
7549
|
-
|
|
8555
|
+
logError(chalk5.red(`Scenario not found: ${scenarioId}`));
|
|
7550
8556
|
process.exit(1);
|
|
7551
8557
|
}
|
|
7552
8558
|
const dep = getScenario(opts.from) ?? getScenarioByShortId(opts.from);
|
|
7553
8559
|
if (!dep) {
|
|
7554
|
-
|
|
8560
|
+
logError(chalk5.red(`Dependency not found: ${opts.from}`));
|
|
7555
8561
|
process.exit(1);
|
|
7556
8562
|
}
|
|
7557
8563
|
removeDependency(scenario.id, dep.id);
|
|
7558
|
-
|
|
8564
|
+
log(chalk5.green(`Removed dependency: ${scenario.shortId} no longer depends on ${dep.shortId}`));
|
|
7559
8565
|
} catch (error) {
|
|
7560
|
-
|
|
8566
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7561
8567
|
process.exit(1);
|
|
7562
8568
|
}
|
|
7563
8569
|
});
|
|
@@ -7565,34 +8571,34 @@ program2.command("deps <scenario-id>").description("Show dependencies for a scen
|
|
|
7565
8571
|
try {
|
|
7566
8572
|
const scenario = getScenario(scenarioId) ?? getScenarioByShortId(scenarioId);
|
|
7567
8573
|
if (!scenario) {
|
|
7568
|
-
|
|
8574
|
+
logError(chalk5.red(`Scenario not found: ${scenarioId}`));
|
|
7569
8575
|
process.exit(1);
|
|
7570
8576
|
}
|
|
7571
8577
|
const deps = getDependencies(scenario.id);
|
|
7572
8578
|
const dependents = getDependents(scenario.id);
|
|
7573
|
-
|
|
7574
|
-
|
|
7575
|
-
|
|
8579
|
+
log("");
|
|
8580
|
+
log(chalk5.bold(` Dependencies for ${scenario.shortId}: ${scenario.name}`));
|
|
8581
|
+
log("");
|
|
7576
8582
|
if (deps.length > 0) {
|
|
7577
|
-
|
|
8583
|
+
log(chalk5.dim(" Depends on:"));
|
|
7578
8584
|
for (const depId of deps) {
|
|
7579
8585
|
const s = getScenario(depId);
|
|
7580
|
-
|
|
8586
|
+
log(` \u2192 ${s ? `${s.shortId}: ${s.name}` : depId.slice(0, 8)}`);
|
|
7581
8587
|
}
|
|
7582
8588
|
} else {
|
|
7583
|
-
|
|
8589
|
+
log(chalk5.dim(" No dependencies"));
|
|
7584
8590
|
}
|
|
7585
8591
|
if (dependents.length > 0) {
|
|
7586
|
-
|
|
7587
|
-
|
|
8592
|
+
log("");
|
|
8593
|
+
log(chalk5.dim(" Required by:"));
|
|
7588
8594
|
for (const depId of dependents) {
|
|
7589
8595
|
const s = getScenario(depId);
|
|
7590
|
-
|
|
8596
|
+
log(` \u2190 ${s ? `${s.shortId}: ${s.name}` : depId.slice(0, 8)}`);
|
|
7591
8597
|
}
|
|
7592
8598
|
}
|
|
7593
|
-
|
|
8599
|
+
log("");
|
|
7594
8600
|
} catch (error) {
|
|
7595
|
-
|
|
8601
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7596
8602
|
process.exit(1);
|
|
7597
8603
|
}
|
|
7598
8604
|
});
|
|
@@ -7602,7 +8608,7 @@ flowCmd.command("create <name>").description("Create a flow from scenario IDs").
|
|
|
7602
8608
|
const ids = opts.chain.split(",").map((id) => {
|
|
7603
8609
|
const s = getScenario(id.trim()) ?? getScenarioByShortId(id.trim());
|
|
7604
8610
|
if (!s) {
|
|
7605
|
-
|
|
8611
|
+
logError(chalk5.red(`Scenario not found: ${id.trim()}`));
|
|
7606
8612
|
process.exit(1);
|
|
7607
8613
|
}
|
|
7608
8614
|
return s.id;
|
|
@@ -7613,49 +8619,49 @@ flowCmd.command("create <name>").description("Create a flow from scenario IDs").
|
|
|
7613
8619
|
} catch {}
|
|
7614
8620
|
}
|
|
7615
8621
|
const flow = createFlow({ name, scenarioIds: ids, projectId: resolveProject(opts.project) });
|
|
7616
|
-
|
|
8622
|
+
log(chalk5.green(`Flow created: ${flow.id.slice(0, 8)} \u2014 ${flow.name} (${ids.length} scenarios)`));
|
|
7617
8623
|
} catch (error) {
|
|
7618
|
-
|
|
8624
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7619
8625
|
process.exit(1);
|
|
7620
8626
|
}
|
|
7621
8627
|
});
|
|
7622
8628
|
flowCmd.command("list").description("List all flows").option("--project <id>", "Project ID").action((opts) => {
|
|
7623
8629
|
const flows = listFlows(resolveProject(opts.project) ?? undefined);
|
|
7624
8630
|
if (flows.length === 0) {
|
|
7625
|
-
|
|
8631
|
+
log(chalk5.dim(`
|
|
7626
8632
|
No flows found.
|
|
7627
8633
|
`));
|
|
7628
8634
|
return;
|
|
7629
8635
|
}
|
|
7630
|
-
|
|
7631
|
-
|
|
7632
|
-
|
|
8636
|
+
log("");
|
|
8637
|
+
log(chalk5.bold(" Flows"));
|
|
8638
|
+
log("");
|
|
7633
8639
|
for (const f of flows) {
|
|
7634
|
-
|
|
8640
|
+
log(` ${chalk5.dim(f.id.slice(0, 8))} ${f.name} ${chalk5.dim(`(${f.scenarioIds.length} scenarios)`)}`);
|
|
7635
8641
|
}
|
|
7636
|
-
|
|
8642
|
+
log("");
|
|
7637
8643
|
});
|
|
7638
8644
|
flowCmd.command("show <id>").description("Show flow details").action((id) => {
|
|
7639
8645
|
const flow = getFlow(id);
|
|
7640
8646
|
if (!flow) {
|
|
7641
|
-
|
|
8647
|
+
logError(chalk5.red(`Flow not found: ${id}`));
|
|
7642
8648
|
process.exit(1);
|
|
7643
8649
|
}
|
|
7644
|
-
|
|
7645
|
-
|
|
7646
|
-
|
|
7647
|
-
|
|
8650
|
+
log("");
|
|
8651
|
+
log(chalk5.bold(` Flow: ${flow.name}`));
|
|
8652
|
+
log(` ID: ${chalk5.dim(flow.id)}`);
|
|
8653
|
+
log(` Scenarios (in order):`);
|
|
7648
8654
|
for (let i = 0;i < flow.scenarioIds.length; i++) {
|
|
7649
8655
|
const s = getScenario(flow.scenarioIds[i]);
|
|
7650
|
-
|
|
8656
|
+
log(` ${i + 1}. ${s ? `${s.shortId}: ${s.name}` : flow.scenarioIds[i].slice(0, 8)}`);
|
|
7651
8657
|
}
|
|
7652
|
-
|
|
8658
|
+
log("");
|
|
7653
8659
|
});
|
|
7654
8660
|
flowCmd.command("delete <id>").description("Delete a flow").action((id) => {
|
|
7655
8661
|
if (deleteFlow(id))
|
|
7656
|
-
|
|
8662
|
+
log(chalk5.green("Flow deleted."));
|
|
7657
8663
|
else {
|
|
7658
|
-
|
|
8664
|
+
logError(chalk5.red("Flow not found."));
|
|
7659
8665
|
process.exit(1);
|
|
7660
8666
|
}
|
|
7661
8667
|
});
|
|
@@ -7663,14 +8669,14 @@ flowCmd.command("run <id>").description("Run a flow (scenarios in dependency ord
|
|
|
7663
8669
|
try {
|
|
7664
8670
|
const flow = getFlow(id);
|
|
7665
8671
|
if (!flow) {
|
|
7666
|
-
|
|
8672
|
+
logError(chalk5.red(`Flow not found: ${id}`));
|
|
7667
8673
|
process.exit(1);
|
|
7668
8674
|
}
|
|
7669
8675
|
if (!opts.url) {
|
|
7670
|
-
|
|
8676
|
+
logError(chalk5.red("--url is required for flow run"));
|
|
7671
8677
|
process.exit(1);
|
|
7672
8678
|
}
|
|
7673
|
-
|
|
8679
|
+
log(chalk5.blue(`Running flow: ${flow.name} (${flow.scenarioIds.length} scenarios)`));
|
|
7674
8680
|
const { run, results } = await runByFilter({
|
|
7675
8681
|
url: opts.url,
|
|
7676
8682
|
scenarioIds: flow.scenarioIds,
|
|
@@ -7679,12 +8685,12 @@ flowCmd.command("run <id>").description("Run a flow (scenarios in dependency ord
|
|
|
7679
8685
|
parallel: 1
|
|
7680
8686
|
});
|
|
7681
8687
|
if (opts.json)
|
|
7682
|
-
|
|
8688
|
+
log(formatJSON(run, results));
|
|
7683
8689
|
else
|
|
7684
|
-
|
|
8690
|
+
log(formatTerminal(run, results));
|
|
7685
8691
|
process.exit(getExitCode(run));
|
|
7686
8692
|
} catch (error) {
|
|
7687
|
-
|
|
8693
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7688
8694
|
process.exit(1);
|
|
7689
8695
|
}
|
|
7690
8696
|
});
|
|
@@ -7698,9 +8704,9 @@ envCmd.command("add <name>").description("Add a named environment").requiredOpti
|
|
|
7698
8704
|
projectId: opts.project,
|
|
7699
8705
|
isDefault: opts.default
|
|
7700
8706
|
});
|
|
7701
|
-
|
|
8707
|
+
log(chalk5.green(`Environment added: ${env.name} \u2192 ${env.url}${env.isDefault ? " (default)" : ""}`));
|
|
7702
8708
|
} catch (error) {
|
|
7703
|
-
|
|
8709
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7704
8710
|
process.exit(1);
|
|
7705
8711
|
}
|
|
7706
8712
|
});
|
|
@@ -7708,16 +8714,16 @@ envCmd.command("list").description("List all environments").option("--project <i
|
|
|
7708
8714
|
try {
|
|
7709
8715
|
const envs = listEnvironments(opts.project);
|
|
7710
8716
|
if (envs.length === 0) {
|
|
7711
|
-
|
|
8717
|
+
log(chalk5.dim("No environments configured. Add one with: testers env add <name> --url <url>"));
|
|
7712
8718
|
return;
|
|
7713
8719
|
}
|
|
7714
8720
|
for (const env of envs) {
|
|
7715
8721
|
const marker = env.isDefault ? chalk5.green(" \u2605 default") : "";
|
|
7716
8722
|
const auth = env.authPresetName ? chalk5.dim(` (auth: ${env.authPresetName})`) : "";
|
|
7717
|
-
|
|
8723
|
+
log(` ${chalk5.bold(env.name)} ${env.url}${auth}${marker}`);
|
|
7718
8724
|
}
|
|
7719
8725
|
} catch (error) {
|
|
7720
|
-
|
|
8726
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7721
8727
|
process.exit(1);
|
|
7722
8728
|
}
|
|
7723
8729
|
});
|
|
@@ -7725,9 +8731,9 @@ envCmd.command("use <name>").description("Set an environment as the default").ac
|
|
|
7725
8731
|
try {
|
|
7726
8732
|
setDefaultEnvironment(name);
|
|
7727
8733
|
const env = getEnvironment(name);
|
|
7728
|
-
|
|
8734
|
+
log(chalk5.green(`Default environment set: ${env.name} \u2192 ${env.url}`));
|
|
7729
8735
|
} catch (error) {
|
|
7730
|
-
|
|
8736
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7731
8737
|
process.exit(1);
|
|
7732
8738
|
}
|
|
7733
8739
|
});
|
|
@@ -7735,13 +8741,13 @@ envCmd.command("delete <name>").description("Delete an environment").action((nam
|
|
|
7735
8741
|
try {
|
|
7736
8742
|
const deleted = deleteEnvironment(name);
|
|
7737
8743
|
if (deleted) {
|
|
7738
|
-
|
|
8744
|
+
log(chalk5.green(`Environment deleted: ${name}`));
|
|
7739
8745
|
} else {
|
|
7740
|
-
|
|
8746
|
+
logError(chalk5.red(`Environment not found: ${name}`));
|
|
7741
8747
|
process.exit(1);
|
|
7742
8748
|
}
|
|
7743
8749
|
} catch (error) {
|
|
7744
|
-
|
|
8750
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7745
8751
|
process.exit(1);
|
|
7746
8752
|
}
|
|
7747
8753
|
});
|
|
@@ -7749,9 +8755,9 @@ program2.command("baseline <run-id>").description("Set a run as the visual basel
|
|
|
7749
8755
|
try {
|
|
7750
8756
|
setBaseline(runId);
|
|
7751
8757
|
const run = getRun(runId);
|
|
7752
|
-
|
|
8758
|
+
log(chalk5.green(`Baseline set: ${chalk5.bold(runId.slice(0, 8))}${run ? ` (${run.status}, ${run.total} scenarios)` : ""}`));
|
|
7753
8759
|
} catch (error) {
|
|
7754
|
-
|
|
8760
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7755
8761
|
process.exit(1);
|
|
7756
8762
|
}
|
|
7757
8763
|
});
|
|
@@ -7759,29 +8765,99 @@ program2.command("import-api <spec>").description("Import test scenarios from an
|
|
|
7759
8765
|
try {
|
|
7760
8766
|
const { importFromOpenAPI: importFromOpenAPI2 } = await Promise.resolve().then(() => (init_openapi_import(), exports_openapi_import));
|
|
7761
8767
|
const { imported, scenarios } = importFromOpenAPI2(spec, resolveProject(opts.project) ?? undefined);
|
|
7762
|
-
|
|
8768
|
+
log(chalk5.green(`
|
|
7763
8769
|
Imported ${imported} scenarios from API spec:`));
|
|
7764
8770
|
for (const s of scenarios) {
|
|
7765
|
-
|
|
8771
|
+
log(` ${chalk5.cyan(s.shortId)} ${s.name} ${chalk5.dim(`[${s.tags.join(", ")}]`)}`);
|
|
7766
8772
|
}
|
|
7767
|
-
|
|
8773
|
+
log("");
|
|
7768
8774
|
} catch (error) {
|
|
7769
|
-
|
|
8775
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7770
8776
|
process.exit(1);
|
|
7771
8777
|
}
|
|
7772
8778
|
});
|
|
7773
8779
|
program2.command("record <url>").description("Record a browser session and generate a test scenario").option("-n, --name <name>", "Scenario name", "Recorded session").option("--project <id>", "Project ID").action(async (url, opts) => {
|
|
7774
8780
|
try {
|
|
7775
8781
|
const { recordAndSave: recordAndSave2 } = await Promise.resolve().then(() => (init_recorder(), exports_recorder));
|
|
7776
|
-
|
|
8782
|
+
log(chalk5.blue("Opening browser for recording..."));
|
|
7777
8783
|
const { recording, scenario } = await recordAndSave2(url, opts.name, resolveProject(opts.project) ?? undefined);
|
|
7778
|
-
|
|
7779
|
-
|
|
7780
|
-
|
|
7781
|
-
|
|
8784
|
+
log("");
|
|
8785
|
+
log(chalk5.green(`Recording saved as scenario ${chalk5.bold(scenario.shortId)}: ${scenario.name}`));
|
|
8786
|
+
log(chalk5.dim(` ${recording.actions.length} actions recorded in ${(recording.duration / 1000).toFixed(0)}s`));
|
|
8787
|
+
log(chalk5.dim(` ${scenario.steps.length} steps generated`));
|
|
8788
|
+
} catch (error) {
|
|
8789
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
8790
|
+
process.exit(1);
|
|
8791
|
+
}
|
|
8792
|
+
});
|
|
8793
|
+
program2.command("doctor").description("Check system setup and configuration").action(async () => {
|
|
8794
|
+
let allPassed = true;
|
|
8795
|
+
const hasApiKey = Boolean(process.env["ANTHROPIC_API_KEY"]);
|
|
8796
|
+
if (hasApiKey) {
|
|
8797
|
+
log(chalk5.green("\u2713") + " ANTHROPIC_API_KEY is set");
|
|
8798
|
+
} else {
|
|
8799
|
+
log(chalk5.red("\u2717") + " ANTHROPIC_API_KEY is not set (required for AI-powered tests)");
|
|
8800
|
+
allPassed = false;
|
|
8801
|
+
}
|
|
8802
|
+
const dbPath = join6(process.env["HOME"] ?? "~", ".testers", "testers.db");
|
|
8803
|
+
try {
|
|
8804
|
+
const { Database: Database3 } = await import("bun:sqlite");
|
|
8805
|
+
const db2 = new Database3(dbPath, { create: true });
|
|
8806
|
+
db2.close();
|
|
8807
|
+
log(chalk5.green("\u2713") + ` Database accessible: ${dbPath}`);
|
|
8808
|
+
} catch (err) {
|
|
8809
|
+
log(chalk5.red("\u2717") + ` Database not accessible at ${dbPath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
8810
|
+
allPassed = false;
|
|
8811
|
+
}
|
|
8812
|
+
try {
|
|
8813
|
+
const { chromium: chromium4 } = await import("playwright");
|
|
8814
|
+
const execPath = chromium4.executablePath();
|
|
8815
|
+
const { existsSync: fsExists } = await import("fs");
|
|
8816
|
+
if (fsExists(execPath)) {
|
|
8817
|
+
log(chalk5.green("\u2713") + " Playwright chromium is installed");
|
|
8818
|
+
} else {
|
|
8819
|
+
log(chalk5.red("\u2717") + ` Playwright chromium executable not found at ${execPath}. Run: testers install`);
|
|
8820
|
+
allPassed = false;
|
|
8821
|
+
}
|
|
8822
|
+
} catch {
|
|
8823
|
+
log(chalk5.red("\u2717") + " Playwright is not installed. Run: testers install");
|
|
8824
|
+
allPassed = false;
|
|
8825
|
+
}
|
|
8826
|
+
if (!allPassed) {
|
|
8827
|
+
process.exit(1);
|
|
8828
|
+
}
|
|
8829
|
+
});
|
|
8830
|
+
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) => {
|
|
8831
|
+
try {
|
|
8832
|
+
const port = parseInt(opts.port, 10);
|
|
8833
|
+
const url = `http://localhost:${port}`;
|
|
8834
|
+
const serverBin = join6(resolve(process.execPath, ".."), "..", "dist", "server", "index.js");
|
|
8835
|
+
const { join: pathJoin, resolve: pathResolve, dirname: dirname2 } = await import("path");
|
|
8836
|
+
const { fileURLToPath } = await import("url");
|
|
8837
|
+
const serverPath = pathJoin(dirname2(fileURLToPath(import.meta.url)), "..", "server", "index.js");
|
|
8838
|
+
const proc = Bun.spawn(["bun", "run", serverPath], {
|
|
8839
|
+
env: { ...process.env, TESTERS_PORT: String(port) },
|
|
8840
|
+
stdout: "inherit",
|
|
8841
|
+
stderr: "inherit"
|
|
8842
|
+
});
|
|
8843
|
+
log(chalk5.green(`Open Testers dashboard starting at ${url}`));
|
|
8844
|
+
if (opts.open !== false) {
|
|
8845
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
8846
|
+
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
|
|
8847
|
+
Bun.spawn([openCmd, url]);
|
|
8848
|
+
}
|
|
8849
|
+
await proc.exited;
|
|
7782
8850
|
} catch (error) {
|
|
7783
|
-
|
|
8851
|
+
logError(chalk5.red(`Error: ${error instanceof Error ? error.message : String(error)}`));
|
|
7784
8852
|
process.exit(1);
|
|
7785
8853
|
}
|
|
7786
8854
|
});
|
|
8855
|
+
program2.hook("preAction", () => {
|
|
8856
|
+
const opts = program2.opts();
|
|
8857
|
+
QUIET = opts.quiet === true;
|
|
8858
|
+
NO_COLOR = opts.color === false || process.env["FORCE_COLOR"] === "0";
|
|
8859
|
+
if (NO_COLOR) {
|
|
8860
|
+
process.env["FORCE_COLOR"] = "0";
|
|
8861
|
+
}
|
|
8862
|
+
});
|
|
7787
8863
|
program2.parse();
|