blastproof 0.3.0 → 0.5.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
@@ -26,6 +26,9 @@ llm:
26
26
 
27
27
  browser:
28
28
  headless: true
29
+ # How long to wait for an element to appear (self-healing resolution) as well as
30
+ # for the action performed on it and for navigation. Raising it makes a slow app
31
+ # wait longer to succeed, and a genuinely missing element take longer to fail.
29
32
  timeout_ms: 30000
30
33
 
31
34
  # Budget of self-healing retries per step (element re-resolution via accessibility tree).
@@ -152,18 +155,25 @@ function formatInitGuidance(result) {
152
155
  lines.push(
153
156
  "",
154
157
  "Next steps:",
155
- " 1. Set your LLM API key: export ANTHROPIC_API_KEY=... (or OPENAI_API_KEY; ollama needs no key)",
156
- " 2. Start your app, then point base_url at it in .blastproof/config.yaml",
157
- " 3. Run your tests: blastproof run",
158
+ // Deliberately keyless first. A first-time-user trial found that following
159
+ // the old order — key, app, run — meant hitting the missing-key error and
160
+ // then a browser-launch failure with nothing successful behind you, which
161
+ // reads as "this is broken" rather than "one leg needs setting up". Seeing
162
+ // real output first reframes both.
163
+ " 1. Point base_url at your app in .blastproof/config.yaml",
164
+ " 2. Check the setup: blastproof run --dry-run (no API key or browser needed)",
165
+ " 3. Set your LLM API key: export ANTHROPIC_API_KEY=... (or OPENAI_API_KEY; ollama needs no key)",
166
+ " 4. Install the browser: npx playwright install chromium",
167
+ " 5. Run your tests: blastproof run",
158
168
  "",
159
- "First time with Playwright? Install the browser: npx playwright install chromium"
169
+ "Without a key or a browser you can still map a diff to routes and gate on",
170
+ "coverage: blastproof run --impacted --fail-on-unmapped --dry-run"
160
171
  );
161
172
  return lines.join("\n");
162
173
  }
163
174
 
164
175
  // src/commands/plan.ts
165
176
  import path10 from "path";
166
- import { chromium as chromium2 } from "playwright";
167
177
 
168
178
  // src/auth.ts
169
179
  import { readFile, writeFile as writeFile2, mkdir as mkdir3 } from "fs/promises";
@@ -246,7 +256,7 @@ function estimateMaxModelCalls(tests, maxIterationsPerStep, maxRetriesPerStep) {
246
256
  (sum, test) => sum + (test.setup?.length ?? 0) + test.steps.length,
247
257
  0
248
258
  );
249
- return totalSteps * (maxIterationsPerStep + maxRetriesPerStep);
259
+ return totalSteps * (maxIterationsPerStep + maxRetriesPerStep + Math.min(maxIterationsPerStep, maxRetriesPerStep));
250
260
  }
251
261
 
252
262
  // src/runner/env.ts
@@ -387,7 +397,7 @@ async function performAction(page, action, ctx) {
387
397
  const value = resolve(requireValue(action), ctx);
388
398
  const url = new URL(value, ctx.baseUrl);
389
399
  assertAllowedOrigin(url, ctx);
390
- await page.goto(url.toString(), { timeout: 3e4 });
400
+ await page.goto(url.toString(), { timeout: ctx.resolveTimeoutMs ?? 3e4 });
391
401
  return `ok: navigated to ${url.toString()}`;
392
402
  }
393
403
  case "click": {
@@ -429,9 +439,22 @@ async function performAction(page, action, ctx) {
429
439
 
430
440
  // src/runner/executor.ts
431
441
  var DEFAULT_MAX_ITERATIONS_PER_STEP = 15;
432
- async function defaultSnapshot(page) {
442
+ var SETTLE_TIMEOUT_MS = 2e3;
443
+ async function waitForSettled(page) {
444
+ const deadline = Date.now() + SETTLE_TIMEOUT_MS;
445
+ const urlBefore = page.url();
446
+ await page.waitForLoadState("networkidle", { timeout: SETTLE_TIMEOUT_MS }).catch(() => {
447
+ });
448
+ if (page.url() === urlBefore) return;
449
+ const remaining = deadline - Date.now();
450
+ if (remaining > 0) {
451
+ await page.waitForLoadState("networkidle", { timeout: remaining }).catch(() => {
452
+ });
453
+ }
454
+ }
455
+ async function defaultSnapshot(page, maxLines) {
433
456
  const { captureSnapshot } = await import("./snapshot-CAIB2OHX.js");
434
- return captureSnapshot(page);
457
+ return captureSnapshot(page, { maxLines });
435
458
  }
436
459
  function slugify(text) {
437
460
  const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
@@ -448,11 +471,14 @@ async function executeTest(page, test, options) {
448
471
  resolveValue,
449
472
  maxRetries = 3,
450
473
  maxIterationsPerStep = DEFAULT_MAX_ITERATIONS_PER_STEP,
474
+ timeoutMs,
451
475
  mask = (s) => s,
452
- snapshot = defaultSnapshot,
476
+ snapshot,
477
+ maxSnapshotLines,
453
478
  onEvent = () => {
454
479
  }
455
480
  } = options;
481
+ const takeSnapshot = snapshot ?? ((page2) => defaultSnapshot(page2, maxSnapshotLines));
456
482
  const startedAt = Date.now();
457
483
  const stepResults = [];
458
484
  const allSteps = [
@@ -483,7 +509,8 @@ async function executeTest(page, test, options) {
483
509
  if (iterations >= maxIterationsPerStep) {
484
510
  throw new StepFailure(`step exceeded ${maxIterationsPerStep} actions without completing`);
485
511
  }
486
- const snap = await snapshot(page);
512
+ await waitForSettled(page);
513
+ const snap = await takeSnapshot(page);
487
514
  let action;
488
515
  try {
489
516
  action = await brain.nextAction({
@@ -514,7 +541,12 @@ async function executeTest(page, test, options) {
514
541
  }
515
542
  if (action.action === "assert") {
516
543
  const expectation = action.expectation ?? action.reasoning;
517
- const judgment = await brain.judge(mask(expectation), mask(snap));
544
+ let judgment = await brain.judge(mask(expectation), mask(snap));
545
+ if (!judgment.pass) {
546
+ await waitForSettled(page);
547
+ const freshSnap = await takeSnapshot(page);
548
+ judgment = await brain.judge(mask(expectation), mask(freshSnap));
549
+ }
518
550
  const result = judgment.pass ? `ok: assertion passed: ${judgment.reason}` : `assertion failed: ${judgment.reason}`;
519
551
  emitAction(index, action, result);
520
552
  if (judgment.pass) {
@@ -529,7 +561,12 @@ async function executeTest(page, test, options) {
529
561
  continue;
530
562
  }
531
563
  try {
532
- const result = await performAction(page, action, { baseUrl, allowedOrigins, resolveValue });
564
+ const result = await performAction(page, action, {
565
+ baseUrl,
566
+ allowedOrigins,
567
+ resolveValue,
568
+ resolveTimeoutMs: timeoutMs
569
+ });
533
570
  lastResult = result;
534
571
  emitAction(index, action, result);
535
572
  } catch (error) {
@@ -650,8 +687,9 @@ async function runJourney(...args) {
650
687
  }
651
688
  }
652
689
  async function fromSteps(options) {
653
- const { auth, baseUrl, browser, brain, maxRetries, snapshot = defaultSnapshot, onEvent, mask } = options;
690
+ const { auth, baseUrl, browser, brain, maxRetries, snapshot, timeoutMs, maxSnapshotLines, onEvent, mask } = options;
654
691
  const steps = auth.steps;
692
+ const takeSnapshot = snapshot ?? ((page) => defaultSnapshot(page, maxSnapshotLines));
655
693
  const context = await browser.newContext();
656
694
  try {
657
695
  const page = await context.newPage();
@@ -673,8 +711,9 @@ async function fromSteps(options) {
673
711
  allowedOrigins: options.allowedOrigins,
674
712
  resolveValue: (value) => substituteEnv(value),
675
713
  maxRetries,
714
+ timeoutMs,
676
715
  mask: (text) => mask.mask(text),
677
- snapshot,
716
+ snapshot: takeSnapshot,
678
717
  onEvent
679
718
  }
680
719
  );
@@ -684,7 +723,7 @@ async function fromSteps(options) {
684
723
  );
685
724
  }
686
725
  if (auth.verify) {
687
- const judgment = await brain.judge(auth.verify, mask.mask(await snapshot(page)));
726
+ const judgment = await brain.judge(auth.verify, mask.mask(await takeSnapshot(page)));
688
727
  if (!judgment.pass) {
689
728
  throw new AuthError(`Authentication could not be verified: ${mask.mask(judgment.reason)}`);
690
729
  }
@@ -745,7 +784,19 @@ var llmSchema = z.object({
745
784
  });
746
785
  var browserSchema = z.object({
747
786
  headless: z.boolean().default(true),
748
- timeout_ms: z.number().int().positive().default(3e4)
787
+ /**
788
+ * Bounds every wait: resolving a target element on the accessibility tree, and
789
+ * navigating. An explicit per-call Playwright timeout always overrides
790
+ * `page.setDefaultTimeout()`, so this must be threaded through the action
791
+ * context rather than relied on ambiently (design D2, runner/actions.ts).
792
+ */
793
+ timeout_ms: z.number().int().positive().default(3e4),
794
+ /**
795
+ * Caps accessibility-tree lines sent to the model per snapshot. Optional:
796
+ * undefined lets `runner/snapshot.ts`'s own default (200) apply, so a config
797
+ * that never mentions this behaves exactly as before it existed.
798
+ */
799
+ max_snapshot_lines: z.number().int().positive().optional()
749
800
  });
750
801
  var budgetSchema = z.object({
751
802
  max_llm_calls: z.coerce.number().int().positive().optional(),
@@ -844,6 +895,37 @@ var ConfigError = class extends Error {
844
895
  function formatIssues(error) {
845
896
  return error.issues.map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
846
897
  }
898
+ function unwrapToObjectSchema(schema) {
899
+ let current = schema;
900
+ for (; ; ) {
901
+ if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) {
902
+ current = current.unwrap();
903
+ } else if (current instanceof z.ZodDefault) {
904
+ current = current.removeDefault();
905
+ } else if (current instanceof z.ZodEffects) {
906
+ current = current.innerType();
907
+ } else {
908
+ break;
909
+ }
910
+ }
911
+ return current instanceof z.ZodObject ? current : void 0;
912
+ }
913
+ function findUnknownConfigKeys(data, schema = configSchema, path11 = []) {
914
+ const object = unwrapToObjectSchema(schema);
915
+ if (!object || data === null || typeof data !== "object" || Array.isArray(data)) return [];
916
+ const shape = object.shape;
917
+ const warnings = [];
918
+ for (const key of Object.keys(data)) {
919
+ const fieldPath = [...path11, key];
920
+ const fieldSchema = shape[key];
921
+ if (!fieldSchema) {
922
+ warnings.push(fieldPath.join("."));
923
+ continue;
924
+ }
925
+ warnings.push(...findUnknownConfigKeys(data[key], fieldSchema, fieldPath));
926
+ }
927
+ return warnings;
928
+ }
847
929
  async function loadConfig(cwd = process.cwd(), env = process.env) {
848
930
  const configPath = path4.join(cwd, CONFIG_RELATIVE_PATH);
849
931
  let raw;
@@ -863,12 +945,17 @@ async function loadConfig(cwd = process.cwd(), env = process.env) {
863
945
  );
864
946
  }
865
947
  const { data: merged, applied } = applyEnvOverrides(data ?? {}, env);
948
+ const source = applied.length > 0 ? `${CONFIG_RELATIVE_PATH} (with overrides from ${applied.join(", ")})` : CONFIG_RELATIVE_PATH;
866
949
  const result = configSchema.safeParse(merged);
867
950
  if (!result.success) {
868
- const source = applied.length > 0 ? `${CONFIG_RELATIVE_PATH} (with overrides from ${applied.join(", ")})` : CONFIG_RELATIVE_PATH;
869
951
  throw new ConfigError(`Invalid ${source}:
870
952
  ${formatIssues(result.error)}`);
871
953
  }
954
+ for (const key of findUnknownConfigKeys(merged)) {
955
+ console.warn(
956
+ `warning: unknown config key '${key}' in ${source} \u2014 this version of blastproof does not recognise it, so it has no effect.`
957
+ );
958
+ }
872
959
  return result.data;
873
960
  }
874
961
 
@@ -965,6 +1052,7 @@ Rules:
965
1052
  - Return "done" when the current step's outcome holds \u2014 including when it already held before you acted, or was achieved by your previous action. "Already true" is done, never failure. Do not return "done" for work belonging to later steps.
966
1053
  - Return "fail" only when the step's outcome cannot be reached: the element is still absent after retries, the page cannot support the step, or an error blocks progress. Never return "fail" because the work appears to have been done already.
967
1054
  - If your previous action errored, re-read the fresh snapshot and choose an alternative element or approach. Do not repeat the exact same failing action.
1055
+ - \`***\` in a snapshot is a redacted secret \u2014 a password, token or key deliberately withheld from you. Seeing it is expected and is not a problem. A field showing \`***\` after you filled it from an {{env.VAR}} placeholder means the fill worked; treat that as success and move on. Never retry a fill because its value is redacted, and never report failure because a value was withheld.
968
1056
  - Keep reasoning to one short sentence.`;
969
1057
  }
970
1058
  function agentUserPrompt(input) {
@@ -985,7 +1073,9 @@ function agentUserPrompt(input) {
985
1073
  return parts.join("\n");
986
1074
  }
987
1075
  function assertSystemPrompt() {
988
- return `You are a QA judge. You receive a page accessibility snapshot and an expectation. Decide whether the snapshot satisfies the expectation. Be strict: only pass when the expectation is clearly met by the snapshot content. Answer with pass=true/false and a one-sentence reason.`;
1076
+ return `You are a QA judge. You receive a page accessibility snapshot and an expectation. Decide whether the snapshot satisfies the expectation. Be strict: only pass when the expectation is clearly met by the snapshot content. Answer with pass=true/false and a one-sentence reason.
1077
+
1078
+ \`***\` marks a secret deliberately withheld from you \u2014 a password, token or key. Seeing it is expected. A field holding \`***\` is filled, not empty, so do not fail an expectation on the grounds that a value was redacted. This applies only to the redaction itself: everything else the expectation asks for must still be visibly satisfied by the snapshot, and an expectation you genuinely cannot check against what you were shown still fails.`;
989
1079
  }
990
1080
  function assertUserPrompt(expectation, snapshot) {
991
1081
  return [
@@ -1277,10 +1367,11 @@ function renderTestYaml(draft, meta) {
1277
1367
  });
1278
1368
  }
1279
1369
  async function generateForRoute(page, options) {
1280
- const { route, baseUrl, changedFiles, brain, mask, snapshot = defaultSnapshot } = options;
1370
+ const { route, baseUrl, changedFiles, brain, mask, snapshot, maxSnapshotLines, timeoutMs } = options;
1371
+ const takeSnapshot = snapshot ?? ((p) => defaultSnapshot(p, maxSnapshotLines));
1281
1372
  const url = new URL(route, baseUrl).toString();
1282
1373
  try {
1283
- await page.goto(url, { timeout: 3e4 });
1374
+ await page.goto(url, { timeout: timeoutMs });
1284
1375
  } catch (error) {
1285
1376
  throw new PlannerError(
1286
1377
  `Cannot load ${url}: ${error instanceof Error ? error.message : String(error)}`
@@ -1288,7 +1379,7 @@ async function generateForRoute(page, options) {
1288
1379
  }
1289
1380
  const generated = await brain.planTest({
1290
1381
  route,
1291
- snapshot: mask(await snapshot(page)),
1382
+ snapshot: mask(await takeSnapshot(page)),
1292
1383
  changedFiles
1293
1384
  });
1294
1385
  const leaked = findSecretLiterals(generated.steps);
@@ -1317,9 +1408,120 @@ function coveredRoutes(tests) {
1317
1408
  return new Set(tests.flatMap((test) => test.routes));
1318
1409
  }
1319
1410
 
1411
+ // src/runner/browser.ts
1412
+ import { chromium } from "playwright";
1413
+ var BrowserLaunchError = class extends Error {
1414
+ constructor(message) {
1415
+ super(message);
1416
+ this.name = "BrowserLaunchError";
1417
+ }
1418
+ };
1419
+ var MISSING_LIBRARY_PATTERN = /([^\s:]+\.so(?:\.\d+)*): cannot open shared object file/;
1420
+ var MISSING_EXECUTABLE_PATTERN = /executable doesn't exist/i;
1421
+ function stripCommandLine(message) {
1422
+ return message.split("\n").filter((line) => (line.match(/\s--[\w-]+/g)?.length ?? 0) < 3).join("\n").trim();
1423
+ }
1424
+ async function launchBrowser(options) {
1425
+ try {
1426
+ return await chromium.launch(options);
1427
+ } catch (error) {
1428
+ const message = error instanceof Error ? error.message : String(error);
1429
+ if (MISSING_EXECUTABLE_PATTERN.test(message)) {
1430
+ throw new BrowserLaunchError(
1431
+ "The browser is not installed. Run `npx playwright install chromium` to download it."
1432
+ );
1433
+ }
1434
+ const missingLibrary = message.match(MISSING_LIBRARY_PATTERN);
1435
+ if (missingLibrary) {
1436
+ throw new BrowserLaunchError(
1437
+ `The browser could not start because a system library is missing: ${missingLibrary[1]}. Run \`npx playwright install-deps chromium\` to install it \u2014 this needs root/administrator privileges (sudo, or an elevated shell on Windows). Without them, blastproof run --dry-run and blastproof plan --dry-run need neither a browser nor a key.`
1438
+ );
1439
+ }
1440
+ throw new BrowserLaunchError(stripCommandLine(message));
1441
+ }
1442
+ }
1443
+
1444
+ // src/preflight.ts
1445
+ var REACHABILITY_TIMEOUT_MS = 15e3;
1446
+ function messageOf(error) {
1447
+ return error instanceof Error ? error.message : String(error);
1448
+ }
1449
+ function isTimeout(error) {
1450
+ return error instanceof Error && error.name === "AbortError";
1451
+ }
1452
+ async function probe(url, fetchImpl) {
1453
+ const controller = new AbortController();
1454
+ const timer = setTimeout(() => controller.abort(), REACHABILITY_TIMEOUT_MS);
1455
+ try {
1456
+ await fetchImpl(url, { signal: controller.signal });
1457
+ } finally {
1458
+ clearTimeout(timer);
1459
+ }
1460
+ }
1461
+ function providerBaseUrl(llm) {
1462
+ if (llm.base_url) return llm.base_url;
1463
+ switch (llm.provider) {
1464
+ case "anthropic":
1465
+ return "https://api.anthropic.com";
1466
+ case "openai":
1467
+ return "https://api.openai.com";
1468
+ case "ollama":
1469
+ return DEFAULT_OLLAMA_BASE_URL;
1470
+ }
1471
+ }
1472
+ async function checkModel(llm, fetchImpl) {
1473
+ const url = providerBaseUrl(llm);
1474
+ try {
1475
+ await probe(url, fetchImpl);
1476
+ return void 0;
1477
+ } catch (error) {
1478
+ if (isTimeout(error)) {
1479
+ return `Cannot reach the ${llm.provider} provider at ${url}: it did not respond within ${REACHABILITY_TIMEOUT_MS / 1e3}s. It may be temporarily slow or overloaded rather than down \u2014 try again in a moment, or check llm.base_url in .blastproof/config.yaml.`;
1480
+ }
1481
+ return `Cannot reach the ${llm.provider} provider at ${url} (${messageOf(error)}). Check your network connection` + (llm.provider === "ollama" ? ", that Ollama is running (`ollama serve`)," : "") + " or llm.base_url in .blastproof/config.yaml.";
1482
+ }
1483
+ }
1484
+ async function checkBaseUrl(baseUrl, fetchImpl) {
1485
+ try {
1486
+ await probe(baseUrl, fetchImpl);
1487
+ return void 0;
1488
+ } catch (error) {
1489
+ if (isTimeout(error)) {
1490
+ return `The application at ${baseUrl} did not respond within ${REACHABILITY_TIMEOUT_MS / 1e3}s. It may still be starting (a dev server compiling a route on first request, a cold-starting container) rather than down \u2014 try again in a moment.`;
1491
+ }
1492
+ return `The application at ${baseUrl} is not responding (${messageOf(error)}). Start it, or point --url / base_url in .blastproof/config.yaml at the right address.`;
1493
+ }
1494
+ }
1495
+ async function checkBrowser(headless) {
1496
+ try {
1497
+ const browser = await launchBrowser({ headless });
1498
+ return { browser };
1499
+ } catch (error) {
1500
+ return { failure: error instanceof BrowserLaunchError ? error.message : messageOf(error) };
1501
+ }
1502
+ }
1503
+ async function runPreflight(needs, config, fetchImpl = fetch) {
1504
+ const [browserResult, modelFailure, baseUrlFailure] = await Promise.all([
1505
+ needs.browser ? checkBrowser(config.browser.headless) : Promise.resolve({}),
1506
+ needs.model ? checkModel(config.llm, fetchImpl) : Promise.resolve(void 0),
1507
+ needs.baseUrl ? checkBaseUrl(config.base_url, fetchImpl) : Promise.resolve(void 0)
1508
+ ]);
1509
+ const failures = [browserResult.failure, modelFailure, baseUrlFailure].filter(
1510
+ (value) => value !== void 0
1511
+ );
1512
+ if (failures.length > 0) {
1513
+ if (browserResult.browser) await browserResult.browser.close();
1514
+ return { ok: false, failures };
1515
+ }
1516
+ return { ok: true, browser: browserResult.browser };
1517
+ }
1518
+ function printPreflightFailures(failures) {
1519
+ console.error(`error: ${failures.length} unmet prerequisite(s):`);
1520
+ for (const failure of failures) console.error(` - ${failure}`);
1521
+ }
1522
+
1320
1523
  // src/commands/run.ts
1321
1524
  import path9 from "path";
1322
- import { chromium } from "playwright";
1323
1525
 
1324
1526
  // src/report/html.ts
1325
1527
  import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
@@ -1766,6 +1968,8 @@ async function runOne(browser, test, config, sessionDir, session, runMask, budge
1766
1968
  allowedOrigins: config.allowed_origins,
1767
1969
  resolveValue: (value) => substituteEnv(value),
1768
1970
  maxRetries: config.max_retries_per_step,
1971
+ timeoutMs: config.browser.timeout_ms,
1972
+ maxSnapshotLines: config.browser.max_snapshot_lines,
1769
1973
  mask: (text) => mask.mask(text),
1770
1974
  onEvent: printEvent
1771
1975
  });
@@ -1970,7 +2174,12 @@ ${results.length} test file(s) could not be parsed:`);
1970
2174
  );
1971
2175
  const runMask = buildRunMask(config, parsed);
1972
2176
  const budget = options.budget ?? new RunBudget(resolveBudgetOptions(config, options));
1973
- const browser = await chromium.launch({ headless: config.browser.headless });
2177
+ const preflight = await runPreflight({ browser: true, model: true, baseUrl: true }, config);
2178
+ if (!preflight.ok) {
2179
+ printPreflightFailures(preflight.failures);
2180
+ return EXIT_USAGE;
2181
+ }
2182
+ const browser = preflight.browser;
1974
2183
  let incomplete;
1975
2184
  try {
1976
2185
  let session;
@@ -1985,6 +2194,8 @@ ${results.length} test file(s) could not be parsed:`);
1985
2194
  browser,
1986
2195
  brain: createBrain(createModel(config.llm).model, void 0, budget),
1987
2196
  maxRetries: config.max_retries_per_step,
2197
+ timeoutMs: config.browser.timeout_ms,
2198
+ maxSnapshotLines: config.browser.max_snapshot_lines,
1988
2199
  mask: runMask,
1989
2200
  onEvent: printEvent
1990
2201
  });
@@ -2111,6 +2322,16 @@ async function planCommand(options) {
2111
2322
  console.log("Already covered (skipped):");
2112
2323
  for (const route of alreadyCovered) console.log(` ${route}`);
2113
2324
  }
2325
+ if (options.dryRun) {
2326
+ if (work.length === 0) {
2327
+ console.log("Nothing to generate: no affected route is missing coverage.");
2328
+ } else {
2329
+ console.log(`Dry run: ${work.length} route(s) would generate a draft for:`);
2330
+ for (const { route } of work) console.log(` ${route}`);
2331
+ }
2332
+ console.log("Dry run: no browser launched, no LLM calls made.");
2333
+ return EXIT_OK;
2334
+ }
2114
2335
  if (work.length === 0) {
2115
2336
  console.log("Nothing to generate: no affected route is missing coverage.");
2116
2337
  return EXIT_OK;
@@ -2142,7 +2363,12 @@ async function planCommand(options) {
2142
2363
  const written = [];
2143
2364
  const failed = [];
2144
2365
  let incomplete;
2145
- const browser = await chromium2.launch({ headless: config.browser.headless });
2366
+ const preflight = await runPreflight({ browser: true, model: true, baseUrl: true }, config);
2367
+ if (!preflight.ok) {
2368
+ printPreflightFailures(preflight.failures);
2369
+ return EXIT_USAGE;
2370
+ }
2371
+ const browser = preflight.browser;
2146
2372
  try {
2147
2373
  let session;
2148
2374
  if (config.auth) {
@@ -2156,6 +2382,8 @@ async function planCommand(options) {
2156
2382
  browser,
2157
2383
  brain: createBrain(createModel(config.llm).model, void 0, budget),
2158
2384
  maxRetries: config.max_retries_per_step,
2385
+ timeoutMs: config.browser.timeout_ms,
2386
+ maxSnapshotLines: config.browser.max_snapshot_lines,
2159
2387
  mask,
2160
2388
  onEvent: printAuthEvent
2161
2389
  });
@@ -2183,7 +2411,9 @@ async function planCommand(options) {
2183
2411
  baseUrl: config.base_url,
2184
2412
  changedFiles,
2185
2413
  brain,
2186
- mask: (text) => mask.mask(text)
2414
+ mask: (text) => mask.mask(text),
2415
+ maxSnapshotLines: config.browser.max_snapshot_lines,
2416
+ timeoutMs: config.browser.timeout_ms
2187
2417
  });
2188
2418
  } catch (error) {
2189
2419
  if (error instanceof BudgetExhaustedError) {
@@ -2326,7 +2556,7 @@ function parsePositiveNumber(flag) {
2326
2556
  };
2327
2557
  }
2328
2558
  var program = new Command();
2329
- program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.3.0");
2559
+ program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.5.0");
2330
2560
  program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
2331
2561
  try {
2332
2562
  const result = await initProject(process.cwd());
@@ -2380,6 +2610,9 @@ program.command("run").description("Discover and run all tests under .blastproof
2380
2610
  }
2381
2611
  );
2382
2612
  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(
2613
+ "--dry-run",
2614
+ "print the routes that would generate drafts and exit without launching a browser or calling the LLM"
2615
+ ).option(
2383
2616
  "--max-llm-calls <n>",
2384
2617
  "stop after this many model calls, reported as incomplete (overrides config)",
2385
2618
  parsePositiveInt("--max-llm-calls")
@@ -2400,6 +2633,7 @@ program.command("plan").description("Generate plain-English YAML tests for affec
2400
2633
  url: options.url,
2401
2634
  routes: options.route,
2402
2635
  write: options.write,
2636
+ dryRun: options.dryRun,
2403
2637
  maxLlmCalls: options.maxLlmCalls,
2404
2638
  maxTokens: options.maxTokens,
2405
2639
  maxDuration: options.maxDuration