blastproof 0.2.1 → 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 {
@@ -215,9 +295,21 @@ var SecretsMask = class {
215
295
  if (value === void 0) {
216
296
  throw new MissingEnvError(name);
217
297
  }
218
- this.secrets.add(value);
298
+ this.add(value);
219
299
  }
220
300
  }
301
+ /**
302
+ * Registers a value and the forms it takes on the way to a prompt. `navigate`
303
+ * reports a resolved URL, and `new URL()` percent-encodes — so a secret with a
304
+ * space stopped matching a literal search and passed through unmasked.
305
+ * This cannot cover every transform a page might apply; see the README.
306
+ */
307
+ add(value) {
308
+ if (!value) return;
309
+ this.secrets.add(value);
310
+ const encoded = encodeURIComponent(value);
311
+ if (encoded !== value) this.secrets.add(encoded);
312
+ }
221
313
  mask(text) {
222
314
  return maskSecrets(text, this.secrets);
223
315
  }
@@ -336,6 +428,7 @@ async function performAction(page, action, ctx) {
336
428
  }
337
429
 
338
430
  // src/runner/executor.ts
431
+ var DEFAULT_MAX_ITERATIONS_PER_STEP = 15;
339
432
  async function defaultSnapshot(page) {
340
433
  const { captureSnapshot } = await import("./snapshot-CAIB2OHX.js");
341
434
  return captureSnapshot(page);
@@ -354,7 +447,7 @@ async function executeTest(page, test, options) {
354
447
  allowedOrigins,
355
448
  resolveValue,
356
449
  maxRetries = 3,
357
- maxIterationsPerStep = 15,
450
+ maxIterationsPerStep = DEFAULT_MAX_ITERATIONS_PER_STEP,
358
451
  mask = (s) => s,
359
452
  snapshot = defaultSnapshot,
360
453
  onEvent = () => {
@@ -396,12 +489,13 @@ async function executeTest(page, test, options) {
396
489
  action = await brain.nextAction({
397
490
  step,
398
491
  isSetup: setup,
399
- snapshot: snap,
400
- lastResult,
492
+ snapshot: mask(snap),
493
+ lastResult: lastResult === void 0 ? void 0 : mask(lastResult),
401
494
  retriesLeft: maxRetries - failedAttempts,
402
495
  iterationsLeft: maxIterationsPerStep - iterations
403
496
  });
404
497
  } catch (error) {
498
+ if (error instanceof BudgetExhaustedError) throw error;
405
499
  failedAttempts++;
406
500
  lastResult = `error: ${error instanceof Error ? error.message : String(error)}`;
407
501
  if (failedAttempts >= maxRetries) {
@@ -420,12 +514,12 @@ async function executeTest(page, test, options) {
420
514
  }
421
515
  if (action.action === "assert") {
422
516
  const expectation = action.expectation ?? action.reasoning;
423
- const judgment = await brain.judge(expectation, snap);
517
+ const judgment = await brain.judge(mask(expectation), mask(snap));
424
518
  const result = judgment.pass ? `ok: assertion passed: ${judgment.reason}` : `assertion failed: ${judgment.reason}`;
425
519
  emitAction(index, action, result);
426
520
  if (judgment.pass) {
427
521
  lastResult = result;
428
- continue;
522
+ break;
429
523
  }
430
524
  failedAttempts++;
431
525
  lastResult = result;
@@ -448,6 +542,7 @@ async function executeTest(page, test, options) {
448
542
  }
449
543
  }
450
544
  } catch (error) {
545
+ if (error instanceof BudgetExhaustedError) throw error;
451
546
  stepFailedReason = mask(error instanceof Error ? error.message : String(error));
452
547
  }
453
548
  const status = stepFailedReason ? "failed" : "passed";
@@ -525,8 +620,7 @@ async function fromStorageState(file) {
525
620
  }
526
621
  return { storageState: parsed };
527
622
  }
528
- function fromStatic(auth) {
529
- const mask = new SecretsMask();
623
+ function fromStatic(auth, mask) {
530
624
  const session = {};
531
625
  if (auth.headers) {
532
626
  const headers = {};
@@ -543,22 +637,21 @@ function fromStatic(auth) {
543
637
  });
544
638
  session.storageState = { cookies, origins: [] };
545
639
  }
546
- return { session, mask };
640
+ return session;
547
641
  }
548
642
  async function runJourney(...args) {
549
643
  try {
550
644
  return await executeTest(...args);
551
645
  } catch (error) {
646
+ if (error instanceof BudgetExhaustedError) throw error;
552
647
  throw new AuthError(
553
648
  `Authentication could not run: ${error instanceof Error ? error.message : String(error)}`
554
649
  );
555
650
  }
556
651
  }
557
652
  async function fromSteps(options) {
558
- const { auth, baseUrl, browser, brain, maxRetries, snapshot = defaultSnapshot, onEvent } = options;
653
+ const { auth, baseUrl, browser, brain, maxRetries, snapshot = defaultSnapshot, onEvent, mask } = options;
559
654
  const steps = auth.steps;
560
- const mask = new SecretsMask();
561
- for (const step of steps) mask.registerFrom(step);
562
655
  const context = await browser.newContext();
563
656
  try {
564
657
  const page = await context.newPage();
@@ -591,7 +684,7 @@ async function fromSteps(options) {
591
684
  );
592
685
  }
593
686
  if (auth.verify) {
594
- const judgment = await brain.judge(auth.verify, await snapshot(page));
687
+ const judgment = await brain.judge(auth.verify, mask.mask(await snapshot(page)));
595
688
  if (!judgment.pass) {
596
689
  throw new AuthError(`Authentication could not be verified: ${mask.mask(judgment.reason)}`);
597
690
  }
@@ -624,7 +717,7 @@ async function authenticate(options) {
624
717
  return fromStorageState(path3.resolve(cwd, auth.storage_state));
625
718
  }
626
719
  if (auth.headers || auth.cookies) {
627
- return fromStatic(auth).session;
720
+ return fromStatic(auth, options.mask);
628
721
  }
629
722
  const session = await fromSteps(options);
630
723
  if (auth.cache) await writeCached(cwd, session);
@@ -654,6 +747,12 @@ var browserSchema = z.object({
654
747
  headless: z.boolean().default(true),
655
748
  timeout_ms: z.number().int().positive().default(3e4)
656
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
+ });
657
756
  var cookieSchema = z.object({
658
757
  name: z.string().min(1),
659
758
  value: z.string(),
@@ -703,14 +802,18 @@ var configSchema = z.object({
703
802
  */
704
803
  allowed_origins: z.array(z.string().url()).optional(),
705
804
  auth: authSchema.optional(),
706
- 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()
707
807
  });
708
808
  var ENV_OVERRIDES = {
709
809
  BLASTPROOF_BASE_URL: ["base_url"],
710
810
  BLASTPROOF_LLM_PROVIDER: ["llm", "provider"],
711
811
  BLASTPROOF_LLM_MODEL: ["llm", "model"],
712
812
  BLASTPROOF_LLM_BASE_URL: ["llm", "base_url"],
713
- 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"]
714
817
  };
715
818
  function applyEnvOverrides(data, env = process.env) {
716
819
  const merged = data && typeof data === "object" ? { ...data } : {};
@@ -958,10 +1061,16 @@ var MalformedModelOutputError = class extends Error {
958
1061
  this.name = "MalformedModelOutputError";
959
1062
  }
960
1063
  };
961
- 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) {
962
1071
  return {
963
1072
  async nextAction(input) {
964
- const result = await generate({
1073
+ const result = await countedGenerate(generate, budget, {
965
1074
  model,
966
1075
  schema: agentActionSchema,
967
1076
  system: agentSystemPrompt(),
@@ -976,7 +1085,7 @@ function createBrain(model, generate = generateObject) {
976
1085
  return parsed.data;
977
1086
  },
978
1087
  async judge(expectation, snapshot) {
979
- const result = await generate({
1088
+ const result = await countedGenerate(generate, budget, {
980
1089
  model,
981
1090
  schema: assertJudgmentSchema,
982
1091
  system: assertSystemPrompt(),
@@ -992,10 +1101,10 @@ function createBrain(model, generate = generateObject) {
992
1101
  }
993
1102
  };
994
1103
  }
995
- function createPlanner(model, generate = generateObject) {
1104
+ function createPlanner(model, generate = generateObject, budget) {
996
1105
  return {
997
1106
  async planTest(input) {
998
- const result = await generate({
1107
+ const result = await countedGenerate(generate, budget, {
999
1108
  model,
1000
1109
  schema: generatedTestSchema,
1001
1110
  system: plannerSystemPrompt(),
@@ -1168,7 +1277,7 @@ function renderTestYaml(draft, meta) {
1168
1277
  });
1169
1278
  }
1170
1279
  async function generateForRoute(page, options) {
1171
- const { route, baseUrl, changedFiles, brain, snapshot = defaultSnapshot } = options;
1280
+ const { route, baseUrl, changedFiles, brain, mask, snapshot = defaultSnapshot } = options;
1172
1281
  const url = new URL(route, baseUrl).toString();
1173
1282
  try {
1174
1283
  await page.goto(url, { timeout: 3e4 });
@@ -1177,7 +1286,11 @@ async function generateForRoute(page, options) {
1177
1286
  `Cannot load ${url}: ${error instanceof Error ? error.message : String(error)}`
1178
1287
  );
1179
1288
  }
1180
- const generated = await brain.planTest({ route, snapshot: await snapshot(page), changedFiles });
1289
+ const generated = await brain.planTest({
1290
+ route,
1291
+ snapshot: mask(await snapshot(page)),
1292
+ changedFiles
1293
+ });
1181
1294
  const leaked = findSecretLiterals(generated.steps);
1182
1295
  if (leaked.length > 0) {
1183
1296
  throw new PlannerError(
@@ -1249,6 +1362,8 @@ h1 { font-size:1.35rem; margin:0 0 .25rem; }
1249
1362
  .verdict { font-weight:600; }
1250
1363
  .verdict.pass { color:var(--pass); } .verdict.fail { color:var(--fail); }
1251
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; }
1252
1367
  table { width:100%; border-collapse:collapse; margin-bottom:1.5rem; }
1253
1368
  th, td { text-align:left; padding:.5rem .6rem; border-bottom:1px solid var(--line);
1254
1369
  font-size:.9rem; vertical-align:top; }
@@ -1285,10 +1400,13 @@ function renderSteps(steps) {
1285
1400
  }
1286
1401
  async function renderTest(result, cwd) {
1287
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";
1288
1406
  const file = cwd ? path7.relative(cwd, result.file) : result.file;
1289
1407
  const parts = [
1290
- ` <details${failed ? " open" : ""}>`,
1291
- ` <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>`
1292
1410
  ];
1293
1411
  if (failed) {
1294
1412
  if (result.failedStep) {
@@ -1298,6 +1416,9 @@ async function renderTest(result, cwd) {
1298
1416
  parts.push(` <p class="reason">${escapeHtml(result.reason)}</p>`);
1299
1417
  }
1300
1418
  }
1419
+ if (notRun && result.reason) {
1420
+ parts.push(` <p class="note">${escapeHtml(result.reason)}</p>`);
1421
+ }
1301
1422
  parts.push(` ${renderSteps(result.steps)}`);
1302
1423
  if (failed && result.screenshot) {
1303
1424
  const embedded = await embedScreenshot(result.screenshot);
@@ -1311,13 +1432,20 @@ async function renderTest(result, cwd) {
1311
1432
  async function renderHtml(results, skipped, meta) {
1312
1433
  const passed = results.filter((r) => r.status === "passed").length;
1313
1434
  const failures = results.filter((r) => r.status === "failed");
1435
+ const notRun = results.filter((r) => r.status === "not-run");
1314
1436
  const generatedAt = (meta.generatedAt ?? /* @__PURE__ */ new Date()).toISOString();
1315
- const ordered = [...failures, ...results.filter((r) => r.status === "passed")];
1437
+ const ordered = [...failures, ...notRun, ...results.filter((r) => r.status === "passed")];
1316
1438
  const detail = (await Promise.all(ordered.map((r) => renderTest(r, meta.cwd)))).join("\n");
1317
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
+ `;
1318
1443
  const rows = results.map((r) => {
1319
1444
  const file = meta.cwd ? path7.relative(meta.cwd, r.file) : r.file;
1320
- 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>`;
1321
1449
  }).join("\n");
1322
1450
  const skippedRows = skipped.map((s) => {
1323
1451
  const file = meta.cwd ? path7.relative(meta.cwd, s.path) : s.path;
@@ -1336,10 +1464,10 @@ async function renderHtml(results, skipped, meta) {
1336
1464
  <h1>blastproof report</h1>
1337
1465
  <p class="sub">${escapeHtml(generatedAt)} \xB7 ${seconds(meta.durationMs)}</p>
1338
1466
 
1339
- <section class="score">
1467
+ ${banner} <section class="score">
1340
1468
  <b>${meta.score}</b>
1341
1469
  ${verdict}
1342
- <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>
1343
1471
  </section>
1344
1472
 
1345
1473
  <table>
@@ -1382,14 +1510,27 @@ function seconds2(ms) {
1382
1510
  function renderJUnit(results, skipped, meta) {
1383
1511
  const relative = (file) => meta.cwd ? path8.relative(meta.cwd, file) : file;
1384
1512
  const failures = results.filter((result) => result.status === "failed").length;
1513
+ const notRun = results.filter((result) => result.status === "not-run");
1385
1514
  const lines = [
1386
1515
  '<?xml version="1.0" encoding="UTF-8"?>',
1387
- `<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)}">`,
1388
1517
  " <properties>",
1389
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
+ ] : [],
1390
1523
  " </properties>"
1391
1524
  ];
1392
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
+ }
1393
1534
  const open = ` <testcase classname="${escapeXml(relative(result.file))}" name="${escapeXml(result.summary)}" time="${seconds2(result.durationMs)}"`;
1394
1535
  if (result.status === "passed") {
1395
1536
  lines.push(`${open}/>`);
@@ -1425,6 +1566,7 @@ function computeScore(results) {
1425
1566
  let total = 0;
1426
1567
  let passed = 0;
1427
1568
  for (const result of results) {
1569
+ if (result.status === "not-run") continue;
1428
1570
  const weight = WEIGHTS[result.priority] ?? DEFAULT_WEIGHT;
1429
1571
  total += weight;
1430
1572
  if (result.status === "passed") passed += weight;
@@ -1440,6 +1582,10 @@ function formatScoreLine(score, results, threshold) {
1440
1582
  if (threshold === void 0) return `Score: ${score}`;
1441
1583
  return score >= threshold ? `Score: ${score} \u2014 min-score ${threshold}: pass` : `Score: ${score} \u2014 min-score ${threshold}: FAIL (below threshold)`;
1442
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
+ }
1443
1589
 
1444
1590
  // src/runner/selection.ts
1445
1591
  function matchesFilters(test, filters) {
@@ -1479,6 +1625,14 @@ function selectImpactedTests(tests, affectedRoutes, filters = { tags: [] }) {
1479
1625
  var EXIT_OK = 0;
1480
1626
  var EXIT_FAILED = 1;
1481
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
+ }
1482
1636
  function sessionId() {
1483
1637
  return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1484
1638
  }
@@ -1493,12 +1647,33 @@ function applyUrlOverride(config, url) {
1493
1647
  }
1494
1648
  return { ...config, base_url: url };
1495
1649
  }
1496
- function resolveSecretsAndSteps(test) {
1650
+ function buildRunMask(config, tests) {
1497
1651
  const mask = new SecretsMask();
1652
+ for (const step of config.auth?.steps ?? []) mask.registerFrom(step);
1653
+ for (const value of Object.values(config.auth?.headers ?? {})) mask.registerFrom(value);
1654
+ for (const cookie of config.auth?.cookies ?? []) mask.registerFrom(cookie.value);
1655
+ for (const test of tests) {
1656
+ for (const step of [...test.setup ?? [], ...test.steps]) mask.registerFrom(step);
1657
+ }
1658
+ return mask;
1659
+ }
1660
+ function resolveSecretsAndSteps(test) {
1498
1661
  for (const step of [...test.setup ?? [], ...test.steps]) {
1499
- mask.registerFrom(step);
1662
+ new SecretsMask().registerFrom(step);
1500
1663
  }
1501
- return { test, mask };
1664
+ return { test };
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
+ }));
1502
1677
  }
1503
1678
  function printEvent(event) {
1504
1679
  switch (event.type) {
@@ -1519,12 +1694,18 @@ function printEvent(event) {
1519
1694
  break;
1520
1695
  }
1521
1696
  }
1697
+ function statusLabel(status) {
1698
+ if (status === "passed") return "PASS";
1699
+ if (status === "failed") return "FAIL";
1700
+ return "NOT RUN";
1701
+ }
1522
1702
  function printSummary(results) {
1523
1703
  const passed = results.filter((r) => r.status === "passed");
1524
1704
  const failed = results.filter((r) => r.status === "failed");
1705
+ const notRun = results.filter((r) => r.status === "not-run");
1525
1706
  console.log("\n--- Summary ---------------------------------------------------");
1526
1707
  const rows = results.map((r) => ({
1527
- status: r.status === "passed" ? "PASS" : "FAIL",
1708
+ status: statusLabel(r.status),
1528
1709
  priority: r.priority,
1529
1710
  summary: r.summary,
1530
1711
  duration: `${(r.durationMs / 1e3).toFixed(1)}s`
@@ -1537,7 +1718,9 @@ function printSummary(results) {
1537
1718
  );
1538
1719
  }
1539
1720
  console.log("---------------------------------------------------------------");
1540
- 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
+ );
1541
1724
  for (const r of failed) {
1542
1725
  console.log(`
1543
1726
  X ${r.summary} (${r.file})`);
@@ -1545,13 +1728,18 @@ X ${r.summary} (${r.file})`);
1545
1728
  if (r.reason) console.log(` reason: ${r.reason}`);
1546
1729
  if (r.screenshot) console.log(` screenshot: ${r.screenshot}`);
1547
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
+ }
1548
1736
  }
1549
- async function runOne(browser, test, config, sessionDir, session) {
1550
- 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);
1551
1739
  let resolved;
1552
- let mask;
1740
+ const mask = runMask;
1553
1741
  try {
1554
- ({ test: resolved, mask } = resolveSecretsAndSteps(test));
1742
+ ({ test: resolved } = resolveSecretsAndSteps(test));
1555
1743
  } catch (error) {
1556
1744
  if (error instanceof MissingEnvError) {
1557
1745
  return {
@@ -1610,13 +1798,20 @@ function printImpactReport(impact, selection, cwd) {
1610
1798
  }
1611
1799
  console.log("---------------------------------------------------------------");
1612
1800
  }
1613
- async function finalize(results, skipped, options, sessionDir, durationMs, impact) {
1801
+ async function finalize(results, skipped, options, sessionDir, durationMs, impact, incomplete) {
1614
1802
  if (results.length > 0) printSummary(results);
1615
1803
  const score = computeScore(results);
1616
- console.log(formatScoreLine(score, results, options.minScore));
1804
+ console.log(
1805
+ incomplete ? formatIncompleteLine(score, incomplete.message) : formatScoreLine(score, results, options.minScore)
1806
+ );
1617
1807
  if (options.junit) {
1618
1808
  const target = typeof options.junit === "string" ? path9.resolve(options.cwd, options.junit) : path9.join(sessionDir, "junit.xml");
1619
- 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
+ });
1620
1815
  await writeJUnit(target, xml);
1621
1816
  console.log(`JUnit report: ${path9.relative(options.cwd, target)}`);
1622
1817
  }
@@ -1626,33 +1821,48 @@ async function finalize(results, skipped, options, sessionDir, durationMs, impac
1626
1821
  score,
1627
1822
  durationMs,
1628
1823
  minScore: options.minScore,
1629
- cwd: options.cwd
1824
+ cwd: options.cwd,
1825
+ incomplete: incomplete?.message
1630
1826
  });
1631
1827
  await writeHtml(target, html);
1632
1828
  console.log(`HTML report: ${path9.relative(options.cwd, target)}`);
1633
1829
  }
1634
- const unclassified = options.failOnUnmapped ? impact?.unmappedFiles ?? [] : [];
1635
- if (unclassified.length > 0) {
1636
- console.error(
1637
- `error: ${unclassified.length} changed file(s) match no routes: or ignore: glob, so their blast radius is unknown:`
1638
- );
1639
- for (const file of unclassified) console.error(` ${file}`);
1640
- console.error(
1641
- "Map them in routes: if they can affect a page, or list them in ignore: if they cannot."
1642
- );
1643
- return EXIT_FAILED;
1644
- }
1830
+ if (incomplete) return EXIT_FAILED;
1831
+ if (reportUnclassified(options, impact)) return EXIT_FAILED;
1645
1832
  if (options.minScore !== void 0) {
1646
1833
  return score >= options.minScore ? EXIT_OK : EXIT_FAILED;
1647
1834
  }
1648
1835
  return results.some((result) => result.status === "failed") ? EXIT_FAILED : EXIT_OK;
1649
1836
  }
1650
- function printDryRun(selected, baseUrl, cwd) {
1837
+ function reportUnclassified(options, impact) {
1838
+ const unclassified = options.failOnUnmapped ? impact?.unmappedFiles ?? [] : [];
1839
+ if (unclassified.length === 0) return false;
1840
+ console.error(
1841
+ `error: ${unclassified.length} changed file(s) match no routes: or ignore: glob, so their blast radius is unknown:`
1842
+ );
1843
+ for (const file of unclassified) console.error(` ${file}`);
1844
+ console.error(
1845
+ "Map them in routes: if they can affect a page, or list them in ignore: if they cannot."
1846
+ );
1847
+ return true;
1848
+ }
1849
+ function printDryRun(selected, config, cwd) {
1651
1850
  console.log(`
1652
- Dry run: ${selected.length} test(s) selected, base_url=${baseUrl}`);
1851
+ Dry run: ${selected.length} test(s) selected, base_url=${config.base_url}`);
1653
1852
  for (const test of selected) {
1654
1853
  console.log(` ${test.summary} [${test.priority}] (${path9.relative(cwd, test.path)})`);
1655
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
+ );
1656
1866
  console.log("Dry run: no browser launched, no LLM calls made.");
1657
1867
  }
1658
1868
  async function runCommand(options) {
@@ -1668,6 +1878,12 @@ async function runCommand(options) {
1668
1878
  }
1669
1879
  const impacted = options.impacted ?? false;
1670
1880
  const base = options.base ?? "main";
1881
+ if (options.failOnUnmapped && !impacted) {
1882
+ console.error(
1883
+ "error: --fail-on-unmapped classifies the files in a diff, so it requires --impacted."
1884
+ );
1885
+ return EXIT_USAGE;
1886
+ }
1671
1887
  let impact;
1672
1888
  if (impacted) {
1673
1889
  let changedFiles;
@@ -1722,8 +1938,14 @@ async function runCommand(options) {
1722
1938
  printImpactReport(impact, selection, options.cwd);
1723
1939
  }
1724
1940
  if (options.dryRun) {
1725
- printDryRun(selected, config.base_url, options.cwd);
1726
- return EXIT_OK;
1941
+ printDryRun(selected, config, options.cwd);
1942
+ if (results.length > 0) {
1943
+ console.error(`
1944
+ ${results.length} test file(s) could not be parsed:`);
1945
+ for (const broken of results) console.error(` ${broken.file}: ${broken.reason ?? "invalid"}`);
1946
+ return EXIT_FAILED;
1947
+ }
1948
+ return reportUnclassified(options, impact) ? EXIT_FAILED : EXIT_OK;
1727
1949
  }
1728
1950
  const sessionDir = path9.join(options.cwd, ".blastproof", "reports", sessionId());
1729
1951
  const startedAt = Date.now();
@@ -1746,7 +1968,10 @@ async function runCommand(options) {
1746
1968
  console.log(
1747
1969
  `blastproof run: ${selected.length} test(s), provider=${provider} model=${modelId}, base_url=${config.base_url}`
1748
1970
  );
1971
+ const runMask = buildRunMask(config, parsed);
1972
+ const budget = options.budget ?? new RunBudget(resolveBudgetOptions(config, options));
1749
1973
  const browser = await chromium.launch({ headless: config.browser.headless });
1974
+ let incomplete;
1750
1975
  try {
1751
1976
  let session;
1752
1977
  if (config.auth) {
@@ -1758,27 +1983,54 @@ async function runCommand(options) {
1758
1983
  baseUrl: config.base_url,
1759
1984
  allowedOrigins: config.allowed_origins,
1760
1985
  browser,
1761
- brain: createBrain(createModel(config.llm).model),
1986
+ brain: createBrain(createModel(config.llm).model, void 0, budget),
1762
1987
  maxRetries: config.max_retries_per_step,
1988
+ mask: runMask,
1763
1989
  onEvent: printEvent
1764
1990
  });
1765
1991
  } catch (error) {
1766
- if (error instanceof AuthError) {
1992
+ if (error instanceof BudgetExhaustedError) {
1993
+ incomplete = error;
1994
+ } else if (error instanceof AuthError) {
1767
1995
  console.error(`error: ${error.message}`);
1768
1996
  return EXIT_USAGE;
1997
+ } else {
1998
+ throw error;
1769
1999
  }
1770
- throw error;
1771
2000
  }
1772
2001
  }
1773
- for (const test of selected) {
1774
- 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(`
1775
2008
  > ${test.summary} [${test.priority}] (${path9.relative(options.cwd, test.path)})`);
1776
- results.push(await runOne(browser, test, config, sessionDir, session));
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
+ }
1777
2021
  }
1778
2022
  } finally {
1779
2023
  await browser.close();
1780
2024
  }
1781
- 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
+ );
1782
2034
  }
1783
2035
 
1784
2036
  // src/commands/plan.ts
@@ -1873,14 +2125,23 @@ async function planCommand(options) {
1873
2125
  throw error;
1874
2126
  }
1875
2127
  const { model, provider, modelId } = createModel(config.llm);
1876
- const brain = createPlanner(model);
2128
+ const budget = options.budget ?? new RunBudget(resolveBudgetOptions(config, options));
2129
+ const brain = createPlanner(model, void 0, budget);
1877
2130
  const base = options.routes && options.routes.length > 0 ? void 0 : options.base ?? "main";
1878
2131
  console.log(
1879
2132
  `blastproof plan: ${work.length} route(s), provider=${provider} model=${modelId}, base_url=${config.base_url}`
1880
2133
  );
2134
+ const mask = new SecretsMask();
2135
+ for (const step of config.auth?.steps ?? []) mask.registerFrom(step);
2136
+ for (const value of Object.values(config.auth?.headers ?? {})) mask.registerFrom(value);
2137
+ for (const cookie of config.auth?.cookies ?? []) mask.registerFrom(cookie.value);
2138
+ for (const test of suite) {
2139
+ for (const step of [...test.setup ?? [], ...test.steps]) mask.registerFrom(step);
2140
+ }
1881
2141
  const generated = [];
1882
2142
  const written = [];
1883
2143
  const failed = [];
2144
+ let incomplete;
1884
2145
  const browser = await chromium2.launch({ headless: config.browser.headless });
1885
2146
  try {
1886
2147
  let session;
@@ -1893,19 +2154,23 @@ async function planCommand(options) {
1893
2154
  baseUrl: config.base_url,
1894
2155
  allowedOrigins: config.allowed_origins,
1895
2156
  browser,
1896
- brain: createBrain(createModel(config.llm).model),
2157
+ brain: createBrain(createModel(config.llm).model, void 0, budget),
1897
2158
  maxRetries: config.max_retries_per_step,
2159
+ mask,
1898
2160
  onEvent: printAuthEvent
1899
2161
  });
1900
2162
  } catch (error) {
1901
- if (error instanceof AuthError) {
2163
+ if (error instanceof BudgetExhaustedError) {
2164
+ incomplete = error;
2165
+ } else if (error instanceof AuthError) {
1902
2166
  console.error(`error: ${error.message}`);
1903
2167
  return EXIT_USAGE;
2168
+ } else {
2169
+ throw error;
1904
2170
  }
1905
- throw error;
1906
2171
  }
1907
2172
  }
1908
- for (const { route, changedFiles } of work) {
2173
+ for (const { route, changedFiles } of incomplete ? [] : work) {
1909
2174
  console.log(`
1910
2175
  > ${route}`);
1911
2176
  let draft;
@@ -1917,9 +2182,14 @@ async function planCommand(options) {
1917
2182
  route,
1918
2183
  baseUrl: config.base_url,
1919
2184
  changedFiles,
1920
- brain
2185
+ brain,
2186
+ mask: (text) => mask.mask(text)
1921
2187
  });
1922
2188
  } catch (error) {
2189
+ if (error instanceof BudgetExhaustedError) {
2190
+ incomplete = error;
2191
+ break;
2192
+ }
1923
2193
  failed.push({ route, reason: error instanceof Error ? error.message : String(error) });
1924
2194
  console.log(` X failed: ${error instanceof Error ? error.message : String(error)}`);
1925
2195
  continue;
@@ -1949,6 +2219,7 @@ ${renderTestYaml(draft, { route, base })}`);
1949
2219
  } finally {
1950
2220
  await browser.close();
1951
2221
  }
2222
+ const notAttempted = incomplete ? work.map((item) => item.route).filter((route) => !generated.includes(route) && !failed.some((f) => f.route === route)) : [];
1952
2223
  console.log("\n--- Plan -------------------------------------------------------");
1953
2224
  console.log(`Generated: ${generated.length > 0 ? generated.join(", ") : "none"}`);
1954
2225
  if (written.length > 0) {
@@ -1962,8 +2233,15 @@ ${renderTestYaml(draft, { route, base })}`);
1962
2233
  console.log("Failed:");
1963
2234
  for (const { route, reason } of failed) console.log(` ${route}: ${reason}`);
1964
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
+ }
1965
2243
  console.log("---------------------------------------------------------------");
1966
- return failed.length > 0 ? EXIT_FAILED : EXIT_OK;
2244
+ return incomplete || failed.length > 0 ? EXIT_FAILED : EXIT_OK;
1967
2245
  }
1968
2246
 
1969
2247
  // src/commands/test.ts
@@ -1972,6 +2250,17 @@ function section(title) {
1972
2250
  === ${title} ${"=".repeat(Math.max(0, 58 - title.length))}`);
1973
2251
  }
1974
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));
1975
2264
  section("Verify: tests covering the affected routes");
1976
2265
  const runCode = await runCommand({
1977
2266
  cwd: options.cwd,
@@ -1982,7 +2271,8 @@ async function testCommand(options) {
1982
2271
  minScore: options.minScore,
1983
2272
  junit: options.junit,
1984
2273
  html: options.html,
1985
- failOnUnmapped: options.failOnUnmapped
2274
+ failOnUnmapped: options.failOnUnmapped,
2275
+ budget
1986
2276
  });
1987
2277
  if (runCode === EXIT_USAGE) return EXIT_USAGE;
1988
2278
  section("Draft: affected routes no test covers");
@@ -1990,7 +2280,8 @@ async function testCommand(options) {
1990
2280
  cwd: options.cwd,
1991
2281
  base: options.base,
1992
2282
  url: options.url,
1993
- write: options.write
2283
+ write: options.write,
2284
+ budget
1994
2285
  });
1995
2286
  if (planCode === EXIT_USAGE) return EXIT_USAGE;
1996
2287
  console.log(
@@ -2016,8 +2307,26 @@ function parseMinScore(value) {
2016
2307
  }
2017
2308
  return score;
2018
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
+ }
2019
2328
  var program = new Command();
2020
- program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.2.1");
2329
+ program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.3.0");
2021
2330
  program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
2022
2331
  try {
2023
2332
  const result = await initProject(process.cwd());
@@ -2032,7 +2341,19 @@ program.command("run").description("Discover and run all tests under .blastproof
2032
2341
  "--min-score <n>",
2033
2342
  "require a weighted score of at least n (0-100); replaces the all-must-pass rule",
2034
2343
  parseMinScore
2035
- ).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(
2036
2357
  async (options) => {
2037
2358
  try {
2038
2359
  process.exitCode = await runCommand({
@@ -2047,7 +2368,10 @@ program.command("run").description("Discover and run all tests under .blastproof
2047
2368
  minScore: options.minScore,
2048
2369
  junit: options.junit,
2049
2370
  html: options.html,
2050
- failOnUnmapped: options.failOnUnmapped
2371
+ failOnUnmapped: options.failOnUnmapped,
2372
+ maxLlmCalls: options.maxLlmCalls,
2373
+ maxTokens: options.maxTokens,
2374
+ maxDuration: options.maxDuration
2051
2375
  });
2052
2376
  } catch (error) {
2053
2377
  console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
@@ -2055,25 +2379,54 @@ program.command("run").description("Discover and run all tests under .blastproof
2055
2379
  }
2056
2380
  }
2057
2381
  );
2058
- 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) => {
2059
- try {
2060
- process.exitCode = await planCommand({
2061
- cwd: process.cwd(),
2062
- base: options.base,
2063
- url: options.url,
2064
- routes: options.route,
2065
- write: options.write
2066
- });
2067
- } catch (error) {
2068
- console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
2069
- 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
+ }
2070
2411
  }
2071
- });
2412
+ );
2072
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(
2073
2414
  "--min-score <n>",
2074
2415
  "require a weighted score of at least n (0-100); replaces the all-must-pass rule",
2075
2416
  parseMinScore
2076
- ).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(
2077
2430
  async (options) => {
2078
2431
  try {
2079
2432
  process.exitCode = await testCommand({
@@ -2084,7 +2437,10 @@ program.command("test").description("Full PR pipeline: run the tests covering th
2084
2437
  junit: options.junit,
2085
2438
  html: options.html,
2086
2439
  write: options.write,
2087
- failOnUnmapped: options.failOnUnmapped
2440
+ failOnUnmapped: options.failOnUnmapped,
2441
+ maxLlmCalls: options.maxLlmCalls,
2442
+ maxTokens: options.maxTokens,
2443
+ maxDuration: options.maxDuration
2088
2444
  });
2089
2445
  } catch (error) {
2090
2446
  console.error(`error: ${error instanceof Error ? error.message : String(error)}`);