blastproof 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -8
- package/dist/cli.js +448 -92
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -169,6 +169,86 @@ import { chromium as chromium2 } from "playwright";
|
|
|
169
169
|
import { readFile, writeFile as writeFile2, mkdir as mkdir3 } from "fs/promises";
|
|
170
170
|
import path3 from "path";
|
|
171
171
|
|
|
172
|
+
// src/runner/budget.ts
|
|
173
|
+
function describeLimit(limit, observed, configured) {
|
|
174
|
+
switch (limit) {
|
|
175
|
+
case "calls":
|
|
176
|
+
return `model call budget exhausted: reached the configured maximum of ${configured} call(s)`;
|
|
177
|
+
case "tokens":
|
|
178
|
+
return `token budget exhausted: reached the configured maximum of ${configured} token(s) (observed ${observed})`;
|
|
179
|
+
case "duration":
|
|
180
|
+
return `deadline exceeded: reached the configured maximum of ${(configured / 1e3).toFixed(0)}s (elapsed ${(observed / 1e3).toFixed(1)}s)`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
var BudgetExhaustedError = class extends Error {
|
|
184
|
+
limit;
|
|
185
|
+
observed;
|
|
186
|
+
configured;
|
|
187
|
+
constructor(limit, observed, configured) {
|
|
188
|
+
super(describeLimit(limit, observed, configured));
|
|
189
|
+
this.name = "BudgetExhaustedError";
|
|
190
|
+
this.limit = limit;
|
|
191
|
+
this.observed = observed;
|
|
192
|
+
this.configured = configured;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
var RunBudget = class {
|
|
196
|
+
maxCalls;
|
|
197
|
+
maxTokens;
|
|
198
|
+
maxDurationMs;
|
|
199
|
+
now;
|
|
200
|
+
startedAt;
|
|
201
|
+
calls = 0;
|
|
202
|
+
tokens = 0;
|
|
203
|
+
constructor(options = {}) {
|
|
204
|
+
this.maxCalls = options.maxCalls;
|
|
205
|
+
this.maxTokens = options.maxTokens;
|
|
206
|
+
this.maxDurationMs = options.maxDurationMs;
|
|
207
|
+
this.now = options.now ?? Date.now;
|
|
208
|
+
this.startedAt = this.now();
|
|
209
|
+
}
|
|
210
|
+
/** Model calls recorded so far. */
|
|
211
|
+
get callCount() {
|
|
212
|
+
return this.calls;
|
|
213
|
+
}
|
|
214
|
+
/** Tokens recorded so far, summed across every completed call. */
|
|
215
|
+
get tokenCount() {
|
|
216
|
+
return this.tokens;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Throws {@link BudgetExhaustedError} when the next unit of work could exceed a
|
|
220
|
+
* configured limit. Called before spending anything, never after — so an
|
|
221
|
+
* over-budget call is never issued and then rejected (spec: "the call is not
|
|
222
|
+
* made").
|
|
223
|
+
*/
|
|
224
|
+
check() {
|
|
225
|
+
if (this.maxCalls !== void 0 && this.calls >= this.maxCalls) {
|
|
226
|
+
throw new BudgetExhaustedError("calls", this.calls, this.maxCalls);
|
|
227
|
+
}
|
|
228
|
+
if (this.maxTokens !== void 0 && this.tokens >= this.maxTokens) {
|
|
229
|
+
throw new BudgetExhaustedError("tokens", this.tokens, this.maxTokens);
|
|
230
|
+
}
|
|
231
|
+
if (this.maxDurationMs !== void 0) {
|
|
232
|
+
const elapsed = this.now() - this.startedAt;
|
|
233
|
+
if (elapsed >= this.maxDurationMs) {
|
|
234
|
+
throw new BudgetExhaustedError("duration", elapsed, this.maxDurationMs);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Records what a completed model call spent. */
|
|
239
|
+
record(usage) {
|
|
240
|
+
this.calls += 1;
|
|
241
|
+
if (usage?.totalTokens !== void 0) this.tokens += usage.totalTokens;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
function estimateMaxModelCalls(tests, maxIterationsPerStep, maxRetriesPerStep) {
|
|
245
|
+
const totalSteps = tests.reduce(
|
|
246
|
+
(sum, test) => sum + (test.setup?.length ?? 0) + test.steps.length,
|
|
247
|
+
0
|
|
248
|
+
);
|
|
249
|
+
return totalSteps * (maxIterationsPerStep + maxRetriesPerStep);
|
|
250
|
+
}
|
|
251
|
+
|
|
172
252
|
// src/runner/env.ts
|
|
173
253
|
var ENV_PLACEHOLDER = /\{\{\s*env\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
|
|
174
254
|
var MissingEnvError = class extends Error {
|
|
@@ -215,9 +295,21 @@ var SecretsMask = class {
|
|
|
215
295
|
if (value === void 0) {
|
|
216
296
|
throw new MissingEnvError(name);
|
|
217
297
|
}
|
|
218
|
-
this.
|
|
298
|
+
this.add(value);
|
|
219
299
|
}
|
|
220
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Registers a value and the forms it takes on the way to a prompt. `navigate`
|
|
303
|
+
* reports a resolved URL, and `new URL()` percent-encodes — so a secret with a
|
|
304
|
+
* space stopped matching a literal search and passed through unmasked.
|
|
305
|
+
* This cannot cover every transform a page might apply; see the README.
|
|
306
|
+
*/
|
|
307
|
+
add(value) {
|
|
308
|
+
if (!value) return;
|
|
309
|
+
this.secrets.add(value);
|
|
310
|
+
const encoded = encodeURIComponent(value);
|
|
311
|
+
if (encoded !== value) this.secrets.add(encoded);
|
|
312
|
+
}
|
|
221
313
|
mask(text) {
|
|
222
314
|
return maskSecrets(text, this.secrets);
|
|
223
315
|
}
|
|
@@ -336,6 +428,7 @@ async function performAction(page, action, ctx) {
|
|
|
336
428
|
}
|
|
337
429
|
|
|
338
430
|
// src/runner/executor.ts
|
|
431
|
+
var DEFAULT_MAX_ITERATIONS_PER_STEP = 15;
|
|
339
432
|
async function defaultSnapshot(page) {
|
|
340
433
|
const { captureSnapshot } = await import("./snapshot-CAIB2OHX.js");
|
|
341
434
|
return captureSnapshot(page);
|
|
@@ -354,7 +447,7 @@ async function executeTest(page, test, options) {
|
|
|
354
447
|
allowedOrigins,
|
|
355
448
|
resolveValue,
|
|
356
449
|
maxRetries = 3,
|
|
357
|
-
maxIterationsPerStep =
|
|
450
|
+
maxIterationsPerStep = DEFAULT_MAX_ITERATIONS_PER_STEP,
|
|
358
451
|
mask = (s) => s,
|
|
359
452
|
snapshot = defaultSnapshot,
|
|
360
453
|
onEvent = () => {
|
|
@@ -396,12 +489,13 @@ async function executeTest(page, test, options) {
|
|
|
396
489
|
action = await brain.nextAction({
|
|
397
490
|
step,
|
|
398
491
|
isSetup: setup,
|
|
399
|
-
snapshot: snap,
|
|
400
|
-
lastResult,
|
|
492
|
+
snapshot: mask(snap),
|
|
493
|
+
lastResult: lastResult === void 0 ? void 0 : mask(lastResult),
|
|
401
494
|
retriesLeft: maxRetries - failedAttempts,
|
|
402
495
|
iterationsLeft: maxIterationsPerStep - iterations
|
|
403
496
|
});
|
|
404
497
|
} catch (error) {
|
|
498
|
+
if (error instanceof BudgetExhaustedError) throw error;
|
|
405
499
|
failedAttempts++;
|
|
406
500
|
lastResult = `error: ${error instanceof Error ? error.message : String(error)}`;
|
|
407
501
|
if (failedAttempts >= maxRetries) {
|
|
@@ -420,12 +514,12 @@ async function executeTest(page, test, options) {
|
|
|
420
514
|
}
|
|
421
515
|
if (action.action === "assert") {
|
|
422
516
|
const expectation = action.expectation ?? action.reasoning;
|
|
423
|
-
const judgment = await brain.judge(expectation, snap);
|
|
517
|
+
const judgment = await brain.judge(mask(expectation), mask(snap));
|
|
424
518
|
const result = judgment.pass ? `ok: assertion passed: ${judgment.reason}` : `assertion failed: ${judgment.reason}`;
|
|
425
519
|
emitAction(index, action, result);
|
|
426
520
|
if (judgment.pass) {
|
|
427
521
|
lastResult = result;
|
|
428
|
-
|
|
522
|
+
break;
|
|
429
523
|
}
|
|
430
524
|
failedAttempts++;
|
|
431
525
|
lastResult = result;
|
|
@@ -448,6 +542,7 @@ async function executeTest(page, test, options) {
|
|
|
448
542
|
}
|
|
449
543
|
}
|
|
450
544
|
} catch (error) {
|
|
545
|
+
if (error instanceof BudgetExhaustedError) throw error;
|
|
451
546
|
stepFailedReason = mask(error instanceof Error ? error.message : String(error));
|
|
452
547
|
}
|
|
453
548
|
const status = stepFailedReason ? "failed" : "passed";
|
|
@@ -525,8 +620,7 @@ async function fromStorageState(file) {
|
|
|
525
620
|
}
|
|
526
621
|
return { storageState: parsed };
|
|
527
622
|
}
|
|
528
|
-
function fromStatic(auth) {
|
|
529
|
-
const mask = new SecretsMask();
|
|
623
|
+
function fromStatic(auth, mask) {
|
|
530
624
|
const session = {};
|
|
531
625
|
if (auth.headers) {
|
|
532
626
|
const headers = {};
|
|
@@ -543,22 +637,21 @@ function fromStatic(auth) {
|
|
|
543
637
|
});
|
|
544
638
|
session.storageState = { cookies, origins: [] };
|
|
545
639
|
}
|
|
546
|
-
return
|
|
640
|
+
return session;
|
|
547
641
|
}
|
|
548
642
|
async function runJourney(...args) {
|
|
549
643
|
try {
|
|
550
644
|
return await executeTest(...args);
|
|
551
645
|
} catch (error) {
|
|
646
|
+
if (error instanceof BudgetExhaustedError) throw error;
|
|
552
647
|
throw new AuthError(
|
|
553
648
|
`Authentication could not run: ${error instanceof Error ? error.message : String(error)}`
|
|
554
649
|
);
|
|
555
650
|
}
|
|
556
651
|
}
|
|
557
652
|
async function fromSteps(options) {
|
|
558
|
-
const { auth, baseUrl, browser, brain, maxRetries, snapshot = defaultSnapshot, onEvent } = options;
|
|
653
|
+
const { auth, baseUrl, browser, brain, maxRetries, snapshot = defaultSnapshot, onEvent, mask } = options;
|
|
559
654
|
const steps = auth.steps;
|
|
560
|
-
const mask = new SecretsMask();
|
|
561
|
-
for (const step of steps) mask.registerFrom(step);
|
|
562
655
|
const context = await browser.newContext();
|
|
563
656
|
try {
|
|
564
657
|
const page = await context.newPage();
|
|
@@ -591,7 +684,7 @@ async function fromSteps(options) {
|
|
|
591
684
|
);
|
|
592
685
|
}
|
|
593
686
|
if (auth.verify) {
|
|
594
|
-
const judgment = await brain.judge(auth.verify, await snapshot(page));
|
|
687
|
+
const judgment = await brain.judge(auth.verify, mask.mask(await snapshot(page)));
|
|
595
688
|
if (!judgment.pass) {
|
|
596
689
|
throw new AuthError(`Authentication could not be verified: ${mask.mask(judgment.reason)}`);
|
|
597
690
|
}
|
|
@@ -624,7 +717,7 @@ async function authenticate(options) {
|
|
|
624
717
|
return fromStorageState(path3.resolve(cwd, auth.storage_state));
|
|
625
718
|
}
|
|
626
719
|
if (auth.headers || auth.cookies) {
|
|
627
|
-
return fromStatic(auth)
|
|
720
|
+
return fromStatic(auth, options.mask);
|
|
628
721
|
}
|
|
629
722
|
const session = await fromSteps(options);
|
|
630
723
|
if (auth.cache) await writeCached(cwd, session);
|
|
@@ -654,6 +747,12 @@ var browserSchema = z.object({
|
|
|
654
747
|
headless: z.boolean().default(true),
|
|
655
748
|
timeout_ms: z.number().int().positive().default(3e4)
|
|
656
749
|
});
|
|
750
|
+
var budgetSchema = z.object({
|
|
751
|
+
max_llm_calls: z.coerce.number().int().positive().optional(),
|
|
752
|
+
max_tokens: z.coerce.number().int().positive().optional(),
|
|
753
|
+
/** Seconds, not milliseconds, to match how a human would set a deadline. */
|
|
754
|
+
max_duration_s: z.coerce.number().positive().optional()
|
|
755
|
+
});
|
|
657
756
|
var cookieSchema = z.object({
|
|
658
757
|
name: z.string().min(1),
|
|
659
758
|
value: z.string(),
|
|
@@ -703,14 +802,18 @@ var configSchema = z.object({
|
|
|
703
802
|
*/
|
|
704
803
|
allowed_origins: z.array(z.string().url()).optional(),
|
|
705
804
|
auth: authSchema.optional(),
|
|
706
|
-
max_retries_per_step: z.number().int().min(1).default(3)
|
|
805
|
+
max_retries_per_step: z.number().int().min(1).default(3),
|
|
806
|
+
budget: budgetSchema.optional()
|
|
707
807
|
});
|
|
708
808
|
var ENV_OVERRIDES = {
|
|
709
809
|
BLASTPROOF_BASE_URL: ["base_url"],
|
|
710
810
|
BLASTPROOF_LLM_PROVIDER: ["llm", "provider"],
|
|
711
811
|
BLASTPROOF_LLM_MODEL: ["llm", "model"],
|
|
712
812
|
BLASTPROOF_LLM_BASE_URL: ["llm", "base_url"],
|
|
713
|
-
BLASTPROOF_LLM_API_KEY_ENV: ["llm", "api_key_env"]
|
|
813
|
+
BLASTPROOF_LLM_API_KEY_ENV: ["llm", "api_key_env"],
|
|
814
|
+
BLASTPROOF_MAX_LLM_CALLS: ["budget", "max_llm_calls"],
|
|
815
|
+
BLASTPROOF_MAX_TOKENS: ["budget", "max_tokens"],
|
|
816
|
+
BLASTPROOF_MAX_DURATION_S: ["budget", "max_duration_s"]
|
|
714
817
|
};
|
|
715
818
|
function applyEnvOverrides(data, env = process.env) {
|
|
716
819
|
const merged = data && typeof data === "object" ? { ...data } : {};
|
|
@@ -958,10 +1061,16 @@ var MalformedModelOutputError = class extends Error {
|
|
|
958
1061
|
this.name = "MalformedModelOutputError";
|
|
959
1062
|
}
|
|
960
1063
|
};
|
|
961
|
-
function
|
|
1064
|
+
async function countedGenerate(generate, budget, options) {
|
|
1065
|
+
budget.check();
|
|
1066
|
+
const result = await generate(options);
|
|
1067
|
+
budget.record(result.usage);
|
|
1068
|
+
return result;
|
|
1069
|
+
}
|
|
1070
|
+
function createBrain(model, generate = generateObject, budget) {
|
|
962
1071
|
return {
|
|
963
1072
|
async nextAction(input) {
|
|
964
|
-
const result = await generate
|
|
1073
|
+
const result = await countedGenerate(generate, budget, {
|
|
965
1074
|
model,
|
|
966
1075
|
schema: agentActionSchema,
|
|
967
1076
|
system: agentSystemPrompt(),
|
|
@@ -976,7 +1085,7 @@ function createBrain(model, generate = generateObject) {
|
|
|
976
1085
|
return parsed.data;
|
|
977
1086
|
},
|
|
978
1087
|
async judge(expectation, snapshot) {
|
|
979
|
-
const result = await generate
|
|
1088
|
+
const result = await countedGenerate(generate, budget, {
|
|
980
1089
|
model,
|
|
981
1090
|
schema: assertJudgmentSchema,
|
|
982
1091
|
system: assertSystemPrompt(),
|
|
@@ -992,10 +1101,10 @@ function createBrain(model, generate = generateObject) {
|
|
|
992
1101
|
}
|
|
993
1102
|
};
|
|
994
1103
|
}
|
|
995
|
-
function createPlanner(model, generate = generateObject) {
|
|
1104
|
+
function createPlanner(model, generate = generateObject, budget) {
|
|
996
1105
|
return {
|
|
997
1106
|
async planTest(input) {
|
|
998
|
-
const result = await generate
|
|
1107
|
+
const result = await countedGenerate(generate, budget, {
|
|
999
1108
|
model,
|
|
1000
1109
|
schema: generatedTestSchema,
|
|
1001
1110
|
system: plannerSystemPrompt(),
|
|
@@ -1168,7 +1277,7 @@ function renderTestYaml(draft, meta) {
|
|
|
1168
1277
|
});
|
|
1169
1278
|
}
|
|
1170
1279
|
async function generateForRoute(page, options) {
|
|
1171
|
-
const { route, baseUrl, changedFiles, brain, snapshot = defaultSnapshot } = options;
|
|
1280
|
+
const { route, baseUrl, changedFiles, brain, mask, snapshot = defaultSnapshot } = options;
|
|
1172
1281
|
const url = new URL(route, baseUrl).toString();
|
|
1173
1282
|
try {
|
|
1174
1283
|
await page.goto(url, { timeout: 3e4 });
|
|
@@ -1177,7 +1286,11 @@ async function generateForRoute(page, options) {
|
|
|
1177
1286
|
`Cannot load ${url}: ${error instanceof Error ? error.message : String(error)}`
|
|
1178
1287
|
);
|
|
1179
1288
|
}
|
|
1180
|
-
const generated = await brain.planTest({
|
|
1289
|
+
const generated = await brain.planTest({
|
|
1290
|
+
route,
|
|
1291
|
+
snapshot: mask(await snapshot(page)),
|
|
1292
|
+
changedFiles
|
|
1293
|
+
});
|
|
1181
1294
|
const leaked = findSecretLiterals(generated.steps);
|
|
1182
1295
|
if (leaked.length > 0) {
|
|
1183
1296
|
throw new PlannerError(
|
|
@@ -1249,6 +1362,8 @@ h1 { font-size:1.35rem; margin:0 0 .25rem; }
|
|
|
1249
1362
|
.verdict { font-weight:600; }
|
|
1250
1363
|
.verdict.pass { color:var(--pass); } .verdict.fail { color:var(--fail); }
|
|
1251
1364
|
.counts { color:var(--muted); font-size:.9rem; }
|
|
1365
|
+
.incomplete { padding:1rem 1.25rem; border:1px solid var(--fail); border-radius:10px;
|
|
1366
|
+
background:color-mix(in srgb, var(--fail) 10%, transparent); margin-bottom:1.5rem; font-weight:600; }
|
|
1252
1367
|
table { width:100%; border-collapse:collapse; margin-bottom:1.5rem; }
|
|
1253
1368
|
th, td { text-align:left; padding:.5rem .6rem; border-bottom:1px solid var(--line);
|
|
1254
1369
|
font-size:.9rem; vertical-align:top; }
|
|
@@ -1285,10 +1400,13 @@ function renderSteps(steps) {
|
|
|
1285
1400
|
}
|
|
1286
1401
|
async function renderTest(result, cwd) {
|
|
1287
1402
|
const failed = result.status === "failed";
|
|
1403
|
+
const notRun = result.status === "not-run";
|
|
1404
|
+
const tagClass = failed ? "fail" : notRun ? "skip" : "pass";
|
|
1405
|
+
const label = failed ? "FAIL" : notRun ? "NOT RUN" : "PASS";
|
|
1288
1406
|
const file = cwd ? path7.relative(cwd, result.file) : result.file;
|
|
1289
1407
|
const parts = [
|
|
1290
|
-
` <details${failed ? " open" : ""}>`,
|
|
1291
|
-
` <summary><span class="tag ${
|
|
1408
|
+
` <details${failed || notRun ? " open" : ""}>`,
|
|
1409
|
+
` <summary><span class="tag ${tagClass}">${label}</span> ${escapeHtml(result.summary)} <span class="note">${escapeHtml(result.priority)} \xB7 ${escapeHtml(file)}</span></summary>`
|
|
1292
1410
|
];
|
|
1293
1411
|
if (failed) {
|
|
1294
1412
|
if (result.failedStep) {
|
|
@@ -1298,6 +1416,9 @@ async function renderTest(result, cwd) {
|
|
|
1298
1416
|
parts.push(` <p class="reason">${escapeHtml(result.reason)}</p>`);
|
|
1299
1417
|
}
|
|
1300
1418
|
}
|
|
1419
|
+
if (notRun && result.reason) {
|
|
1420
|
+
parts.push(` <p class="note">${escapeHtml(result.reason)}</p>`);
|
|
1421
|
+
}
|
|
1301
1422
|
parts.push(` ${renderSteps(result.steps)}`);
|
|
1302
1423
|
if (failed && result.screenshot) {
|
|
1303
1424
|
const embedded = await embedScreenshot(result.screenshot);
|
|
@@ -1311,13 +1432,20 @@ async function renderTest(result, cwd) {
|
|
|
1311
1432
|
async function renderHtml(results, skipped, meta) {
|
|
1312
1433
|
const passed = results.filter((r) => r.status === "passed").length;
|
|
1313
1434
|
const failures = results.filter((r) => r.status === "failed");
|
|
1435
|
+
const notRun = results.filter((r) => r.status === "not-run");
|
|
1314
1436
|
const generatedAt = (meta.generatedAt ?? /* @__PURE__ */ new Date()).toISOString();
|
|
1315
|
-
const ordered = [...failures, ...results.filter((r) => r.status === "passed")];
|
|
1437
|
+
const ordered = [...failures, ...notRun, ...results.filter((r) => r.status === "passed")];
|
|
1316
1438
|
const detail = (await Promise.all(ordered.map((r) => renderTest(r, meta.cwd)))).join("\n");
|
|
1317
1439
|
const verdict = meta.minScore === void 0 ? "" : meta.score >= meta.minScore ? `<span class="verdict pass">min-score ${meta.minScore}: pass</span>` : `<span class="verdict fail">min-score ${meta.minScore}: FAIL</span>`;
|
|
1440
|
+
const banner = meta.incomplete === void 0 ? "" : ` <section class="incomplete">Run stopped: ${escapeHtml(meta.incomplete)}</section>
|
|
1441
|
+
|
|
1442
|
+
`;
|
|
1318
1443
|
const rows = results.map((r) => {
|
|
1319
1444
|
const file = meta.cwd ? path7.relative(meta.cwd, r.file) : r.file;
|
|
1320
|
-
|
|
1445
|
+
const tagClass = r.status === "failed" ? "fail" : r.status === "not-run" ? "skip" : "pass";
|
|
1446
|
+
const label = r.status === "failed" ? "FAIL" : r.status === "not-run" ? "NOT RUN" : "PASS";
|
|
1447
|
+
const time = r.status === "not-run" ? "\u2014" : seconds(r.durationMs);
|
|
1448
|
+
return ` <tr><td><span class="tag ${tagClass}">${label}</span></td><td>${escapeHtml(r.priority)}</td><td>${escapeHtml(r.summary)}</td><td>${escapeHtml(file)}</td><td class="num">${time}</td></tr>`;
|
|
1321
1449
|
}).join("\n");
|
|
1322
1450
|
const skippedRows = skipped.map((s) => {
|
|
1323
1451
|
const file = meta.cwd ? path7.relative(meta.cwd, s.path) : s.path;
|
|
@@ -1336,10 +1464,10 @@ async function renderHtml(results, skipped, meta) {
|
|
|
1336
1464
|
<h1>blastproof report</h1>
|
|
1337
1465
|
<p class="sub">${escapeHtml(generatedAt)} \xB7 ${seconds(meta.durationMs)}</p>
|
|
1338
1466
|
|
|
1339
|
-
<section class="score">
|
|
1467
|
+
${banner} <section class="score">
|
|
1340
1468
|
<b>${meta.score}</b>
|
|
1341
1469
|
${verdict}
|
|
1342
|
-
<span class="counts">${passed} passed \xB7 ${failures.length} failed${skipped.length > 0 ? ` \xB7 ${skipped.length} skipped` : ""}</span>
|
|
1470
|
+
<span class="counts">${passed} passed \xB7 ${failures.length} failed${notRun.length > 0 ? ` \xB7 ${notRun.length} not run` : ""}${skipped.length > 0 ? ` \xB7 ${skipped.length} skipped` : ""}</span>
|
|
1343
1471
|
</section>
|
|
1344
1472
|
|
|
1345
1473
|
<table>
|
|
@@ -1382,14 +1510,27 @@ function seconds2(ms) {
|
|
|
1382
1510
|
function renderJUnit(results, skipped, meta) {
|
|
1383
1511
|
const relative = (file) => meta.cwd ? path8.relative(meta.cwd, file) : file;
|
|
1384
1512
|
const failures = results.filter((result) => result.status === "failed").length;
|
|
1513
|
+
const notRun = results.filter((result) => result.status === "not-run");
|
|
1385
1514
|
const lines = [
|
|
1386
1515
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
1387
|
-
`<testsuite name="blastproof" tests="${results.length + skipped.length}" failures="${failures}" skipped="${skipped.length}" time="${seconds2(meta.durationMs)}">`,
|
|
1516
|
+
`<testsuite name="blastproof" tests="${results.length + skipped.length}" failures="${failures}" skipped="${skipped.length + notRun.length}" time="${seconds2(meta.durationMs)}">`,
|
|
1388
1517
|
" <properties>",
|
|
1389
1518
|
` <property name="score" value="${meta.score}"/>`,
|
|
1519
|
+
...meta.incomplete !== void 0 ? [
|
|
1520
|
+
' <property name="incomplete" value="true"/>',
|
|
1521
|
+
` <property name="incomplete_reason" value="${escapeXml(meta.incomplete)}"/>`
|
|
1522
|
+
] : [],
|
|
1390
1523
|
" </properties>"
|
|
1391
1524
|
];
|
|
1392
1525
|
for (const result of results) {
|
|
1526
|
+
if (result.status === "not-run") {
|
|
1527
|
+
lines.push(
|
|
1528
|
+
` <testcase classname="${escapeXml(relative(result.file))}" name="${escapeXml(result.summary)}" time="0.000">`,
|
|
1529
|
+
` <skipped message="${escapeXml(result.reason ?? "not run: the run stopped before this test executed")}"/>`,
|
|
1530
|
+
" </testcase>"
|
|
1531
|
+
);
|
|
1532
|
+
continue;
|
|
1533
|
+
}
|
|
1393
1534
|
const open = ` <testcase classname="${escapeXml(relative(result.file))}" name="${escapeXml(result.summary)}" time="${seconds2(result.durationMs)}"`;
|
|
1394
1535
|
if (result.status === "passed") {
|
|
1395
1536
|
lines.push(`${open}/>`);
|
|
@@ -1425,6 +1566,7 @@ function computeScore(results) {
|
|
|
1425
1566
|
let total = 0;
|
|
1426
1567
|
let passed = 0;
|
|
1427
1568
|
for (const result of results) {
|
|
1569
|
+
if (result.status === "not-run") continue;
|
|
1428
1570
|
const weight = WEIGHTS[result.priority] ?? DEFAULT_WEIGHT;
|
|
1429
1571
|
total += weight;
|
|
1430
1572
|
if (result.status === "passed") passed += weight;
|
|
@@ -1440,6 +1582,10 @@ function formatScoreLine(score, results, threshold) {
|
|
|
1440
1582
|
if (threshold === void 0) return `Score: ${score}`;
|
|
1441
1583
|
return score >= threshold ? `Score: ${score} \u2014 min-score ${threshold}: pass` : `Score: ${score} \u2014 min-score ${threshold}: FAIL (below threshold)`;
|
|
1442
1584
|
}
|
|
1585
|
+
function formatIncompleteLine(score, reason) {
|
|
1586
|
+
return `Run incomplete: ${reason}
|
|
1587
|
+
Score over executed tests: ${score} (not a verdict \u2014 exit code 1 regardless of --min-score)`;
|
|
1588
|
+
}
|
|
1443
1589
|
|
|
1444
1590
|
// src/runner/selection.ts
|
|
1445
1591
|
function matchesFilters(test, filters) {
|
|
@@ -1479,6 +1625,14 @@ function selectImpactedTests(tests, affectedRoutes, filters = { tags: [] }) {
|
|
|
1479
1625
|
var EXIT_OK = 0;
|
|
1480
1626
|
var EXIT_FAILED = 1;
|
|
1481
1627
|
var EXIT_USAGE = 2;
|
|
1628
|
+
function resolveBudgetOptions(config, options) {
|
|
1629
|
+
const maxDurationSeconds = options.maxDuration ?? config.budget?.max_duration_s;
|
|
1630
|
+
return {
|
|
1631
|
+
maxCalls: options.maxLlmCalls ?? config.budget?.max_llm_calls,
|
|
1632
|
+
maxTokens: options.maxTokens ?? config.budget?.max_tokens,
|
|
1633
|
+
maxDurationMs: maxDurationSeconds === void 0 ? void 0 : maxDurationSeconds * 1e3
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1482
1636
|
function sessionId() {
|
|
1483
1637
|
return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1484
1638
|
}
|
|
@@ -1493,12 +1647,33 @@ function applyUrlOverride(config, url) {
|
|
|
1493
1647
|
}
|
|
1494
1648
|
return { ...config, base_url: url };
|
|
1495
1649
|
}
|
|
1496
|
-
function
|
|
1650
|
+
function buildRunMask(config, tests) {
|
|
1497
1651
|
const mask = new SecretsMask();
|
|
1652
|
+
for (const step of config.auth?.steps ?? []) mask.registerFrom(step);
|
|
1653
|
+
for (const value of Object.values(config.auth?.headers ?? {})) mask.registerFrom(value);
|
|
1654
|
+
for (const cookie of config.auth?.cookies ?? []) mask.registerFrom(cookie.value);
|
|
1655
|
+
for (const test of tests) {
|
|
1656
|
+
for (const step of [...test.setup ?? [], ...test.steps]) mask.registerFrom(step);
|
|
1657
|
+
}
|
|
1658
|
+
return mask;
|
|
1659
|
+
}
|
|
1660
|
+
function resolveSecretsAndSteps(test) {
|
|
1498
1661
|
for (const step of [...test.setup ?? [], ...test.steps]) {
|
|
1499
|
-
|
|
1662
|
+
new SecretsMask().registerFrom(step);
|
|
1500
1663
|
}
|
|
1501
|
-
return { test
|
|
1664
|
+
return { test };
|
|
1665
|
+
}
|
|
1666
|
+
function notRunResults(tests, reason) {
|
|
1667
|
+
return tests.map((test) => ({
|
|
1668
|
+
file: test.path,
|
|
1669
|
+
summary: test.summary,
|
|
1670
|
+
priority: test.priority,
|
|
1671
|
+
tags: test.tags,
|
|
1672
|
+
status: "not-run",
|
|
1673
|
+
steps: [],
|
|
1674
|
+
reason,
|
|
1675
|
+
durationMs: 0
|
|
1676
|
+
}));
|
|
1502
1677
|
}
|
|
1503
1678
|
function printEvent(event) {
|
|
1504
1679
|
switch (event.type) {
|
|
@@ -1519,12 +1694,18 @@ function printEvent(event) {
|
|
|
1519
1694
|
break;
|
|
1520
1695
|
}
|
|
1521
1696
|
}
|
|
1697
|
+
function statusLabel(status) {
|
|
1698
|
+
if (status === "passed") return "PASS";
|
|
1699
|
+
if (status === "failed") return "FAIL";
|
|
1700
|
+
return "NOT RUN";
|
|
1701
|
+
}
|
|
1522
1702
|
function printSummary(results) {
|
|
1523
1703
|
const passed = results.filter((r) => r.status === "passed");
|
|
1524
1704
|
const failed = results.filter((r) => r.status === "failed");
|
|
1705
|
+
const notRun = results.filter((r) => r.status === "not-run");
|
|
1525
1706
|
console.log("\n--- Summary ---------------------------------------------------");
|
|
1526
1707
|
const rows = results.map((r) => ({
|
|
1527
|
-
status: r.status
|
|
1708
|
+
status: statusLabel(r.status),
|
|
1528
1709
|
priority: r.priority,
|
|
1529
1710
|
summary: r.summary,
|
|
1530
1711
|
duration: `${(r.durationMs / 1e3).toFixed(1)}s`
|
|
@@ -1537,7 +1718,9 @@ function printSummary(results) {
|
|
|
1537
1718
|
);
|
|
1538
1719
|
}
|
|
1539
1720
|
console.log("---------------------------------------------------------------");
|
|
1540
|
-
console.log(
|
|
1721
|
+
console.log(
|
|
1722
|
+
notRun.length > 0 ? `${passed.length} passed, ${failed.length} failed, ${notRun.length} not run, ${results.length} total` : `${passed.length} passed, ${failed.length} failed, ${results.length} total`
|
|
1723
|
+
);
|
|
1541
1724
|
for (const r of failed) {
|
|
1542
1725
|
console.log(`
|
|
1543
1726
|
X ${r.summary} (${r.file})`);
|
|
@@ -1545,13 +1728,18 @@ X ${r.summary} (${r.file})`);
|
|
|
1545
1728
|
if (r.reason) console.log(` reason: ${r.reason}`);
|
|
1546
1729
|
if (r.screenshot) console.log(` screenshot: ${r.screenshot}`);
|
|
1547
1730
|
}
|
|
1731
|
+
if (notRun.length > 0) {
|
|
1732
|
+
console.log(`
|
|
1733
|
+
${notRun.length} test(s) not run (run stopped by its budget or deadline):`);
|
|
1734
|
+
for (const r of notRun) console.log(` - ${r.summary} (${r.file})`);
|
|
1735
|
+
}
|
|
1548
1736
|
}
|
|
1549
|
-
async function runOne(browser, test, config, sessionDir, session) {
|
|
1550
|
-
const brain = createBrain(createModel(config.llm).model);
|
|
1737
|
+
async function runOne(browser, test, config, sessionDir, session, runMask, budget) {
|
|
1738
|
+
const brain = createBrain(createModel(config.llm).model, void 0, budget);
|
|
1551
1739
|
let resolved;
|
|
1552
|
-
|
|
1740
|
+
const mask = runMask;
|
|
1553
1741
|
try {
|
|
1554
|
-
({ test: resolved
|
|
1742
|
+
({ test: resolved } = resolveSecretsAndSteps(test));
|
|
1555
1743
|
} catch (error) {
|
|
1556
1744
|
if (error instanceof MissingEnvError) {
|
|
1557
1745
|
return {
|
|
@@ -1610,13 +1798,20 @@ function printImpactReport(impact, selection, cwd) {
|
|
|
1610
1798
|
}
|
|
1611
1799
|
console.log("---------------------------------------------------------------");
|
|
1612
1800
|
}
|
|
1613
|
-
async function finalize(results, skipped, options, sessionDir, durationMs, impact) {
|
|
1801
|
+
async function finalize(results, skipped, options, sessionDir, durationMs, impact, incomplete) {
|
|
1614
1802
|
if (results.length > 0) printSummary(results);
|
|
1615
1803
|
const score = computeScore(results);
|
|
1616
|
-
console.log(
|
|
1804
|
+
console.log(
|
|
1805
|
+
incomplete ? formatIncompleteLine(score, incomplete.message) : formatScoreLine(score, results, options.minScore)
|
|
1806
|
+
);
|
|
1617
1807
|
if (options.junit) {
|
|
1618
1808
|
const target = typeof options.junit === "string" ? path9.resolve(options.cwd, options.junit) : path9.join(sessionDir, "junit.xml");
|
|
1619
|
-
const xml = renderJUnit(results, skipped, {
|
|
1809
|
+
const xml = renderJUnit(results, skipped, {
|
|
1810
|
+
score,
|
|
1811
|
+
durationMs,
|
|
1812
|
+
cwd: options.cwd,
|
|
1813
|
+
incomplete: incomplete?.message
|
|
1814
|
+
});
|
|
1620
1815
|
await writeJUnit(target, xml);
|
|
1621
1816
|
console.log(`JUnit report: ${path9.relative(options.cwd, target)}`);
|
|
1622
1817
|
}
|
|
@@ -1626,33 +1821,48 @@ async function finalize(results, skipped, options, sessionDir, durationMs, impac
|
|
|
1626
1821
|
score,
|
|
1627
1822
|
durationMs,
|
|
1628
1823
|
minScore: options.minScore,
|
|
1629
|
-
cwd: options.cwd
|
|
1824
|
+
cwd: options.cwd,
|
|
1825
|
+
incomplete: incomplete?.message
|
|
1630
1826
|
});
|
|
1631
1827
|
await writeHtml(target, html);
|
|
1632
1828
|
console.log(`HTML report: ${path9.relative(options.cwd, target)}`);
|
|
1633
1829
|
}
|
|
1634
|
-
|
|
1635
|
-
if (
|
|
1636
|
-
console.error(
|
|
1637
|
-
`error: ${unclassified.length} changed file(s) match no routes: or ignore: glob, so their blast radius is unknown:`
|
|
1638
|
-
);
|
|
1639
|
-
for (const file of unclassified) console.error(` ${file}`);
|
|
1640
|
-
console.error(
|
|
1641
|
-
"Map them in routes: if they can affect a page, or list them in ignore: if they cannot."
|
|
1642
|
-
);
|
|
1643
|
-
return EXIT_FAILED;
|
|
1644
|
-
}
|
|
1830
|
+
if (incomplete) return EXIT_FAILED;
|
|
1831
|
+
if (reportUnclassified(options, impact)) return EXIT_FAILED;
|
|
1645
1832
|
if (options.minScore !== void 0) {
|
|
1646
1833
|
return score >= options.minScore ? EXIT_OK : EXIT_FAILED;
|
|
1647
1834
|
}
|
|
1648
1835
|
return results.some((result) => result.status === "failed") ? EXIT_FAILED : EXIT_OK;
|
|
1649
1836
|
}
|
|
1650
|
-
function
|
|
1837
|
+
function reportUnclassified(options, impact) {
|
|
1838
|
+
const unclassified = options.failOnUnmapped ? impact?.unmappedFiles ?? [] : [];
|
|
1839
|
+
if (unclassified.length === 0) return false;
|
|
1840
|
+
console.error(
|
|
1841
|
+
`error: ${unclassified.length} changed file(s) match no routes: or ignore: glob, so their blast radius is unknown:`
|
|
1842
|
+
);
|
|
1843
|
+
for (const file of unclassified) console.error(` ${file}`);
|
|
1844
|
+
console.error(
|
|
1845
|
+
"Map them in routes: if they can affect a page, or list them in ignore: if they cannot."
|
|
1846
|
+
);
|
|
1847
|
+
return true;
|
|
1848
|
+
}
|
|
1849
|
+
function printDryRun(selected, config, cwd) {
|
|
1651
1850
|
console.log(`
|
|
1652
|
-
Dry run: ${selected.length} test(s) selected, base_url=${
|
|
1851
|
+
Dry run: ${selected.length} test(s) selected, base_url=${config.base_url}`);
|
|
1653
1852
|
for (const test of selected) {
|
|
1654
1853
|
console.log(` ${test.summary} [${test.priority}] (${path9.relative(cwd, test.path)})`);
|
|
1655
1854
|
}
|
|
1855
|
+
const authSteps = config.auth?.steps ?? [];
|
|
1856
|
+
const withAuth = authSteps.length > 0 ? [...selected, { steps: authSteps }] : selected;
|
|
1857
|
+
const ceiling = estimateMaxModelCalls(
|
|
1858
|
+
withAuth,
|
|
1859
|
+
DEFAULT_MAX_ITERATIONS_PER_STEP,
|
|
1860
|
+
config.max_retries_per_step
|
|
1861
|
+
);
|
|
1862
|
+
const authNote = authSteps.length > 0 ? ", including the login journey" : "";
|
|
1863
|
+
console.log(
|
|
1864
|
+
`Worst case: up to ${ceiling} model call(s) for this selection${authNote} (a maximum, not a prediction).`
|
|
1865
|
+
);
|
|
1656
1866
|
console.log("Dry run: no browser launched, no LLM calls made.");
|
|
1657
1867
|
}
|
|
1658
1868
|
async function runCommand(options) {
|
|
@@ -1668,6 +1878,12 @@ async function runCommand(options) {
|
|
|
1668
1878
|
}
|
|
1669
1879
|
const impacted = options.impacted ?? false;
|
|
1670
1880
|
const base = options.base ?? "main";
|
|
1881
|
+
if (options.failOnUnmapped && !impacted) {
|
|
1882
|
+
console.error(
|
|
1883
|
+
"error: --fail-on-unmapped classifies the files in a diff, so it requires --impacted."
|
|
1884
|
+
);
|
|
1885
|
+
return EXIT_USAGE;
|
|
1886
|
+
}
|
|
1671
1887
|
let impact;
|
|
1672
1888
|
if (impacted) {
|
|
1673
1889
|
let changedFiles;
|
|
@@ -1722,8 +1938,14 @@ async function runCommand(options) {
|
|
|
1722
1938
|
printImpactReport(impact, selection, options.cwd);
|
|
1723
1939
|
}
|
|
1724
1940
|
if (options.dryRun) {
|
|
1725
|
-
printDryRun(selected, config
|
|
1726
|
-
|
|
1941
|
+
printDryRun(selected, config, options.cwd);
|
|
1942
|
+
if (results.length > 0) {
|
|
1943
|
+
console.error(`
|
|
1944
|
+
${results.length} test file(s) could not be parsed:`);
|
|
1945
|
+
for (const broken of results) console.error(` ${broken.file}: ${broken.reason ?? "invalid"}`);
|
|
1946
|
+
return EXIT_FAILED;
|
|
1947
|
+
}
|
|
1948
|
+
return reportUnclassified(options, impact) ? EXIT_FAILED : EXIT_OK;
|
|
1727
1949
|
}
|
|
1728
1950
|
const sessionDir = path9.join(options.cwd, ".blastproof", "reports", sessionId());
|
|
1729
1951
|
const startedAt = Date.now();
|
|
@@ -1746,7 +1968,10 @@ async function runCommand(options) {
|
|
|
1746
1968
|
console.log(
|
|
1747
1969
|
`blastproof run: ${selected.length} test(s), provider=${provider} model=${modelId}, base_url=${config.base_url}`
|
|
1748
1970
|
);
|
|
1971
|
+
const runMask = buildRunMask(config, parsed);
|
|
1972
|
+
const budget = options.budget ?? new RunBudget(resolveBudgetOptions(config, options));
|
|
1749
1973
|
const browser = await chromium.launch({ headless: config.browser.headless });
|
|
1974
|
+
let incomplete;
|
|
1750
1975
|
try {
|
|
1751
1976
|
let session;
|
|
1752
1977
|
if (config.auth) {
|
|
@@ -1758,27 +1983,54 @@ async function runCommand(options) {
|
|
|
1758
1983
|
baseUrl: config.base_url,
|
|
1759
1984
|
allowedOrigins: config.allowed_origins,
|
|
1760
1985
|
browser,
|
|
1761
|
-
brain: createBrain(createModel(config.llm).model),
|
|
1986
|
+
brain: createBrain(createModel(config.llm).model, void 0, budget),
|
|
1762
1987
|
maxRetries: config.max_retries_per_step,
|
|
1988
|
+
mask: runMask,
|
|
1763
1989
|
onEvent: printEvent
|
|
1764
1990
|
});
|
|
1765
1991
|
} catch (error) {
|
|
1766
|
-
if (error instanceof
|
|
1992
|
+
if (error instanceof BudgetExhaustedError) {
|
|
1993
|
+
incomplete = error;
|
|
1994
|
+
} else if (error instanceof AuthError) {
|
|
1767
1995
|
console.error(`error: ${error.message}`);
|
|
1768
1996
|
return EXIT_USAGE;
|
|
1997
|
+
} else {
|
|
1998
|
+
throw error;
|
|
1769
1999
|
}
|
|
1770
|
-
throw error;
|
|
1771
2000
|
}
|
|
1772
2001
|
}
|
|
1773
|
-
|
|
1774
|
-
|
|
2002
|
+
if (incomplete) {
|
|
2003
|
+
results.push(...notRunResults(selected, incomplete.message));
|
|
2004
|
+
} else {
|
|
2005
|
+
for (let i = 0; i < selected.length; i++) {
|
|
2006
|
+
const test = selected[i];
|
|
2007
|
+
console.log(`
|
|
1775
2008
|
> ${test.summary} [${test.priority}] (${path9.relative(options.cwd, test.path)})`);
|
|
1776
|
-
|
|
2009
|
+
try {
|
|
2010
|
+
budget.check();
|
|
2011
|
+
results.push(await runOne(browser, test, config, sessionDir, session, runMask, budget));
|
|
2012
|
+
} catch (error) {
|
|
2013
|
+
if (error instanceof BudgetExhaustedError) {
|
|
2014
|
+
incomplete = error;
|
|
2015
|
+
results.push(...notRunResults(selected.slice(i), error.message));
|
|
2016
|
+
break;
|
|
2017
|
+
}
|
|
2018
|
+
throw error;
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
1777
2021
|
}
|
|
1778
2022
|
} finally {
|
|
1779
2023
|
await browser.close();
|
|
1780
2024
|
}
|
|
1781
|
-
return finalize(
|
|
2025
|
+
return finalize(
|
|
2026
|
+
results,
|
|
2027
|
+
selection.unroutedSkipped,
|
|
2028
|
+
options,
|
|
2029
|
+
sessionDir,
|
|
2030
|
+
Date.now() - startedAt,
|
|
2031
|
+
impact,
|
|
2032
|
+
incomplete
|
|
2033
|
+
);
|
|
1782
2034
|
}
|
|
1783
2035
|
|
|
1784
2036
|
// src/commands/plan.ts
|
|
@@ -1873,14 +2125,23 @@ async function planCommand(options) {
|
|
|
1873
2125
|
throw error;
|
|
1874
2126
|
}
|
|
1875
2127
|
const { model, provider, modelId } = createModel(config.llm);
|
|
1876
|
-
const
|
|
2128
|
+
const budget = options.budget ?? new RunBudget(resolveBudgetOptions(config, options));
|
|
2129
|
+
const brain = createPlanner(model, void 0, budget);
|
|
1877
2130
|
const base = options.routes && options.routes.length > 0 ? void 0 : options.base ?? "main";
|
|
1878
2131
|
console.log(
|
|
1879
2132
|
`blastproof plan: ${work.length} route(s), provider=${provider} model=${modelId}, base_url=${config.base_url}`
|
|
1880
2133
|
);
|
|
2134
|
+
const mask = new SecretsMask();
|
|
2135
|
+
for (const step of config.auth?.steps ?? []) mask.registerFrom(step);
|
|
2136
|
+
for (const value of Object.values(config.auth?.headers ?? {})) mask.registerFrom(value);
|
|
2137
|
+
for (const cookie of config.auth?.cookies ?? []) mask.registerFrom(cookie.value);
|
|
2138
|
+
for (const test of suite) {
|
|
2139
|
+
for (const step of [...test.setup ?? [], ...test.steps]) mask.registerFrom(step);
|
|
2140
|
+
}
|
|
1881
2141
|
const generated = [];
|
|
1882
2142
|
const written = [];
|
|
1883
2143
|
const failed = [];
|
|
2144
|
+
let incomplete;
|
|
1884
2145
|
const browser = await chromium2.launch({ headless: config.browser.headless });
|
|
1885
2146
|
try {
|
|
1886
2147
|
let session;
|
|
@@ -1893,19 +2154,23 @@ async function planCommand(options) {
|
|
|
1893
2154
|
baseUrl: config.base_url,
|
|
1894
2155
|
allowedOrigins: config.allowed_origins,
|
|
1895
2156
|
browser,
|
|
1896
|
-
brain: createBrain(createModel(config.llm).model),
|
|
2157
|
+
brain: createBrain(createModel(config.llm).model, void 0, budget),
|
|
1897
2158
|
maxRetries: config.max_retries_per_step,
|
|
2159
|
+
mask,
|
|
1898
2160
|
onEvent: printAuthEvent
|
|
1899
2161
|
});
|
|
1900
2162
|
} catch (error) {
|
|
1901
|
-
if (error instanceof
|
|
2163
|
+
if (error instanceof BudgetExhaustedError) {
|
|
2164
|
+
incomplete = error;
|
|
2165
|
+
} else if (error instanceof AuthError) {
|
|
1902
2166
|
console.error(`error: ${error.message}`);
|
|
1903
2167
|
return EXIT_USAGE;
|
|
2168
|
+
} else {
|
|
2169
|
+
throw error;
|
|
1904
2170
|
}
|
|
1905
|
-
throw error;
|
|
1906
2171
|
}
|
|
1907
2172
|
}
|
|
1908
|
-
for (const { route, changedFiles } of work) {
|
|
2173
|
+
for (const { route, changedFiles } of incomplete ? [] : work) {
|
|
1909
2174
|
console.log(`
|
|
1910
2175
|
> ${route}`);
|
|
1911
2176
|
let draft;
|
|
@@ -1917,9 +2182,14 @@ async function planCommand(options) {
|
|
|
1917
2182
|
route,
|
|
1918
2183
|
baseUrl: config.base_url,
|
|
1919
2184
|
changedFiles,
|
|
1920
|
-
brain
|
|
2185
|
+
brain,
|
|
2186
|
+
mask: (text) => mask.mask(text)
|
|
1921
2187
|
});
|
|
1922
2188
|
} catch (error) {
|
|
2189
|
+
if (error instanceof BudgetExhaustedError) {
|
|
2190
|
+
incomplete = error;
|
|
2191
|
+
break;
|
|
2192
|
+
}
|
|
1923
2193
|
failed.push({ route, reason: error instanceof Error ? error.message : String(error) });
|
|
1924
2194
|
console.log(` X failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1925
2195
|
continue;
|
|
@@ -1949,6 +2219,7 @@ ${renderTestYaml(draft, { route, base })}`);
|
|
|
1949
2219
|
} finally {
|
|
1950
2220
|
await browser.close();
|
|
1951
2221
|
}
|
|
2222
|
+
const notAttempted = incomplete ? work.map((item) => item.route).filter((route) => !generated.includes(route) && !failed.some((f) => f.route === route)) : [];
|
|
1952
2223
|
console.log("\n--- Plan -------------------------------------------------------");
|
|
1953
2224
|
console.log(`Generated: ${generated.length > 0 ? generated.join(", ") : "none"}`);
|
|
1954
2225
|
if (written.length > 0) {
|
|
@@ -1962,8 +2233,15 @@ ${renderTestYaml(draft, { route, base })}`);
|
|
|
1962
2233
|
console.log("Failed:");
|
|
1963
2234
|
for (const { route, reason } of failed) console.log(` ${route}: ${reason}`);
|
|
1964
2235
|
}
|
|
2236
|
+
if (incomplete) {
|
|
2237
|
+
console.log(`Stopped: ${incomplete.message}`);
|
|
2238
|
+
if (notAttempted.length > 0) {
|
|
2239
|
+
console.log("Not attempted (run out of budget):");
|
|
2240
|
+
for (const route of notAttempted) console.log(` ${route}`);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
1965
2243
|
console.log("---------------------------------------------------------------");
|
|
1966
|
-
return failed.length > 0 ? EXIT_FAILED : EXIT_OK;
|
|
2244
|
+
return incomplete || failed.length > 0 ? EXIT_FAILED : EXIT_OK;
|
|
1967
2245
|
}
|
|
1968
2246
|
|
|
1969
2247
|
// src/commands/test.ts
|
|
@@ -1972,6 +2250,17 @@ function section(title) {
|
|
|
1972
2250
|
=== ${title} ${"=".repeat(Math.max(0, 58 - title.length))}`);
|
|
1973
2251
|
}
|
|
1974
2252
|
async function testCommand(options) {
|
|
2253
|
+
let config;
|
|
2254
|
+
try {
|
|
2255
|
+
config = applyUrlOverride(await loadConfig(options.cwd), options.url);
|
|
2256
|
+
} catch (error) {
|
|
2257
|
+
if (error instanceof ConfigError) {
|
|
2258
|
+
console.error(`error: ${error.message}`);
|
|
2259
|
+
return EXIT_USAGE;
|
|
2260
|
+
}
|
|
2261
|
+
throw error;
|
|
2262
|
+
}
|
|
2263
|
+
const budget = new RunBudget(resolveBudgetOptions(config, options));
|
|
1975
2264
|
section("Verify: tests covering the affected routes");
|
|
1976
2265
|
const runCode = await runCommand({
|
|
1977
2266
|
cwd: options.cwd,
|
|
@@ -1982,7 +2271,8 @@ async function testCommand(options) {
|
|
|
1982
2271
|
minScore: options.minScore,
|
|
1983
2272
|
junit: options.junit,
|
|
1984
2273
|
html: options.html,
|
|
1985
|
-
failOnUnmapped: options.failOnUnmapped
|
|
2274
|
+
failOnUnmapped: options.failOnUnmapped,
|
|
2275
|
+
budget
|
|
1986
2276
|
});
|
|
1987
2277
|
if (runCode === EXIT_USAGE) return EXIT_USAGE;
|
|
1988
2278
|
section("Draft: affected routes no test covers");
|
|
@@ -1990,7 +2280,8 @@ async function testCommand(options) {
|
|
|
1990
2280
|
cwd: options.cwd,
|
|
1991
2281
|
base: options.base,
|
|
1992
2282
|
url: options.url,
|
|
1993
|
-
write: options.write
|
|
2283
|
+
write: options.write,
|
|
2284
|
+
budget
|
|
1994
2285
|
});
|
|
1995
2286
|
if (planCode === EXIT_USAGE) return EXIT_USAGE;
|
|
1996
2287
|
console.log(
|
|
@@ -2016,8 +2307,26 @@ function parseMinScore(value) {
|
|
|
2016
2307
|
}
|
|
2017
2308
|
return score;
|
|
2018
2309
|
}
|
|
2310
|
+
function parsePositiveInt(flag) {
|
|
2311
|
+
return (value) => {
|
|
2312
|
+
const n = Number(value);
|
|
2313
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
2314
|
+
throw new InvalidArgumentError(`${flag} must be a positive integer`);
|
|
2315
|
+
}
|
|
2316
|
+
return n;
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
function parsePositiveNumber(flag) {
|
|
2320
|
+
return (value) => {
|
|
2321
|
+
const n = Number(value);
|
|
2322
|
+
if (!Number.isFinite(n) || n <= 0) {
|
|
2323
|
+
throw new InvalidArgumentError(`${flag} must be a positive number`);
|
|
2324
|
+
}
|
|
2325
|
+
return n;
|
|
2326
|
+
};
|
|
2327
|
+
}
|
|
2019
2328
|
var program = new Command();
|
|
2020
|
-
program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.
|
|
2329
|
+
program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.3.0");
|
|
2021
2330
|
program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
|
|
2022
2331
|
try {
|
|
2023
2332
|
const result = await initProject(process.cwd());
|
|
@@ -2032,7 +2341,19 @@ program.command("run").description("Discover and run all tests under .blastproof
|
|
|
2032
2341
|
"--min-score <n>",
|
|
2033
2342
|
"require a weighted score of at least n (0-100); replaces the all-must-pass rule",
|
|
2034
2343
|
parseMinScore
|
|
2035
|
-
).option("--junit [path]", "write a JUnit XML report (default: .blastproof/reports/<session>/junit.xml)").option("--html [path]", "write a self-contained HTML report (default: .blastproof/reports/<session>/report.html)").option("--fail-on-unmapped", "fail when a changed file matches no routes: or ignore: glob").
|
|
2344
|
+
).option("--junit [path]", "write a JUnit XML report (default: .blastproof/reports/<session>/junit.xml)").option("--html [path]", "write a self-contained HTML report (default: .blastproof/reports/<session>/report.html)").option("--fail-on-unmapped", "fail when a changed file matches no routes: or ignore: glob").option(
|
|
2345
|
+
"--max-llm-calls <n>",
|
|
2346
|
+
"stop the run after this many model calls, reported as incomplete (overrides config)",
|
|
2347
|
+
parsePositiveInt("--max-llm-calls")
|
|
2348
|
+
).option(
|
|
2349
|
+
"--max-tokens <n>",
|
|
2350
|
+
"stop the run after this many tokens spent across all model calls (overrides config)",
|
|
2351
|
+
parsePositiveInt("--max-tokens")
|
|
2352
|
+
).option(
|
|
2353
|
+
"--max-duration <seconds>",
|
|
2354
|
+
"stop the run after this many seconds of wall-clock time (overrides config)",
|
|
2355
|
+
parsePositiveNumber("--max-duration")
|
|
2356
|
+
).action(
|
|
2036
2357
|
async (options) => {
|
|
2037
2358
|
try {
|
|
2038
2359
|
process.exitCode = await runCommand({
|
|
@@ -2047,7 +2368,10 @@ program.command("run").description("Discover and run all tests under .blastproof
|
|
|
2047
2368
|
minScore: options.minScore,
|
|
2048
2369
|
junit: options.junit,
|
|
2049
2370
|
html: options.html,
|
|
2050
|
-
failOnUnmapped: options.failOnUnmapped
|
|
2371
|
+
failOnUnmapped: options.failOnUnmapped,
|
|
2372
|
+
maxLlmCalls: options.maxLlmCalls,
|
|
2373
|
+
maxTokens: options.maxTokens,
|
|
2374
|
+
maxDuration: options.maxDuration
|
|
2051
2375
|
});
|
|
2052
2376
|
} catch (error) {
|
|
2053
2377
|
console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -2055,25 +2379,54 @@ program.command("run").description("Discover and run all tests under .blastproof
|
|
|
2055
2379
|
}
|
|
2056
2380
|
}
|
|
2057
2381
|
);
|
|
2058
|
-
program.command("plan").description("Generate plain-English YAML tests for affected routes no test covers yet").option("--base <ref>", "base git ref for the diff", "main").option("--url <url>", "override config base_url for this run only (config file untouched)").option("--route <route>", "generate for this route, bypassing the diff (repeatable)", collect, []).option("--write", "persist drafts under .blastproof/tests/ instead of previewing them").
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2382
|
+
program.command("plan").description("Generate plain-English YAML tests for affected routes no test covers yet").option("--base <ref>", "base git ref for the diff", "main").option("--url <url>", "override config base_url for this run only (config file untouched)").option("--route <route>", "generate for this route, bypassing the diff (repeatable)", collect, []).option("--write", "persist drafts under .blastproof/tests/ instead of previewing them").option(
|
|
2383
|
+
"--max-llm-calls <n>",
|
|
2384
|
+
"stop after this many model calls, reported as incomplete (overrides config)",
|
|
2385
|
+
parsePositiveInt("--max-llm-calls")
|
|
2386
|
+
).option(
|
|
2387
|
+
"--max-tokens <n>",
|
|
2388
|
+
"stop after this many tokens spent across all model calls (overrides config)",
|
|
2389
|
+
parsePositiveInt("--max-tokens")
|
|
2390
|
+
).option(
|
|
2391
|
+
"--max-duration <seconds>",
|
|
2392
|
+
"stop after this many seconds of wall-clock time (overrides config)",
|
|
2393
|
+
parsePositiveNumber("--max-duration")
|
|
2394
|
+
).action(
|
|
2395
|
+
async (options) => {
|
|
2396
|
+
try {
|
|
2397
|
+
process.exitCode = await planCommand({
|
|
2398
|
+
cwd: process.cwd(),
|
|
2399
|
+
base: options.base,
|
|
2400
|
+
url: options.url,
|
|
2401
|
+
routes: options.route,
|
|
2402
|
+
write: options.write,
|
|
2403
|
+
maxLlmCalls: options.maxLlmCalls,
|
|
2404
|
+
maxTokens: options.maxTokens,
|
|
2405
|
+
maxDuration: options.maxDuration
|
|
2406
|
+
});
|
|
2407
|
+
} catch (error) {
|
|
2408
|
+
console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
|
|
2409
|
+
process.exitCode = EXIT_USAGE;
|
|
2410
|
+
}
|
|
2070
2411
|
}
|
|
2071
|
-
|
|
2412
|
+
);
|
|
2072
2413
|
program.command("test").description("Full PR pipeline: run the tests covering the diff, then draft tests for the gaps").option("--base <ref>", "base git ref for the diff", "main").option("--url <url>", "override config base_url for this run only (config file untouched)").option(
|
|
2073
2414
|
"--min-score <n>",
|
|
2074
2415
|
"require a weighted score of at least n (0-100); replaces the all-must-pass rule",
|
|
2075
2416
|
parseMinScore
|
|
2076
|
-
).option("--junit [path]", "write a JUnit XML report (default: .blastproof/reports/<session>/junit.xml)").option("--html [path]", "write a self-contained HTML report (default: .blastproof/reports/<session>/report.html)").option("--write", "persist generated drafts under .blastproof/tests/ instead of previewing them").option("--fail-on-unmapped", "fail when a changed file matches no routes: or ignore: glob").
|
|
2417
|
+
).option("--junit [path]", "write a JUnit XML report (default: .blastproof/reports/<session>/junit.xml)").option("--html [path]", "write a self-contained HTML report (default: .blastproof/reports/<session>/report.html)").option("--write", "persist generated drafts under .blastproof/tests/ instead of previewing them").option("--fail-on-unmapped", "fail when a changed file matches no routes: or ignore: glob").option(
|
|
2418
|
+
"--max-llm-calls <n>",
|
|
2419
|
+
"stop the pipeline after this many model calls, shared by both phases (overrides config)",
|
|
2420
|
+
parsePositiveInt("--max-llm-calls")
|
|
2421
|
+
).option(
|
|
2422
|
+
"--max-tokens <n>",
|
|
2423
|
+
"stop the pipeline after this many tokens, shared by both phases (overrides config)",
|
|
2424
|
+
parsePositiveInt("--max-tokens")
|
|
2425
|
+
).option(
|
|
2426
|
+
"--max-duration <seconds>",
|
|
2427
|
+
"stop the pipeline after this many seconds of wall-clock time (overrides config)",
|
|
2428
|
+
parsePositiveNumber("--max-duration")
|
|
2429
|
+
).action(
|
|
2077
2430
|
async (options) => {
|
|
2078
2431
|
try {
|
|
2079
2432
|
process.exitCode = await testCommand({
|
|
@@ -2084,7 +2437,10 @@ program.command("test").description("Full PR pipeline: run the tests covering th
|
|
|
2084
2437
|
junit: options.junit,
|
|
2085
2438
|
html: options.html,
|
|
2086
2439
|
write: options.write,
|
|
2087
|
-
failOnUnmapped: options.failOnUnmapped
|
|
2440
|
+
failOnUnmapped: options.failOnUnmapped,
|
|
2441
|
+
maxLlmCalls: options.maxLlmCalls,
|
|
2442
|
+
maxTokens: options.maxTokens,
|
|
2443
|
+
maxDuration: options.maxDuration
|
|
2088
2444
|
});
|
|
2089
2445
|
} catch (error) {
|
|
2090
2446
|
console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
|