blastproof 0.3.0 → 0.4.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";
@@ -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,9 @@ 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
+ async function defaultSnapshot(page, maxLines) {
433
443
  const { captureSnapshot } = await import("./snapshot-CAIB2OHX.js");
434
- return captureSnapshot(page);
444
+ return captureSnapshot(page, { maxLines });
435
445
  }
436
446
  function slugify(text) {
437
447
  const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
@@ -448,11 +458,14 @@ async function executeTest(page, test, options) {
448
458
  resolveValue,
449
459
  maxRetries = 3,
450
460
  maxIterationsPerStep = DEFAULT_MAX_ITERATIONS_PER_STEP,
461
+ timeoutMs,
451
462
  mask = (s) => s,
452
- snapshot = defaultSnapshot,
463
+ snapshot,
464
+ maxSnapshotLines,
453
465
  onEvent = () => {
454
466
  }
455
467
  } = options;
468
+ const takeSnapshot = snapshot ?? ((page2) => defaultSnapshot(page2, maxSnapshotLines));
456
469
  const startedAt = Date.now();
457
470
  const stepResults = [];
458
471
  const allSteps = [
@@ -483,7 +496,7 @@ async function executeTest(page, test, options) {
483
496
  if (iterations >= maxIterationsPerStep) {
484
497
  throw new StepFailure(`step exceeded ${maxIterationsPerStep} actions without completing`);
485
498
  }
486
- const snap = await snapshot(page);
499
+ const snap = await takeSnapshot(page);
487
500
  let action;
488
501
  try {
489
502
  action = await brain.nextAction({
@@ -529,7 +542,12 @@ async function executeTest(page, test, options) {
529
542
  continue;
530
543
  }
531
544
  try {
532
- const result = await performAction(page, action, { baseUrl, allowedOrigins, resolveValue });
545
+ const result = await performAction(page, action, {
546
+ baseUrl,
547
+ allowedOrigins,
548
+ resolveValue,
549
+ resolveTimeoutMs: timeoutMs
550
+ });
533
551
  lastResult = result;
534
552
  emitAction(index, action, result);
535
553
  } catch (error) {
@@ -650,8 +668,9 @@ async function runJourney(...args) {
650
668
  }
651
669
  }
652
670
  async function fromSteps(options) {
653
- const { auth, baseUrl, browser, brain, maxRetries, snapshot = defaultSnapshot, onEvent, mask } = options;
671
+ const { auth, baseUrl, browser, brain, maxRetries, snapshot, timeoutMs, maxSnapshotLines, onEvent, mask } = options;
654
672
  const steps = auth.steps;
673
+ const takeSnapshot = snapshot ?? ((page) => defaultSnapshot(page, maxSnapshotLines));
655
674
  const context = await browser.newContext();
656
675
  try {
657
676
  const page = await context.newPage();
@@ -673,8 +692,9 @@ async function fromSteps(options) {
673
692
  allowedOrigins: options.allowedOrigins,
674
693
  resolveValue: (value) => substituteEnv(value),
675
694
  maxRetries,
695
+ timeoutMs,
676
696
  mask: (text) => mask.mask(text),
677
- snapshot,
697
+ snapshot: takeSnapshot,
678
698
  onEvent
679
699
  }
680
700
  );
@@ -684,7 +704,7 @@ async function fromSteps(options) {
684
704
  );
685
705
  }
686
706
  if (auth.verify) {
687
- const judgment = await brain.judge(auth.verify, mask.mask(await snapshot(page)));
707
+ const judgment = await brain.judge(auth.verify, mask.mask(await takeSnapshot(page)));
688
708
  if (!judgment.pass) {
689
709
  throw new AuthError(`Authentication could not be verified: ${mask.mask(judgment.reason)}`);
690
710
  }
@@ -745,7 +765,19 @@ var llmSchema = z.object({
745
765
  });
746
766
  var browserSchema = z.object({
747
767
  headless: z.boolean().default(true),
748
- timeout_ms: z.number().int().positive().default(3e4)
768
+ /**
769
+ * Bounds every wait: resolving a target element on the accessibility tree, and
770
+ * navigating. An explicit per-call Playwright timeout always overrides
771
+ * `page.setDefaultTimeout()`, so this must be threaded through the action
772
+ * context rather than relied on ambiently (design D2, runner/actions.ts).
773
+ */
774
+ timeout_ms: z.number().int().positive().default(3e4),
775
+ /**
776
+ * Caps accessibility-tree lines sent to the model per snapshot. Optional:
777
+ * undefined lets `runner/snapshot.ts`'s own default (200) apply, so a config
778
+ * that never mentions this behaves exactly as before it existed.
779
+ */
780
+ max_snapshot_lines: z.number().int().positive().optional()
749
781
  });
750
782
  var budgetSchema = z.object({
751
783
  max_llm_calls: z.coerce.number().int().positive().optional(),
@@ -844,6 +876,37 @@ var ConfigError = class extends Error {
844
876
  function formatIssues(error) {
845
877
  return error.issues.map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
846
878
  }
879
+ function unwrapToObjectSchema(schema) {
880
+ let current = schema;
881
+ for (; ; ) {
882
+ if (current instanceof z.ZodOptional || current instanceof z.ZodNullable) {
883
+ current = current.unwrap();
884
+ } else if (current instanceof z.ZodDefault) {
885
+ current = current.removeDefault();
886
+ } else if (current instanceof z.ZodEffects) {
887
+ current = current.innerType();
888
+ } else {
889
+ break;
890
+ }
891
+ }
892
+ return current instanceof z.ZodObject ? current : void 0;
893
+ }
894
+ function findUnknownConfigKeys(data, schema = configSchema, path11 = []) {
895
+ const object = unwrapToObjectSchema(schema);
896
+ if (!object || data === null || typeof data !== "object" || Array.isArray(data)) return [];
897
+ const shape = object.shape;
898
+ const warnings = [];
899
+ for (const key of Object.keys(data)) {
900
+ const fieldPath = [...path11, key];
901
+ const fieldSchema = shape[key];
902
+ if (!fieldSchema) {
903
+ warnings.push(fieldPath.join("."));
904
+ continue;
905
+ }
906
+ warnings.push(...findUnknownConfigKeys(data[key], fieldSchema, fieldPath));
907
+ }
908
+ return warnings;
909
+ }
847
910
  async function loadConfig(cwd = process.cwd(), env = process.env) {
848
911
  const configPath = path4.join(cwd, CONFIG_RELATIVE_PATH);
849
912
  let raw;
@@ -863,12 +926,17 @@ async function loadConfig(cwd = process.cwd(), env = process.env) {
863
926
  );
864
927
  }
865
928
  const { data: merged, applied } = applyEnvOverrides(data ?? {}, env);
929
+ const source = applied.length > 0 ? `${CONFIG_RELATIVE_PATH} (with overrides from ${applied.join(", ")})` : CONFIG_RELATIVE_PATH;
866
930
  const result = configSchema.safeParse(merged);
867
931
  if (!result.success) {
868
- const source = applied.length > 0 ? `${CONFIG_RELATIVE_PATH} (with overrides from ${applied.join(", ")})` : CONFIG_RELATIVE_PATH;
869
932
  throw new ConfigError(`Invalid ${source}:
870
933
  ${formatIssues(result.error)}`);
871
934
  }
935
+ for (const key of findUnknownConfigKeys(merged)) {
936
+ console.warn(
937
+ `warning: unknown config key '${key}' in ${source} \u2014 this version of blastproof does not recognise it, so it has no effect.`
938
+ );
939
+ }
872
940
  return result.data;
873
941
  }
874
942
 
@@ -1277,10 +1345,11 @@ function renderTestYaml(draft, meta) {
1277
1345
  });
1278
1346
  }
1279
1347
  async function generateForRoute(page, options) {
1280
- const { route, baseUrl, changedFiles, brain, mask, snapshot = defaultSnapshot } = options;
1348
+ const { route, baseUrl, changedFiles, brain, mask, snapshot, maxSnapshotLines, timeoutMs } = options;
1349
+ const takeSnapshot = snapshot ?? ((p) => defaultSnapshot(p, maxSnapshotLines));
1281
1350
  const url = new URL(route, baseUrl).toString();
1282
1351
  try {
1283
- await page.goto(url, { timeout: 3e4 });
1352
+ await page.goto(url, { timeout: timeoutMs });
1284
1353
  } catch (error) {
1285
1354
  throw new PlannerError(
1286
1355
  `Cannot load ${url}: ${error instanceof Error ? error.message : String(error)}`
@@ -1288,7 +1357,7 @@ async function generateForRoute(page, options) {
1288
1357
  }
1289
1358
  const generated = await brain.planTest({
1290
1359
  route,
1291
- snapshot: mask(await snapshot(page)),
1360
+ snapshot: mask(await takeSnapshot(page)),
1292
1361
  changedFiles
1293
1362
  });
1294
1363
  const leaked = findSecretLiterals(generated.steps);
@@ -1317,9 +1386,120 @@ function coveredRoutes(tests) {
1317
1386
  return new Set(tests.flatMap((test) => test.routes));
1318
1387
  }
1319
1388
 
1389
+ // src/runner/browser.ts
1390
+ import { chromium } from "playwright";
1391
+ var BrowserLaunchError = class extends Error {
1392
+ constructor(message) {
1393
+ super(message);
1394
+ this.name = "BrowserLaunchError";
1395
+ }
1396
+ };
1397
+ var MISSING_LIBRARY_PATTERN = /([^\s:]+\.so(?:\.\d+)*): cannot open shared object file/;
1398
+ var MISSING_EXECUTABLE_PATTERN = /executable doesn't exist/i;
1399
+ function stripCommandLine(message) {
1400
+ return message.split("\n").filter((line) => (line.match(/\s--[\w-]+/g)?.length ?? 0) < 3).join("\n").trim();
1401
+ }
1402
+ async function launchBrowser(options) {
1403
+ try {
1404
+ return await chromium.launch(options);
1405
+ } catch (error) {
1406
+ const message = error instanceof Error ? error.message : String(error);
1407
+ if (MISSING_EXECUTABLE_PATTERN.test(message)) {
1408
+ throw new BrowserLaunchError(
1409
+ "The browser is not installed. Run `npx playwright install chromium` to download it."
1410
+ );
1411
+ }
1412
+ const missingLibrary = message.match(MISSING_LIBRARY_PATTERN);
1413
+ if (missingLibrary) {
1414
+ throw new BrowserLaunchError(
1415
+ `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.`
1416
+ );
1417
+ }
1418
+ throw new BrowserLaunchError(stripCommandLine(message));
1419
+ }
1420
+ }
1421
+
1422
+ // src/preflight.ts
1423
+ var REACHABILITY_TIMEOUT_MS = 15e3;
1424
+ function messageOf(error) {
1425
+ return error instanceof Error ? error.message : String(error);
1426
+ }
1427
+ function isTimeout(error) {
1428
+ return error instanceof Error && error.name === "AbortError";
1429
+ }
1430
+ async function probe(url, fetchImpl) {
1431
+ const controller = new AbortController();
1432
+ const timer = setTimeout(() => controller.abort(), REACHABILITY_TIMEOUT_MS);
1433
+ try {
1434
+ await fetchImpl(url, { signal: controller.signal });
1435
+ } finally {
1436
+ clearTimeout(timer);
1437
+ }
1438
+ }
1439
+ function providerBaseUrl(llm) {
1440
+ if (llm.base_url) return llm.base_url;
1441
+ switch (llm.provider) {
1442
+ case "anthropic":
1443
+ return "https://api.anthropic.com";
1444
+ case "openai":
1445
+ return "https://api.openai.com";
1446
+ case "ollama":
1447
+ return DEFAULT_OLLAMA_BASE_URL;
1448
+ }
1449
+ }
1450
+ async function checkModel(llm, fetchImpl) {
1451
+ const url = providerBaseUrl(llm);
1452
+ try {
1453
+ await probe(url, fetchImpl);
1454
+ return void 0;
1455
+ } catch (error) {
1456
+ if (isTimeout(error)) {
1457
+ 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.`;
1458
+ }
1459
+ 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.";
1460
+ }
1461
+ }
1462
+ async function checkBaseUrl(baseUrl, fetchImpl) {
1463
+ try {
1464
+ await probe(baseUrl, fetchImpl);
1465
+ return void 0;
1466
+ } catch (error) {
1467
+ if (isTimeout(error)) {
1468
+ 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.`;
1469
+ }
1470
+ 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.`;
1471
+ }
1472
+ }
1473
+ async function checkBrowser(headless) {
1474
+ try {
1475
+ const browser = await launchBrowser({ headless });
1476
+ return { browser };
1477
+ } catch (error) {
1478
+ return { failure: error instanceof BrowserLaunchError ? error.message : messageOf(error) };
1479
+ }
1480
+ }
1481
+ async function runPreflight(needs, config, fetchImpl = fetch) {
1482
+ const [browserResult, modelFailure, baseUrlFailure] = await Promise.all([
1483
+ needs.browser ? checkBrowser(config.browser.headless) : Promise.resolve({}),
1484
+ needs.model ? checkModel(config.llm, fetchImpl) : Promise.resolve(void 0),
1485
+ needs.baseUrl ? checkBaseUrl(config.base_url, fetchImpl) : Promise.resolve(void 0)
1486
+ ]);
1487
+ const failures = [browserResult.failure, modelFailure, baseUrlFailure].filter(
1488
+ (value) => value !== void 0
1489
+ );
1490
+ if (failures.length > 0) {
1491
+ if (browserResult.browser) await browserResult.browser.close();
1492
+ return { ok: false, failures };
1493
+ }
1494
+ return { ok: true, browser: browserResult.browser };
1495
+ }
1496
+ function printPreflightFailures(failures) {
1497
+ console.error(`error: ${failures.length} unmet prerequisite(s):`);
1498
+ for (const failure of failures) console.error(` - ${failure}`);
1499
+ }
1500
+
1320
1501
  // src/commands/run.ts
1321
1502
  import path9 from "path";
1322
- import { chromium } from "playwright";
1323
1503
 
1324
1504
  // src/report/html.ts
1325
1505
  import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
@@ -1766,6 +1946,8 @@ async function runOne(browser, test, config, sessionDir, session, runMask, budge
1766
1946
  allowedOrigins: config.allowed_origins,
1767
1947
  resolveValue: (value) => substituteEnv(value),
1768
1948
  maxRetries: config.max_retries_per_step,
1949
+ timeoutMs: config.browser.timeout_ms,
1950
+ maxSnapshotLines: config.browser.max_snapshot_lines,
1769
1951
  mask: (text) => mask.mask(text),
1770
1952
  onEvent: printEvent
1771
1953
  });
@@ -1970,7 +2152,12 @@ ${results.length} test file(s) could not be parsed:`);
1970
2152
  );
1971
2153
  const runMask = buildRunMask(config, parsed);
1972
2154
  const budget = options.budget ?? new RunBudget(resolveBudgetOptions(config, options));
1973
- const browser = await chromium.launch({ headless: config.browser.headless });
2155
+ const preflight = await runPreflight({ browser: true, model: true, baseUrl: true }, config);
2156
+ if (!preflight.ok) {
2157
+ printPreflightFailures(preflight.failures);
2158
+ return EXIT_USAGE;
2159
+ }
2160
+ const browser = preflight.browser;
1974
2161
  let incomplete;
1975
2162
  try {
1976
2163
  let session;
@@ -1985,6 +2172,8 @@ ${results.length} test file(s) could not be parsed:`);
1985
2172
  browser,
1986
2173
  brain: createBrain(createModel(config.llm).model, void 0, budget),
1987
2174
  maxRetries: config.max_retries_per_step,
2175
+ timeoutMs: config.browser.timeout_ms,
2176
+ maxSnapshotLines: config.browser.max_snapshot_lines,
1988
2177
  mask: runMask,
1989
2178
  onEvent: printEvent
1990
2179
  });
@@ -2111,6 +2300,16 @@ async function planCommand(options) {
2111
2300
  console.log("Already covered (skipped):");
2112
2301
  for (const route of alreadyCovered) console.log(` ${route}`);
2113
2302
  }
2303
+ if (options.dryRun) {
2304
+ if (work.length === 0) {
2305
+ console.log("Nothing to generate: no affected route is missing coverage.");
2306
+ } else {
2307
+ console.log(`Dry run: ${work.length} route(s) would generate a draft for:`);
2308
+ for (const { route } of work) console.log(` ${route}`);
2309
+ }
2310
+ console.log("Dry run: no browser launched, no LLM calls made.");
2311
+ return EXIT_OK;
2312
+ }
2114
2313
  if (work.length === 0) {
2115
2314
  console.log("Nothing to generate: no affected route is missing coverage.");
2116
2315
  return EXIT_OK;
@@ -2142,7 +2341,12 @@ async function planCommand(options) {
2142
2341
  const written = [];
2143
2342
  const failed = [];
2144
2343
  let incomplete;
2145
- const browser = await chromium2.launch({ headless: config.browser.headless });
2344
+ const preflight = await runPreflight({ browser: true, model: true, baseUrl: true }, config);
2345
+ if (!preflight.ok) {
2346
+ printPreflightFailures(preflight.failures);
2347
+ return EXIT_USAGE;
2348
+ }
2349
+ const browser = preflight.browser;
2146
2350
  try {
2147
2351
  let session;
2148
2352
  if (config.auth) {
@@ -2156,6 +2360,8 @@ async function planCommand(options) {
2156
2360
  browser,
2157
2361
  brain: createBrain(createModel(config.llm).model, void 0, budget),
2158
2362
  maxRetries: config.max_retries_per_step,
2363
+ timeoutMs: config.browser.timeout_ms,
2364
+ maxSnapshotLines: config.browser.max_snapshot_lines,
2159
2365
  mask,
2160
2366
  onEvent: printAuthEvent
2161
2367
  });
@@ -2183,7 +2389,9 @@ async function planCommand(options) {
2183
2389
  baseUrl: config.base_url,
2184
2390
  changedFiles,
2185
2391
  brain,
2186
- mask: (text) => mask.mask(text)
2392
+ mask: (text) => mask.mask(text),
2393
+ maxSnapshotLines: config.browser.max_snapshot_lines,
2394
+ timeoutMs: config.browser.timeout_ms
2187
2395
  });
2188
2396
  } catch (error) {
2189
2397
  if (error instanceof BudgetExhaustedError) {
@@ -2326,7 +2534,7 @@ function parsePositiveNumber(flag) {
2326
2534
  };
2327
2535
  }
2328
2536
  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");
2537
+ program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.4.0");
2330
2538
  program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
2331
2539
  try {
2332
2540
  const result = await initProject(process.cwd());
@@ -2380,6 +2588,9 @@ program.command("run").description("Discover and run all tests under .blastproof
2380
2588
  }
2381
2589
  );
2382
2590
  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(
2591
+ "--dry-run",
2592
+ "print the routes that would generate drafts and exit without launching a browser or calling the LLM"
2593
+ ).option(
2383
2594
  "--max-llm-calls <n>",
2384
2595
  "stop after this many model calls, reported as incomplete (overrides config)",
2385
2596
  parsePositiveInt("--max-llm-calls")
@@ -2400,6 +2611,7 @@ program.command("plan").description("Generate plain-English YAML tests for affec
2400
2611
  url: options.url,
2401
2612
  routes: options.route,
2402
2613
  write: options.write,
2614
+ dryRun: options.dryRun,
2403
2615
  maxLlmCalls: options.maxLlmCalls,
2404
2616
  maxTokens: options.maxTokens,
2405
2617
  maxDuration: options.maxDuration