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/README.md +132 -308
- package/dist/cli.js +236 -24
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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, {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|