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/README.md +132 -308
- package/dist/cli.js +261 -27
- 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";
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|