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/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 = 15,
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
- continue;
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 createBrain(model, generate = generateObject) {
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 ${failed ? "fail" : "pass"}">${failed ? "FAIL" : "PASS"}</span> ${escapeHtml(result.summary)} <span class="note">${escapeHtml(result.priority)} \xB7 ${escapeHtml(file)}</span></summary>`
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
- return ` <tr><td><span class="tag ${r.status === "failed" ? "fail" : "pass"}">${r.status === "failed" ? "FAIL" : "PASS"}</span></td><td>${escapeHtml(r.priority)}</td><td>${escapeHtml(r.summary)}</td><td>${escapeHtml(file)}</td><td class="num">${seconds(r.durationMs)}</td></tr>`;
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 === "passed" ? "PASS" : "FAIL",
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(`${passed.length} passed, ${failed.length} failed, ${results.length} total`);
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(formatScoreLine(score, results, options.minScore));
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, { score, durationMs, cwd: options.cwd });
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, baseUrl, cwd) {
1849
+ function printDryRun(selected, config, cwd) {
1675
1850
  console.log(`
1676
- Dry run: ${selected.length} test(s) selected, base_url=${baseUrl}`);
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.base_url, options.cwd);
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 AuthError) {
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
- for (const test of selected) {
1812
- console.log(`
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
- results.push(await runOne(browser, test, config, sessionDir, session, runMask));
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(results, selection.unroutedSkipped, options, sessionDir, Date.now() - startedAt, impact);
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 brain = createPlanner(model);
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 AuthError) {
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.2.2");
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").action(
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").action(async (options) => {
2106
- try {
2107
- process.exitCode = await planCommand({
2108
- cwd: process.cwd(),
2109
- base: options.base,
2110
- url: options.url,
2111
- routes: options.route,
2112
- write: options.write
2113
- });
2114
- } catch (error) {
2115
- console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
2116
- process.exitCode = EXIT_USAGE;
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").action(
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)}`);