blastproof 0.2.2 → 0.3.0
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/README.md +71 -3
- package/dist/cli.js +369 -60
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -169,6 +169,86 @@ import { chromium as chromium2 } from "playwright";
|
|
|
169
169
|
import { readFile, writeFile as writeFile2, mkdir as mkdir3 } from "fs/promises";
|
|
170
170
|
import path3 from "path";
|
|
171
171
|
|
|
172
|
+
// src/runner/budget.ts
|
|
173
|
+
function describeLimit(limit, observed, configured) {
|
|
174
|
+
switch (limit) {
|
|
175
|
+
case "calls":
|
|
176
|
+
return `model call budget exhausted: reached the configured maximum of ${configured} call(s)`;
|
|
177
|
+
case "tokens":
|
|
178
|
+
return `token budget exhausted: reached the configured maximum of ${configured} token(s) (observed ${observed})`;
|
|
179
|
+
case "duration":
|
|
180
|
+
return `deadline exceeded: reached the configured maximum of ${(configured / 1e3).toFixed(0)}s (elapsed ${(observed / 1e3).toFixed(1)}s)`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
var BudgetExhaustedError = class extends Error {
|
|
184
|
+
limit;
|
|
185
|
+
observed;
|
|
186
|
+
configured;
|
|
187
|
+
constructor(limit, observed, configured) {
|
|
188
|
+
super(describeLimit(limit, observed, configured));
|
|
189
|
+
this.name = "BudgetExhaustedError";
|
|
190
|
+
this.limit = limit;
|
|
191
|
+
this.observed = observed;
|
|
192
|
+
this.configured = configured;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
var RunBudget = class {
|
|
196
|
+
maxCalls;
|
|
197
|
+
maxTokens;
|
|
198
|
+
maxDurationMs;
|
|
199
|
+
now;
|
|
200
|
+
startedAt;
|
|
201
|
+
calls = 0;
|
|
202
|
+
tokens = 0;
|
|
203
|
+
constructor(options = {}) {
|
|
204
|
+
this.maxCalls = options.maxCalls;
|
|
205
|
+
this.maxTokens = options.maxTokens;
|
|
206
|
+
this.maxDurationMs = options.maxDurationMs;
|
|
207
|
+
this.now = options.now ?? Date.now;
|
|
208
|
+
this.startedAt = this.now();
|
|
209
|
+
}
|
|
210
|
+
/** Model calls recorded so far. */
|
|
211
|
+
get callCount() {
|
|
212
|
+
return this.calls;
|
|
213
|
+
}
|
|
214
|
+
/** Tokens recorded so far, summed across every completed call. */
|
|
215
|
+
get tokenCount() {
|
|
216
|
+
return this.tokens;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Throws {@link BudgetExhaustedError} when the next unit of work could exceed a
|
|
220
|
+
* configured limit. Called before spending anything, never after — so an
|
|
221
|
+
* over-budget call is never issued and then rejected (spec: "the call is not
|
|
222
|
+
* made").
|
|
223
|
+
*/
|
|
224
|
+
check() {
|
|
225
|
+
if (this.maxCalls !== void 0 && this.calls >= this.maxCalls) {
|
|
226
|
+
throw new BudgetExhaustedError("calls", this.calls, this.maxCalls);
|
|
227
|
+
}
|
|
228
|
+
if (this.maxTokens !== void 0 && this.tokens >= this.maxTokens) {
|
|
229
|
+
throw new BudgetExhaustedError("tokens", this.tokens, this.maxTokens);
|
|
230
|
+
}
|
|
231
|
+
if (this.maxDurationMs !== void 0) {
|
|
232
|
+
const elapsed = this.now() - this.startedAt;
|
|
233
|
+
if (elapsed >= this.maxDurationMs) {
|
|
234
|
+
throw new BudgetExhaustedError("duration", elapsed, this.maxDurationMs);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Records what a completed model call spent. */
|
|
239
|
+
record(usage) {
|
|
240
|
+
this.calls += 1;
|
|
241
|
+
if (usage?.totalTokens !== void 0) this.tokens += usage.totalTokens;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
function estimateMaxModelCalls(tests, maxIterationsPerStep, maxRetriesPerStep) {
|
|
245
|
+
const totalSteps = tests.reduce(
|
|
246
|
+
(sum, test) => sum + (test.setup?.length ?? 0) + test.steps.length,
|
|
247
|
+
0
|
|
248
|
+
);
|
|
249
|
+
return totalSteps * (maxIterationsPerStep + maxRetriesPerStep);
|
|
250
|
+
}
|
|
251
|
+
|
|
172
252
|
// src/runner/env.ts
|
|
173
253
|
var ENV_PLACEHOLDER = /\{\{\s*env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
|
174
254
|
var MissingEnvError = class extends Error {
|
|
@@ -348,6 +428,7 @@ async function performAction(page, action, ctx) {
|
|
|
348
428
|
}
|
|
349
429
|
|
|
350
430
|
// src/runner/executor.ts
|
|
431
|
+
var DEFAULT_MAX_ITERATIONS_PER_STEP = 15;
|
|
351
432
|
async function defaultSnapshot(page) {
|
|
352
433
|
const { captureSnapshot } = await import("./snapshot-CAIB2OHX.js");
|
|
353
434
|
return captureSnapshot(page);
|
|
@@ -366,7 +447,7 @@ async function executeTest(page, test, options) {
|
|
|
366
447
|
allowedOrigins,
|
|
367
448
|
resolveValue,
|
|
368
449
|
maxRetries = 3,
|
|
369
|
-
maxIterationsPerStep =
|
|
450
|
+
maxIterationsPerStep = DEFAULT_MAX_ITERATIONS_PER_STEP,
|
|
370
451
|
mask = (s) => s,
|
|
371
452
|
snapshot = defaultSnapshot,
|
|
372
453
|
onEvent = () => {
|
|
@@ -414,6 +495,7 @@ async function executeTest(page, test, options) {
|
|
|
414
495
|
iterationsLeft: maxIterationsPerStep - iterations
|
|
415
496
|
});
|
|
416
497
|
} catch (error) {
|
|
498
|
+
if (error instanceof BudgetExhaustedError) throw error;
|
|
417
499
|
failedAttempts++;
|
|
418
500
|
lastResult = `error: ${error instanceof Error ? error.message : String(error)}`;
|
|
419
501
|
if (failedAttempts >= maxRetries) {
|
|
@@ -437,7 +519,7 @@ async function executeTest(page, test, options) {
|
|
|
437
519
|
emitAction(index, action, result);
|
|
438
520
|
if (judgment.pass) {
|
|
439
521
|
lastResult = result;
|
|
440
|
-
|
|
522
|
+
break;
|
|
441
523
|
}
|
|
442
524
|
failedAttempts++;
|
|
443
525
|
lastResult = result;
|
|
@@ -460,6 +542,7 @@ async function executeTest(page, test, options) {
|
|
|
460
542
|
}
|
|
461
543
|
}
|
|
462
544
|
} catch (error) {
|
|
545
|
+
if (error instanceof BudgetExhaustedError) throw error;
|
|
463
546
|
stepFailedReason = mask(error instanceof Error ? error.message : String(error));
|
|
464
547
|
}
|
|
465
548
|
const status = stepFailedReason ? "failed" : "passed";
|
|
@@ -560,6 +643,7 @@ async function runJourney(...args) {
|
|
|
560
643
|
try {
|
|
561
644
|
return await executeTest(...args);
|
|
562
645
|
} catch (error) {
|
|
646
|
+
if (error instanceof BudgetExhaustedError) throw error;
|
|
563
647
|
throw new AuthError(
|
|
564
648
|
`Authentication could not run: ${error instanceof Error ? error.message : String(error)}`
|
|
565
649
|
);
|
|
@@ -663,6 +747,12 @@ var browserSchema = z.object({
|
|
|
663
747
|
headless: z.boolean().default(true),
|
|
664
748
|
timeout_ms: z.number().int().positive().default(3e4)
|
|
665
749
|
});
|
|
750
|
+
var budgetSchema = z.object({
|
|
751
|
+
max_llm_calls: z.coerce.number().int().positive().optional(),
|
|
752
|
+
max_tokens: z.coerce.number().int().positive().optional(),
|
|
753
|
+
/** Seconds, not milliseconds, to match how a human would set a deadline. */
|
|
754
|
+
max_duration_s: z.coerce.number().positive().optional()
|
|
755
|
+
});
|
|
666
756
|
var cookieSchema = z.object({
|
|
667
757
|
name: z.string().min(1),
|
|
668
758
|
value: z.string(),
|
|
@@ -712,14 +802,18 @@ var configSchema = z.object({
|
|
|
712
802
|
*/
|
|
713
803
|
allowed_origins: z.array(z.string().url()).optional(),
|
|
714
804
|
auth: authSchema.optional(),
|
|
715
|
-
max_retries_per_step: z.number().int().min(1).default(3)
|
|
805
|
+
max_retries_per_step: z.number().int().min(1).default(3),
|
|
806
|
+
budget: budgetSchema.optional()
|
|
716
807
|
});
|
|
717
808
|
var ENV_OVERRIDES = {
|
|
718
809
|
BLASTPROOF_BASE_URL: ["base_url"],
|
|
719
810
|
BLASTPROOF_LLM_PROVIDER: ["llm", "provider"],
|
|
720
811
|
BLASTPROOF_LLM_MODEL: ["llm", "model"],
|
|
721
812
|
BLASTPROOF_LLM_BASE_URL: ["llm", "base_url"],
|
|
722
|
-
BLASTPROOF_LLM_API_KEY_ENV: ["llm", "api_key_env"]
|
|
813
|
+
BLASTPROOF_LLM_API_KEY_ENV: ["llm", "api_key_env"],
|
|
814
|
+
BLASTPROOF_MAX_LLM_CALLS: ["budget", "max_llm_calls"],
|
|
815
|
+
BLASTPROOF_MAX_TOKENS: ["budget", "max_tokens"],
|
|
816
|
+
BLASTPROOF_MAX_DURATION_S: ["budget", "max_duration_s"]
|
|
723
817
|
};
|
|
724
818
|
function applyEnvOverrides(data, env = process.env) {
|
|
725
819
|
const merged = data && typeof data === "object" ? { ...data } : {};
|
|
@@ -967,10 +1061,16 @@ var MalformedModelOutputError = class extends Error {
|
|
|
967
1061
|
this.name = "MalformedModelOutputError";
|
|
968
1062
|
}
|
|
969
1063
|
};
|
|
970
|
-
function
|
|
1064
|
+
async function countedGenerate(generate, budget, options) {
|
|
1065
|
+
budget.check();
|
|
1066
|
+
const result = await generate(options);
|
|
1067
|
+
budget.record(result.usage);
|
|
1068
|
+
return result;
|
|
1069
|
+
}
|
|
1070
|
+
function createBrain(model, generate = generateObject, budget) {
|
|
971
1071
|
return {
|
|
972
1072
|
async nextAction(input) {
|
|
973
|
-
const result = await generate
|
|
1073
|
+
const result = await countedGenerate(generate, budget, {
|
|
974
1074
|
model,
|
|
975
1075
|
schema: agentActionSchema,
|
|
976
1076
|
system: agentSystemPrompt(),
|
|
@@ -985,7 +1085,7 @@ function createBrain(model, generate = generateObject) {
|
|
|
985
1085
|
return parsed.data;
|
|
986
1086
|
},
|
|
987
1087
|
async judge(expectation, snapshot) {
|
|
988
|
-
const result = await generate
|
|
1088
|
+
const result = await countedGenerate(generate, budget, {
|
|
989
1089
|
model,
|
|
990
1090
|
schema: assertJudgmentSchema,
|
|
991
1091
|
system: assertSystemPrompt(),
|
|
@@ -1001,10 +1101,10 @@ function createBrain(model, generate = generateObject) {
|
|
|
1001
1101
|
}
|
|
1002
1102
|
};
|
|
1003
1103
|
}
|
|
1004
|
-
function createPlanner(model, generate = generateObject) {
|
|
1104
|
+
function createPlanner(model, generate = generateObject, budget) {
|
|
1005
1105
|
return {
|
|
1006
1106
|
async planTest(input) {
|
|
1007
|
-
const result = await generate
|
|
1107
|
+
const result = await countedGenerate(generate, budget, {
|
|
1008
1108
|
model,
|
|
1009
1109
|
schema: generatedTestSchema,
|
|
1010
1110
|
system: plannerSystemPrompt(),
|
|
@@ -1262,6 +1362,8 @@ h1 { font-size:1.35rem; margin:0 0 .25rem; }
|
|
|
1262
1362
|
.verdict { font-weight:600; }
|
|
1263
1363
|
.verdict.pass { color:var(--pass); } .verdict.fail { color:var(--fail); }
|
|
1264
1364
|
.counts { color:var(--muted); font-size:.9rem; }
|
|
1365
|
+
.incomplete { padding:1rem 1.25rem; border:1px solid var(--fail); border-radius:10px;
|
|
1366
|
+
background:color-mix(in srgb, var(--fail) 10%, transparent); margin-bottom:1.5rem; font-weight:600; }
|
|
1265
1367
|
table { width:100%; border-collapse:collapse; margin-bottom:1.5rem; }
|
|
1266
1368
|
th, td { text-align:left; padding:.5rem .6rem; border-bottom:1px solid var(--line);
|
|
1267
1369
|
font-size:.9rem; vertical-align:top; }
|
|
@@ -1298,10 +1400,13 @@ function renderSteps(steps) {
|
|
|
1298
1400
|
}
|
|
1299
1401
|
async function renderTest(result, cwd) {
|
|
1300
1402
|
const failed = result.status === "failed";
|
|
1403
|
+
const notRun = result.status === "not-run";
|
|
1404
|
+
const tagClass = failed ? "fail" : notRun ? "skip" : "pass";
|
|
1405
|
+
const label = failed ? "FAIL" : notRun ? "NOT RUN" : "PASS";
|
|
1301
1406
|
const file = cwd ? path7.relative(cwd, result.file) : result.file;
|
|
1302
1407
|
const parts = [
|
|
1303
|
-
` <details${failed ? " open" : ""}>`,
|
|
1304
|
-
` <summary><span class="tag ${
|
|
1408
|
+
` <details${failed || notRun ? " open" : ""}>`,
|
|
1409
|
+
` <summary><span class="tag ${tagClass}">${label}</span> ${escapeHtml(result.summary)} <span class="note">${escapeHtml(result.priority)} \xB7 ${escapeHtml(file)}</span></summary>`
|
|
1305
1410
|
];
|
|
1306
1411
|
if (failed) {
|
|
1307
1412
|
if (result.failedStep) {
|
|
@@ -1311,6 +1416,9 @@ async function renderTest(result, cwd) {
|
|
|
1311
1416
|
parts.push(` <p class="reason">${escapeHtml(result.reason)}</p>`);
|
|
1312
1417
|
}
|
|
1313
1418
|
}
|
|
1419
|
+
if (notRun && result.reason) {
|
|
1420
|
+
parts.push(` <p class="note">${escapeHtml(result.reason)}</p>`);
|
|
1421
|
+
}
|
|
1314
1422
|
parts.push(` ${renderSteps(result.steps)}`);
|
|
1315
1423
|
if (failed && result.screenshot) {
|
|
1316
1424
|
const embedded = await embedScreenshot(result.screenshot);
|
|
@@ -1324,13 +1432,20 @@ async function renderTest(result, cwd) {
|
|
|
1324
1432
|
async function renderHtml(results, skipped, meta) {
|
|
1325
1433
|
const passed = results.filter((r) => r.status === "passed").length;
|
|
1326
1434
|
const failures = results.filter((r) => r.status === "failed");
|
|
1435
|
+
const notRun = results.filter((r) => r.status === "not-run");
|
|
1327
1436
|
const generatedAt = (meta.generatedAt ?? /* @__PURE__ */ new Date()).toISOString();
|
|
1328
|
-
const ordered = [...failures, ...results.filter((r) => r.status === "passed")];
|
|
1437
|
+
const ordered = [...failures, ...notRun, ...results.filter((r) => r.status === "passed")];
|
|
1329
1438
|
const detail = (await Promise.all(ordered.map((r) => renderTest(r, meta.cwd)))).join("\n");
|
|
1330
1439
|
const verdict = meta.minScore === void 0 ? "" : meta.score >= meta.minScore ? `<span class="verdict pass">min-score ${meta.minScore}: pass</span>` : `<span class="verdict fail">min-score ${meta.minScore}: FAIL</span>`;
|
|
1440
|
+
const banner = meta.incomplete === void 0 ? "" : ` <section class="incomplete">Run stopped: ${escapeHtml(meta.incomplete)}</section>
|
|
1441
|
+
|
|
1442
|
+
`;
|
|
1331
1443
|
const rows = results.map((r) => {
|
|
1332
1444
|
const file = meta.cwd ? path7.relative(meta.cwd, r.file) : r.file;
|
|
1333
|
-
|
|
1445
|
+
const tagClass = r.status === "failed" ? "fail" : r.status === "not-run" ? "skip" : "pass";
|
|
1446
|
+
const label = r.status === "failed" ? "FAIL" : r.status === "not-run" ? "NOT RUN" : "PASS";
|
|
1447
|
+
const time = r.status === "not-run" ? "\u2014" : seconds(r.durationMs);
|
|
1448
|
+
return ` <tr><td><span class="tag ${tagClass}">${label}</span></td><td>${escapeHtml(r.priority)}</td><td>${escapeHtml(r.summary)}</td><td>${escapeHtml(file)}</td><td class="num">${time}</td></tr>`;
|
|
1334
1449
|
}).join("\n");
|
|
1335
1450
|
const skippedRows = skipped.map((s) => {
|
|
1336
1451
|
const file = meta.cwd ? path7.relative(meta.cwd, s.path) : s.path;
|
|
@@ -1349,10 +1464,10 @@ async function renderHtml(results, skipped, meta) {
|
|
|
1349
1464
|
<h1>blastproof report</h1>
|
|
1350
1465
|
<p class="sub">${escapeHtml(generatedAt)} \xB7 ${seconds(meta.durationMs)}</p>
|
|
1351
1466
|
|
|
1352
|
-
<section class="score">
|
|
1467
|
+
${banner} <section class="score">
|
|
1353
1468
|
<b>${meta.score}</b>
|
|
1354
1469
|
${verdict}
|
|
1355
|
-
<span class="counts">${passed} passed \xB7 ${failures.length} failed${skipped.length > 0 ? ` \xB7 ${skipped.length} skipped` : ""}</span>
|
|
1470
|
+
<span class="counts">${passed} passed \xB7 ${failures.length} failed${notRun.length > 0 ? ` \xB7 ${notRun.length} not run` : ""}${skipped.length > 0 ? ` \xB7 ${skipped.length} skipped` : ""}</span>
|
|
1356
1471
|
</section>
|
|
1357
1472
|
|
|
1358
1473
|
<table>
|
|
@@ -1395,14 +1510,27 @@ function seconds2(ms) {
|
|
|
1395
1510
|
function renderJUnit(results, skipped, meta) {
|
|
1396
1511
|
const relative = (file) => meta.cwd ? path8.relative(meta.cwd, file) : file;
|
|
1397
1512
|
const failures = results.filter((result) => result.status === "failed").length;
|
|
1513
|
+
const notRun = results.filter((result) => result.status === "not-run");
|
|
1398
1514
|
const lines = [
|
|
1399
1515
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
1400
|
-
`<testsuite name="blastproof" tests="${results.length + skipped.length}" failures="${failures}" skipped="${skipped.length}" time="${seconds2(meta.durationMs)}">`,
|
|
1516
|
+
`<testsuite name="blastproof" tests="${results.length + skipped.length}" failures="${failures}" skipped="${skipped.length + notRun.length}" time="${seconds2(meta.durationMs)}">`,
|
|
1401
1517
|
" <properties>",
|
|
1402
1518
|
` <property name="score" value="${meta.score}"/>`,
|
|
1519
|
+
...meta.incomplete !== void 0 ? [
|
|
1520
|
+
' <property name="incomplete" value="true"/>',
|
|
1521
|
+
` <property name="incomplete_reason" value="${escapeXml(meta.incomplete)}"/>`
|
|
1522
|
+
] : [],
|
|
1403
1523
|
" </properties>"
|
|
1404
1524
|
];
|
|
1405
1525
|
for (const result of results) {
|
|
1526
|
+
if (result.status === "not-run") {
|
|
1527
|
+
lines.push(
|
|
1528
|
+
` <testcase classname="${escapeXml(relative(result.file))}" name="${escapeXml(result.summary)}" time="0.000">`,
|
|
1529
|
+
` <skipped message="${escapeXml(result.reason ?? "not run: the run stopped before this test executed")}"/>`,
|
|
1530
|
+
" </testcase>"
|
|
1531
|
+
);
|
|
1532
|
+
continue;
|
|
1533
|
+
}
|
|
1406
1534
|
const open = ` <testcase classname="${escapeXml(relative(result.file))}" name="${escapeXml(result.summary)}" time="${seconds2(result.durationMs)}"`;
|
|
1407
1535
|
if (result.status === "passed") {
|
|
1408
1536
|
lines.push(`${open}/>`);
|
|
@@ -1438,6 +1566,7 @@ function computeScore(results) {
|
|
|
1438
1566
|
let total = 0;
|
|
1439
1567
|
let passed = 0;
|
|
1440
1568
|
for (const result of results) {
|
|
1569
|
+
if (result.status === "not-run") continue;
|
|
1441
1570
|
const weight = WEIGHTS[result.priority] ?? DEFAULT_WEIGHT;
|
|
1442
1571
|
total += weight;
|
|
1443
1572
|
if (result.status === "passed") passed += weight;
|
|
@@ -1453,6 +1582,10 @@ function formatScoreLine(score, results, threshold) {
|
|
|
1453
1582
|
if (threshold === void 0) return `Score: ${score}`;
|
|
1454
1583
|
return score >= threshold ? `Score: ${score} \u2014 min-score ${threshold}: pass` : `Score: ${score} \u2014 min-score ${threshold}: FAIL (below threshold)`;
|
|
1455
1584
|
}
|
|
1585
|
+
function formatIncompleteLine(score, reason) {
|
|
1586
|
+
return `Run incomplete: ${reason}
|
|
1587
|
+
Score over executed tests: ${score} (not a verdict \u2014 exit code 1 regardless of --min-score)`;
|
|
1588
|
+
}
|
|
1456
1589
|
|
|
1457
1590
|
// src/runner/selection.ts
|
|
1458
1591
|
function matchesFilters(test, filters) {
|
|
@@ -1492,6 +1625,14 @@ function selectImpactedTests(tests, affectedRoutes, filters = { tags: [] }) {
|
|
|
1492
1625
|
var EXIT_OK = 0;
|
|
1493
1626
|
var EXIT_FAILED = 1;
|
|
1494
1627
|
var EXIT_USAGE = 2;
|
|
1628
|
+
function resolveBudgetOptions(config, options) {
|
|
1629
|
+
const maxDurationSeconds = options.maxDuration ?? config.budget?.max_duration_s;
|
|
1630
|
+
return {
|
|
1631
|
+
maxCalls: options.maxLlmCalls ?? config.budget?.max_llm_calls,
|
|
1632
|
+
maxTokens: options.maxTokens ?? config.budget?.max_tokens,
|
|
1633
|
+
maxDurationMs: maxDurationSeconds === void 0 ? void 0 : maxDurationSeconds * 1e3
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1495
1636
|
function sessionId() {
|
|
1496
1637
|
return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1497
1638
|
}
|
|
@@ -1522,6 +1663,18 @@ function resolveSecretsAndSteps(test) {
|
|
|
1522
1663
|
}
|
|
1523
1664
|
return { test };
|
|
1524
1665
|
}
|
|
1666
|
+
function notRunResults(tests, reason) {
|
|
1667
|
+
return tests.map((test) => ({
|
|
1668
|
+
file: test.path,
|
|
1669
|
+
summary: test.summary,
|
|
1670
|
+
priority: test.priority,
|
|
1671
|
+
tags: test.tags,
|
|
1672
|
+
status: "not-run",
|
|
1673
|
+
steps: [],
|
|
1674
|
+
reason,
|
|
1675
|
+
durationMs: 0
|
|
1676
|
+
}));
|
|
1677
|
+
}
|
|
1525
1678
|
function printEvent(event) {
|
|
1526
1679
|
switch (event.type) {
|
|
1527
1680
|
case "step-start":
|
|
@@ -1541,12 +1694,18 @@ function printEvent(event) {
|
|
|
1541
1694
|
break;
|
|
1542
1695
|
}
|
|
1543
1696
|
}
|
|
1697
|
+
function statusLabel(status) {
|
|
1698
|
+
if (status === "passed") return "PASS";
|
|
1699
|
+
if (status === "failed") return "FAIL";
|
|
1700
|
+
return "NOT RUN";
|
|
1701
|
+
}
|
|
1544
1702
|
function printSummary(results) {
|
|
1545
1703
|
const passed = results.filter((r) => r.status === "passed");
|
|
1546
1704
|
const failed = results.filter((r) => r.status === "failed");
|
|
1705
|
+
const notRun = results.filter((r) => r.status === "not-run");
|
|
1547
1706
|
console.log("\n--- Summary ---------------------------------------------------");
|
|
1548
1707
|
const rows = results.map((r) => ({
|
|
1549
|
-
status: r.status
|
|
1708
|
+
status: statusLabel(r.status),
|
|
1550
1709
|
priority: r.priority,
|
|
1551
1710
|
summary: r.summary,
|
|
1552
1711
|
duration: `${(r.durationMs / 1e3).toFixed(1)}s`
|
|
@@ -1559,7 +1718,9 @@ function printSummary(results) {
|
|
|
1559
1718
|
);
|
|
1560
1719
|
}
|
|
1561
1720
|
console.log("---------------------------------------------------------------");
|
|
1562
|
-
console.log(
|
|
1721
|
+
console.log(
|
|
1722
|
+
notRun.length > 0 ? `${passed.length} passed, ${failed.length} failed, ${notRun.length} not run, ${results.length} total` : `${passed.length} passed, ${failed.length} failed, ${results.length} total`
|
|
1723
|
+
);
|
|
1563
1724
|
for (const r of failed) {
|
|
1564
1725
|
console.log(`
|
|
1565
1726
|
X ${r.summary} (${r.file})`);
|
|
@@ -1567,9 +1728,14 @@ X ${r.summary} (${r.file})`);
|
|
|
1567
1728
|
if (r.reason) console.log(` reason: ${r.reason}`);
|
|
1568
1729
|
if (r.screenshot) console.log(` screenshot: ${r.screenshot}`);
|
|
1569
1730
|
}
|
|
1731
|
+
if (notRun.length > 0) {
|
|
1732
|
+
console.log(`
|
|
1733
|
+
${notRun.length} test(s) not run (run stopped by its budget or deadline):`);
|
|
1734
|
+
for (const r of notRun) console.log(` - ${r.summary} (${r.file})`);
|
|
1735
|
+
}
|
|
1570
1736
|
}
|
|
1571
|
-
async function runOne(browser, test, config, sessionDir, session, runMask) {
|
|
1572
|
-
const brain = createBrain(createModel(config.llm).model);
|
|
1737
|
+
async function runOne(browser, test, config, sessionDir, session, runMask, budget) {
|
|
1738
|
+
const brain = createBrain(createModel(config.llm).model, void 0, budget);
|
|
1573
1739
|
let resolved;
|
|
1574
1740
|
const mask = runMask;
|
|
1575
1741
|
try {
|
|
@@ -1632,13 +1798,20 @@ function printImpactReport(impact, selection, cwd) {
|
|
|
1632
1798
|
}
|
|
1633
1799
|
console.log("---------------------------------------------------------------");
|
|
1634
1800
|
}
|
|
1635
|
-
async function finalize(results, skipped, options, sessionDir, durationMs, impact) {
|
|
1801
|
+
async function finalize(results, skipped, options, sessionDir, durationMs, impact, incomplete) {
|
|
1636
1802
|
if (results.length > 0) printSummary(results);
|
|
1637
1803
|
const score = computeScore(results);
|
|
1638
|
-
console.log(
|
|
1804
|
+
console.log(
|
|
1805
|
+
incomplete ? formatIncompleteLine(score, incomplete.message) : formatScoreLine(score, results, options.minScore)
|
|
1806
|
+
);
|
|
1639
1807
|
if (options.junit) {
|
|
1640
1808
|
const target = typeof options.junit === "string" ? path9.resolve(options.cwd, options.junit) : path9.join(sessionDir, "junit.xml");
|
|
1641
|
-
const xml = renderJUnit(results, skipped, {
|
|
1809
|
+
const xml = renderJUnit(results, skipped, {
|
|
1810
|
+
score,
|
|
1811
|
+
durationMs,
|
|
1812
|
+
cwd: options.cwd,
|
|
1813
|
+
incomplete: incomplete?.message
|
|
1814
|
+
});
|
|
1642
1815
|
await writeJUnit(target, xml);
|
|
1643
1816
|
console.log(`JUnit report: ${path9.relative(options.cwd, target)}`);
|
|
1644
1817
|
}
|
|
@@ -1648,11 +1821,13 @@ async function finalize(results, skipped, options, sessionDir, durationMs, impac
|
|
|
1648
1821
|
score,
|
|
1649
1822
|
durationMs,
|
|
1650
1823
|
minScore: options.minScore,
|
|
1651
|
-
cwd: options.cwd
|
|
1824
|
+
cwd: options.cwd,
|
|
1825
|
+
incomplete: incomplete?.message
|
|
1652
1826
|
});
|
|
1653
1827
|
await writeHtml(target, html);
|
|
1654
1828
|
console.log(`HTML report: ${path9.relative(options.cwd, target)}`);
|
|
1655
1829
|
}
|
|
1830
|
+
if (incomplete) return EXIT_FAILED;
|
|
1656
1831
|
if (reportUnclassified(options, impact)) return EXIT_FAILED;
|
|
1657
1832
|
if (options.minScore !== void 0) {
|
|
1658
1833
|
return score >= options.minScore ? EXIT_OK : EXIT_FAILED;
|
|
@@ -1671,12 +1846,23 @@ function reportUnclassified(options, impact) {
|
|
|
1671
1846
|
);
|
|
1672
1847
|
return true;
|
|
1673
1848
|
}
|
|
1674
|
-
function printDryRun(selected,
|
|
1849
|
+
function printDryRun(selected, config, cwd) {
|
|
1675
1850
|
console.log(`
|
|
1676
|
-
Dry run: ${selected.length} test(s) selected, base_url=${
|
|
1851
|
+
Dry run: ${selected.length} test(s) selected, base_url=${config.base_url}`);
|
|
1677
1852
|
for (const test of selected) {
|
|
1678
1853
|
console.log(` ${test.summary} [${test.priority}] (${path9.relative(cwd, test.path)})`);
|
|
1679
1854
|
}
|
|
1855
|
+
const authSteps = config.auth?.steps ?? [];
|
|
1856
|
+
const withAuth = authSteps.length > 0 ? [...selected, { steps: authSteps }] : selected;
|
|
1857
|
+
const ceiling = estimateMaxModelCalls(
|
|
1858
|
+
withAuth,
|
|
1859
|
+
DEFAULT_MAX_ITERATIONS_PER_STEP,
|
|
1860
|
+
config.max_retries_per_step
|
|
1861
|
+
);
|
|
1862
|
+
const authNote = authSteps.length > 0 ? ", including the login journey" : "";
|
|
1863
|
+
console.log(
|
|
1864
|
+
`Worst case: up to ${ceiling} model call(s) for this selection${authNote} (a maximum, not a prediction).`
|
|
1865
|
+
);
|
|
1680
1866
|
console.log("Dry run: no browser launched, no LLM calls made.");
|
|
1681
1867
|
}
|
|
1682
1868
|
async function runCommand(options) {
|
|
@@ -1752,7 +1938,7 @@ async function runCommand(options) {
|
|
|
1752
1938
|
printImpactReport(impact, selection, options.cwd);
|
|
1753
1939
|
}
|
|
1754
1940
|
if (options.dryRun) {
|
|
1755
|
-
printDryRun(selected, config
|
|
1941
|
+
printDryRun(selected, config, options.cwd);
|
|
1756
1942
|
if (results.length > 0) {
|
|
1757
1943
|
console.error(`
|
|
1758
1944
|
${results.length} test file(s) could not be parsed:`);
|
|
@@ -1783,7 +1969,9 @@ ${results.length} test file(s) could not be parsed:`);
|
|
|
1783
1969
|
`blastproof run: ${selected.length} test(s), provider=${provider} model=${modelId}, base_url=${config.base_url}`
|
|
1784
1970
|
);
|
|
1785
1971
|
const runMask = buildRunMask(config, parsed);
|
|
1972
|
+
const budget = options.budget ?? new RunBudget(resolveBudgetOptions(config, options));
|
|
1786
1973
|
const browser = await chromium.launch({ headless: config.browser.headless });
|
|
1974
|
+
let incomplete;
|
|
1787
1975
|
try {
|
|
1788
1976
|
let session;
|
|
1789
1977
|
if (config.auth) {
|
|
@@ -1795,28 +1983,54 @@ ${results.length} test file(s) could not be parsed:`);
|
|
|
1795
1983
|
baseUrl: config.base_url,
|
|
1796
1984
|
allowedOrigins: config.allowed_origins,
|
|
1797
1985
|
browser,
|
|
1798
|
-
brain: createBrain(createModel(config.llm).model),
|
|
1986
|
+
brain: createBrain(createModel(config.llm).model, void 0, budget),
|
|
1799
1987
|
maxRetries: config.max_retries_per_step,
|
|
1800
1988
|
mask: runMask,
|
|
1801
1989
|
onEvent: printEvent
|
|
1802
1990
|
});
|
|
1803
1991
|
} catch (error) {
|
|
1804
|
-
if (error instanceof
|
|
1992
|
+
if (error instanceof BudgetExhaustedError) {
|
|
1993
|
+
incomplete = error;
|
|
1994
|
+
} else if (error instanceof AuthError) {
|
|
1805
1995
|
console.error(`error: ${error.message}`);
|
|
1806
1996
|
return EXIT_USAGE;
|
|
1997
|
+
} else {
|
|
1998
|
+
throw error;
|
|
1807
1999
|
}
|
|
1808
|
-
throw error;
|
|
1809
2000
|
}
|
|
1810
2001
|
}
|
|
1811
|
-
|
|
1812
|
-
|
|
2002
|
+
if (incomplete) {
|
|
2003
|
+
results.push(...notRunResults(selected, incomplete.message));
|
|
2004
|
+
} else {
|
|
2005
|
+
for (let i = 0; i < selected.length; i++) {
|
|
2006
|
+
const test = selected[i];
|
|
2007
|
+
console.log(`
|
|
1813
2008
|
> ${test.summary} [${test.priority}] (${path9.relative(options.cwd, test.path)})`);
|
|
1814
|
-
|
|
2009
|
+
try {
|
|
2010
|
+
budget.check();
|
|
2011
|
+
results.push(await runOne(browser, test, config, sessionDir, session, runMask, budget));
|
|
2012
|
+
} catch (error) {
|
|
2013
|
+
if (error instanceof BudgetExhaustedError) {
|
|
2014
|
+
incomplete = error;
|
|
2015
|
+
results.push(...notRunResults(selected.slice(i), error.message));
|
|
2016
|
+
break;
|
|
2017
|
+
}
|
|
2018
|
+
throw error;
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
1815
2021
|
}
|
|
1816
2022
|
} finally {
|
|
1817
2023
|
await browser.close();
|
|
1818
2024
|
}
|
|
1819
|
-
return finalize(
|
|
2025
|
+
return finalize(
|
|
2026
|
+
results,
|
|
2027
|
+
selection.unroutedSkipped,
|
|
2028
|
+
options,
|
|
2029
|
+
sessionDir,
|
|
2030
|
+
Date.now() - startedAt,
|
|
2031
|
+
impact,
|
|
2032
|
+
incomplete
|
|
2033
|
+
);
|
|
1820
2034
|
}
|
|
1821
2035
|
|
|
1822
2036
|
// src/commands/plan.ts
|
|
@@ -1911,7 +2125,8 @@ async function planCommand(options) {
|
|
|
1911
2125
|
throw error;
|
|
1912
2126
|
}
|
|
1913
2127
|
const { model, provider, modelId } = createModel(config.llm);
|
|
1914
|
-
const
|
|
2128
|
+
const budget = options.budget ?? new RunBudget(resolveBudgetOptions(config, options));
|
|
2129
|
+
const brain = createPlanner(model, void 0, budget);
|
|
1915
2130
|
const base = options.routes && options.routes.length > 0 ? void 0 : options.base ?? "main";
|
|
1916
2131
|
console.log(
|
|
1917
2132
|
`blastproof plan: ${work.length} route(s), provider=${provider} model=${modelId}, base_url=${config.base_url}`
|
|
@@ -1926,6 +2141,7 @@ async function planCommand(options) {
|
|
|
1926
2141
|
const generated = [];
|
|
1927
2142
|
const written = [];
|
|
1928
2143
|
const failed = [];
|
|
2144
|
+
let incomplete;
|
|
1929
2145
|
const browser = await chromium2.launch({ headless: config.browser.headless });
|
|
1930
2146
|
try {
|
|
1931
2147
|
let session;
|
|
@@ -1938,20 +2154,23 @@ async function planCommand(options) {
|
|
|
1938
2154
|
baseUrl: config.base_url,
|
|
1939
2155
|
allowedOrigins: config.allowed_origins,
|
|
1940
2156
|
browser,
|
|
1941
|
-
brain: createBrain(createModel(config.llm).model),
|
|
2157
|
+
brain: createBrain(createModel(config.llm).model, void 0, budget),
|
|
1942
2158
|
maxRetries: config.max_retries_per_step,
|
|
1943
2159
|
mask,
|
|
1944
2160
|
onEvent: printAuthEvent
|
|
1945
2161
|
});
|
|
1946
2162
|
} catch (error) {
|
|
1947
|
-
if (error instanceof
|
|
2163
|
+
if (error instanceof BudgetExhaustedError) {
|
|
2164
|
+
incomplete = error;
|
|
2165
|
+
} else if (error instanceof AuthError) {
|
|
1948
2166
|
console.error(`error: ${error.message}`);
|
|
1949
2167
|
return EXIT_USAGE;
|
|
2168
|
+
} else {
|
|
2169
|
+
throw error;
|
|
1950
2170
|
}
|
|
1951
|
-
throw error;
|
|
1952
2171
|
}
|
|
1953
2172
|
}
|
|
1954
|
-
for (const { route, changedFiles } of work) {
|
|
2173
|
+
for (const { route, changedFiles } of incomplete ? [] : work) {
|
|
1955
2174
|
console.log(`
|
|
1956
2175
|
> ${route}`);
|
|
1957
2176
|
let draft;
|
|
@@ -1967,6 +2186,10 @@ async function planCommand(options) {
|
|
|
1967
2186
|
mask: (text) => mask.mask(text)
|
|
1968
2187
|
});
|
|
1969
2188
|
} catch (error) {
|
|
2189
|
+
if (error instanceof BudgetExhaustedError) {
|
|
2190
|
+
incomplete = error;
|
|
2191
|
+
break;
|
|
2192
|
+
}
|
|
1970
2193
|
failed.push({ route, reason: error instanceof Error ? error.message : String(error) });
|
|
1971
2194
|
console.log(` X failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1972
2195
|
continue;
|
|
@@ -1996,6 +2219,7 @@ ${renderTestYaml(draft, { route, base })}`);
|
|
|
1996
2219
|
} finally {
|
|
1997
2220
|
await browser.close();
|
|
1998
2221
|
}
|
|
2222
|
+
const notAttempted = incomplete ? work.map((item) => item.route).filter((route) => !generated.includes(route) && !failed.some((f) => f.route === route)) : [];
|
|
1999
2223
|
console.log("\n--- Plan -------------------------------------------------------");
|
|
2000
2224
|
console.log(`Generated: ${generated.length > 0 ? generated.join(", ") : "none"}`);
|
|
2001
2225
|
if (written.length > 0) {
|
|
@@ -2009,8 +2233,15 @@ ${renderTestYaml(draft, { route, base })}`);
|
|
|
2009
2233
|
console.log("Failed:");
|
|
2010
2234
|
for (const { route, reason } of failed) console.log(` ${route}: ${reason}`);
|
|
2011
2235
|
}
|
|
2236
|
+
if (incomplete) {
|
|
2237
|
+
console.log(`Stopped: ${incomplete.message}`);
|
|
2238
|
+
if (notAttempted.length > 0) {
|
|
2239
|
+
console.log("Not attempted (run out of budget):");
|
|
2240
|
+
for (const route of notAttempted) console.log(` ${route}`);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2012
2243
|
console.log("---------------------------------------------------------------");
|
|
2013
|
-
return failed.length > 0 ? EXIT_FAILED : EXIT_OK;
|
|
2244
|
+
return incomplete || failed.length > 0 ? EXIT_FAILED : EXIT_OK;
|
|
2014
2245
|
}
|
|
2015
2246
|
|
|
2016
2247
|
// src/commands/test.ts
|
|
@@ -2019,6 +2250,17 @@ function section(title) {
|
|
|
2019
2250
|
=== ${title} ${"=".repeat(Math.max(0, 58 - title.length))}`);
|
|
2020
2251
|
}
|
|
2021
2252
|
async function testCommand(options) {
|
|
2253
|
+
let config;
|
|
2254
|
+
try {
|
|
2255
|
+
config = applyUrlOverride(await loadConfig(options.cwd), options.url);
|
|
2256
|
+
} catch (error) {
|
|
2257
|
+
if (error instanceof ConfigError) {
|
|
2258
|
+
console.error(`error: ${error.message}`);
|
|
2259
|
+
return EXIT_USAGE;
|
|
2260
|
+
}
|
|
2261
|
+
throw error;
|
|
2262
|
+
}
|
|
2263
|
+
const budget = new RunBudget(resolveBudgetOptions(config, options));
|
|
2022
2264
|
section("Verify: tests covering the affected routes");
|
|
2023
2265
|
const runCode = await runCommand({
|
|
2024
2266
|
cwd: options.cwd,
|
|
@@ -2029,7 +2271,8 @@ async function testCommand(options) {
|
|
|
2029
2271
|
minScore: options.minScore,
|
|
2030
2272
|
junit: options.junit,
|
|
2031
2273
|
html: options.html,
|
|
2032
|
-
failOnUnmapped: options.failOnUnmapped
|
|
2274
|
+
failOnUnmapped: options.failOnUnmapped,
|
|
2275
|
+
budget
|
|
2033
2276
|
});
|
|
2034
2277
|
if (runCode === EXIT_USAGE) return EXIT_USAGE;
|
|
2035
2278
|
section("Draft: affected routes no test covers");
|
|
@@ -2037,7 +2280,8 @@ async function testCommand(options) {
|
|
|
2037
2280
|
cwd: options.cwd,
|
|
2038
2281
|
base: options.base,
|
|
2039
2282
|
url: options.url,
|
|
2040
|
-
write: options.write
|
|
2283
|
+
write: options.write,
|
|
2284
|
+
budget
|
|
2041
2285
|
});
|
|
2042
2286
|
if (planCode === EXIT_USAGE) return EXIT_USAGE;
|
|
2043
2287
|
console.log(
|
|
@@ -2063,8 +2307,26 @@ function parseMinScore(value) {
|
|
|
2063
2307
|
}
|
|
2064
2308
|
return score;
|
|
2065
2309
|
}
|
|
2310
|
+
function parsePositiveInt(flag) {
|
|
2311
|
+
return (value) => {
|
|
2312
|
+
const n = Number(value);
|
|
2313
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
2314
|
+
throw new InvalidArgumentError(`${flag} must be a positive integer`);
|
|
2315
|
+
}
|
|
2316
|
+
return n;
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
function parsePositiveNumber(flag) {
|
|
2320
|
+
return (value) => {
|
|
2321
|
+
const n = Number(value);
|
|
2322
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
2323
|
+
throw new InvalidArgumentError(`${flag} must be a positive number`);
|
|
2324
|
+
}
|
|
2325
|
+
return n;
|
|
2326
|
+
};
|
|
2327
|
+
}
|
|
2066
2328
|
var program = new Command();
|
|
2067
|
-
program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.
|
|
2329
|
+
program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.3.0");
|
|
2068
2330
|
program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
|
|
2069
2331
|
try {
|
|
2070
2332
|
const result = await initProject(process.cwd());
|
|
@@ -2079,7 +2341,19 @@ program.command("run").description("Discover and run all tests under .blastproof
|
|
|
2079
2341
|
"--min-score <n>",
|
|
2080
2342
|
"require a weighted score of at least n (0-100); replaces the all-must-pass rule",
|
|
2081
2343
|
parseMinScore
|
|
2082
|
-
).option("--junit [path]", "write a JUnit XML report (default: .blastproof/reports/<session>/junit.xml)").option("--html [path]", "write a self-contained HTML report (default: .blastproof/reports/<session>/report.html)").option("--fail-on-unmapped", "fail when a changed file matches no routes: or ignore: glob").
|
|
2344
|
+
).option("--junit [path]", "write a JUnit XML report (default: .blastproof/reports/<session>/junit.xml)").option("--html [path]", "write a self-contained HTML report (default: .blastproof/reports/<session>/report.html)").option("--fail-on-unmapped", "fail when a changed file matches no routes: or ignore: glob").option(
|
|
2345
|
+
"--max-llm-calls <n>",
|
|
2346
|
+
"stop the run after this many model calls, reported as incomplete (overrides config)",
|
|
2347
|
+
parsePositiveInt("--max-llm-calls")
|
|
2348
|
+
).option(
|
|
2349
|
+
"--max-tokens <n>",
|
|
2350
|
+
"stop the run after this many tokens spent across all model calls (overrides config)",
|
|
2351
|
+
parsePositiveInt("--max-tokens")
|
|
2352
|
+
).option(
|
|
2353
|
+
"--max-duration <seconds>",
|
|
2354
|
+
"stop the run after this many seconds of wall-clock time (overrides config)",
|
|
2355
|
+
parsePositiveNumber("--max-duration")
|
|
2356
|
+
).action(
|
|
2083
2357
|
async (options) => {
|
|
2084
2358
|
try {
|
|
2085
2359
|
process.exitCode = await runCommand({
|
|
@@ -2094,7 +2368,10 @@ program.command("run").description("Discover and run all tests under .blastproof
|
|
|
2094
2368
|
minScore: options.minScore,
|
|
2095
2369
|
junit: options.junit,
|
|
2096
2370
|
html: options.html,
|
|
2097
|
-
failOnUnmapped: options.failOnUnmapped
|
|
2371
|
+
failOnUnmapped: options.failOnUnmapped,
|
|
2372
|
+
maxLlmCalls: options.maxLlmCalls,
|
|
2373
|
+
maxTokens: options.maxTokens,
|
|
2374
|
+
maxDuration: options.maxDuration
|
|
2098
2375
|
});
|
|
2099
2376
|
} catch (error) {
|
|
2100
2377
|
console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -2102,25 +2379,54 @@ program.command("run").description("Discover and run all tests under .blastproof
|
|
|
2102
2379
|
}
|
|
2103
2380
|
}
|
|
2104
2381
|
);
|
|
2105
|
-
program.command("plan").description("Generate plain-English YAML tests for affected routes no test covers yet").option("--base <ref>", "base git ref for the diff", "main").option("--url <url>", "override config base_url for this run only (config file untouched)").option("--route <route>", "generate for this route, bypassing the diff (repeatable)", collect, []).option("--write", "persist drafts under .blastproof/tests/ instead of previewing them").
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2382
|
+
program.command("plan").description("Generate plain-English YAML tests for affected routes no test covers yet").option("--base <ref>", "base git ref for the diff", "main").option("--url <url>", "override config base_url for this run only (config file untouched)").option("--route <route>", "generate for this route, bypassing the diff (repeatable)", collect, []).option("--write", "persist drafts under .blastproof/tests/ instead of previewing them").option(
|
|
2383
|
+
"--max-llm-calls <n>",
|
|
2384
|
+
"stop after this many model calls, reported as incomplete (overrides config)",
|
|
2385
|
+
parsePositiveInt("--max-llm-calls")
|
|
2386
|
+
).option(
|
|
2387
|
+
"--max-tokens <n>",
|
|
2388
|
+
"stop after this many tokens spent across all model calls (overrides config)",
|
|
2389
|
+
parsePositiveInt("--max-tokens")
|
|
2390
|
+
).option(
|
|
2391
|
+
"--max-duration <seconds>",
|
|
2392
|
+
"stop after this many seconds of wall-clock time (overrides config)",
|
|
2393
|
+
parsePositiveNumber("--max-duration")
|
|
2394
|
+
).action(
|
|
2395
|
+
async (options) => {
|
|
2396
|
+
try {
|
|
2397
|
+
process.exitCode = await planCommand({
|
|
2398
|
+
cwd: process.cwd(),
|
|
2399
|
+
base: options.base,
|
|
2400
|
+
url: options.url,
|
|
2401
|
+
routes: options.route,
|
|
2402
|
+
write: options.write,
|
|
2403
|
+
maxLlmCalls: options.maxLlmCalls,
|
|
2404
|
+
maxTokens: options.maxTokens,
|
|
2405
|
+
maxDuration: options.maxDuration
|
|
2406
|
+
});
|
|
2407
|
+
} catch (error) {
|
|
2408
|
+
console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
|
|
2409
|
+
process.exitCode = EXIT_USAGE;
|
|
2410
|
+
}
|
|
2117
2411
|
}
|
|
2118
|
-
|
|
2412
|
+
);
|
|
2119
2413
|
program.command("test").description("Full PR pipeline: run the tests covering the diff, then draft tests for the gaps").option("--base <ref>", "base git ref for the diff", "main").option("--url <url>", "override config base_url for this run only (config file untouched)").option(
|
|
2120
2414
|
"--min-score <n>",
|
|
2121
2415
|
"require a weighted score of at least n (0-100); replaces the all-must-pass rule",
|
|
2122
2416
|
parseMinScore
|
|
2123
|
-
).option("--junit [path]", "write a JUnit XML report (default: .blastproof/reports/<session>/junit.xml)").option("--html [path]", "write a self-contained HTML report (default: .blastproof/reports/<session>/report.html)").option("--write", "persist generated drafts under .blastproof/tests/ instead of previewing them").option("--fail-on-unmapped", "fail when a changed file matches no routes: or ignore: glob").
|
|
2417
|
+
).option("--junit [path]", "write a JUnit XML report (default: .blastproof/reports/<session>/junit.xml)").option("--html [path]", "write a self-contained HTML report (default: .blastproof/reports/<session>/report.html)").option("--write", "persist generated drafts under .blastproof/tests/ instead of previewing them").option("--fail-on-unmapped", "fail when a changed file matches no routes: or ignore: glob").option(
|
|
2418
|
+
"--max-llm-calls <n>",
|
|
2419
|
+
"stop the pipeline after this many model calls, shared by both phases (overrides config)",
|
|
2420
|
+
parsePositiveInt("--max-llm-calls")
|
|
2421
|
+
).option(
|
|
2422
|
+
"--max-tokens <n>",
|
|
2423
|
+
"stop the pipeline after this many tokens, shared by both phases (overrides config)",
|
|
2424
|
+
parsePositiveInt("--max-tokens")
|
|
2425
|
+
).option(
|
|
2426
|
+
"--max-duration <seconds>",
|
|
2427
|
+
"stop the pipeline after this many seconds of wall-clock time (overrides config)",
|
|
2428
|
+
parsePositiveNumber("--max-duration")
|
|
2429
|
+
).action(
|
|
2124
2430
|
async (options) => {
|
|
2125
2431
|
try {
|
|
2126
2432
|
process.exitCode = await testCommand({
|
|
@@ -2131,7 +2437,10 @@ program.command("test").description("Full PR pipeline: run the tests covering th
|
|
|
2131
2437
|
junit: options.junit,
|
|
2132
2438
|
html: options.html,
|
|
2133
2439
|
write: options.write,
|
|
2134
|
-
failOnUnmapped: options.failOnUnmapped
|
|
2440
|
+
failOnUnmapped: options.failOnUnmapped,
|
|
2441
|
+
maxLlmCalls: options.maxLlmCalls,
|
|
2442
|
+
maxTokens: options.maxTokens,
|
|
2443
|
+
maxDuration: options.maxDuration
|
|
2135
2444
|
});
|
|
2136
2445
|
} catch (error) {
|
|
2137
2446
|
console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
|