blastproof 0.21.0 → 0.23.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 CHANGED
@@ -210,7 +210,7 @@ Common flags — `blastproof <command> --help` has the full list:
210
210
  | `--write` | `plan` only — persist drafts instead of previewing |
211
211
  | `--max-llm-calls` · `--max-tokens` · `--max-duration` | [Bound what a run may spend](./docs/configuration.md#budget--bounding-what-a-run-spends) |
212
212
 
213
- Exit codes: **0** pass, **1** the gate failed, **2** usage or config error.
213
+ Exit codes: **0** pass, **1** the gate failed or the run stopped (its budget, or the model provider refusing a call: `Run incomplete:`), **2** usage or config error.
214
214
 
215
215
  **Generated drafts are never executed and never affect the score.** An unreviewed model-written test in the merge path fails in two directions: a hallucinated expectation blocks a correct PR, and a credulous one waves a broken change through while looking like coverage. `plan` makes the gap visible with a draft to review; it does not make an uncovered route safe.
216
216
 
package/dist/cli.js CHANGED
@@ -219,7 +219,9 @@ function describeLimit(limit, observed, configured) {
219
219
  return `deadline exceeded: reached the configured maximum of ${(configured / 1e3).toFixed(0)}s (elapsed ${(observed / 1e3).toFixed(1)}s)`;
220
220
  }
221
221
  }
222
- var BudgetExhaustedError = class extends Error {
222
+ var RunStoppedError = class extends Error {
223
+ };
224
+ var BudgetExhaustedError = class extends RunStoppedError {
223
225
  limit;
224
226
  observed;
225
227
  configured;
@@ -231,6 +233,30 @@ var BudgetExhaustedError = class extends Error {
231
233
  this.configured = configured;
232
234
  }
233
235
  };
236
+ function refusalRemedy(statusCode) {
237
+ if (statusCode === void 0) {
238
+ return "The provider could not be reached after 3 attempts: check llm.base_url and the network, then run again.";
239
+ }
240
+ if (statusCode === 401 || statusCode === 403) {
241
+ return "Check the API key in the variable named by llm.api_key_env, then run again.";
242
+ }
243
+ if (statusCode === 402) return "Add credit to the provider account, then run again.";
244
+ if (statusCode === 408 || statusCode === 409 || statusCode === 429 || statusCode >= 500) {
245
+ return "The provider was unavailable after 3 attempts: run again later.";
246
+ }
247
+ return void 0;
248
+ }
249
+ var ProviderRefusedError = class extends RunStoppedError {
250
+ statusCode;
251
+ constructor(statusCode, detail) {
252
+ const status = statusCode === void 0 ? "no response" : `HTTP ${statusCode}`;
253
+ const remedy = refusalRemedy(statusCode);
254
+ const separator = /[.!?]$/.test(detail.trim()) ? " " : ". ";
255
+ super(`model provider refused the request (${status}): ${detail}${remedy ? `${separator}${remedy}` : ""}`);
256
+ this.name = "ProviderRefusedError";
257
+ this.statusCode = statusCode;
258
+ }
259
+ };
234
260
  var RunBudget = class {
235
261
  maxCalls;
236
262
  maxTokens;
@@ -333,6 +359,9 @@ function referencedEnvVars(text) {
333
359
  }
334
360
  return [...names];
335
361
  }
362
+ function placeholdersAsLabels(text) {
363
+ return text.replace(ENV_PLACEHOLDER, (_match, name) => redactionLabel(name));
364
+ }
336
365
  function escapeRegExp(value) {
337
366
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
338
367
  }
@@ -343,6 +372,12 @@ var REDACTION_PREFIX = "[redacted";
343
372
  function redactionLabel(name) {
344
373
  return name === void 0 ? `${REDACTION_PREFIX}]` : `${REDACTION_PREFIX} ${name}]`;
345
374
  }
375
+ var LABEL = /\[redacted ([A-Za-z_][A-Za-z0-9_]*)\]/g;
376
+ function labelledVariables(text) {
377
+ const names = /* @__PURE__ */ new Set();
378
+ for (const match of text.matchAll(LABEL)) if (match[1]) names.add(match[1]);
379
+ return [...names];
380
+ }
346
381
  function compile(secrets, names) {
347
382
  return [...secrets].filter(Boolean).sort((a, b) => b.length - a.length).map((value) => ({ value, pattern: secretPattern(value), label: redactionLabel(names?.get(value)) }));
348
383
  }
@@ -591,11 +626,13 @@ function describeAction(action) {
591
626
  return `${action.action}${target}${value}`;
592
627
  }
593
628
  function identity(action) {
629
+ const role = action.target?.role ?? "";
630
+ const name = action.target?.name ?? "";
594
631
  return JSON.stringify([
595
632
  action.action,
596
- action.target?.role ?? "",
597
- action.target?.name ?? "",
598
- action.target?.text ?? "",
633
+ role,
634
+ normalise(name),
635
+ role || name ? "" : action.target?.text ?? "",
599
636
  action.value ?? ""
600
637
  ]);
601
638
  }
@@ -845,7 +882,7 @@ async function executeTest(page, test, options) {
845
882
  iterationsLeft: maxIterationsPerStep - iterations
846
883
  });
847
884
  } catch (error) {
848
- if (error instanceof BudgetExhaustedError) throw error;
885
+ if (error instanceof RunStoppedError) throw error;
849
886
  failedAttempts++;
850
887
  lastResult = `error: ${error instanceof Error ? error.message : String(error)}`;
851
888
  if (failedAttempts >= maxRetries) {
@@ -873,23 +910,35 @@ async function executeTest(page, test, options) {
873
910
  }
874
911
  if (action.action === "assert") {
875
912
  const expectation = action.expectation ?? action.reasoning;
876
- let judgment = await brain.judge(
877
- mask(step),
878
- mask(expectation),
879
- maskedSnap,
880
- recovery.stepHistory()
881
- );
882
- if (!judgment.pass) {
883
- await waitForSettled(page);
884
- const freshSnap = await takeSnapshot(page);
885
- const maskedFresh = mask(freshSnap);
886
- recovery.observe(maskedFresh);
913
+ let judgment;
914
+ try {
887
915
  judgment = await brain.judge(
888
916
  mask(step),
889
917
  mask(expectation),
890
- maskedFresh,
918
+ maskedSnap,
891
919
  recovery.stepHistory()
892
920
  );
921
+ if (!judgment.pass) {
922
+ await waitForSettled(page);
923
+ const freshSnap = await takeSnapshot(page);
924
+ const maskedFresh = mask(freshSnap);
925
+ recovery.observe(maskedFresh);
926
+ judgment = await brain.judge(
927
+ mask(step),
928
+ mask(expectation),
929
+ maskedFresh,
930
+ recovery.stepHistory()
931
+ );
932
+ }
933
+ } catch (error) {
934
+ if (error instanceof RunStoppedError) throw error;
935
+ failedAttempts++;
936
+ lastResult = `error: ${error instanceof Error ? error.message : String(error)}`;
937
+ emitAction(index, action, lastResult);
938
+ if (failedAttempts >= maxRetries) {
939
+ throw new StepFailure(lastResult);
940
+ }
941
+ continue;
893
942
  }
894
943
  const result = judgment.pass ? `ok: assertion passed: ${judgment.reason}` : `assertion failed: ${judgment.reason}`;
895
944
  emitAction(index, action, result);
@@ -934,7 +983,7 @@ async function executeTest(page, test, options) {
934
983
  }
935
984
  }
936
985
  } catch (error) {
937
- if (error instanceof BudgetExhaustedError) throw error;
986
+ if (error instanceof RunStoppedError) throw error;
938
987
  stepFailedReason = mask(error instanceof Error ? error.message : String(error));
939
988
  }
940
989
  const status = stepFailedReason ? "failed" : "passed";
@@ -1035,12 +1084,24 @@ async function runJourney(...args) {
1035
1084
  try {
1036
1085
  return await executeTest(...args);
1037
1086
  } catch (error) {
1038
- if (error instanceof BudgetExhaustedError) throw error;
1087
+ if (error instanceof RunStoppedError) throw error;
1039
1088
  throw new AuthError(
1040
1089
  `Authentication could not run: ${error instanceof Error ? error.message : String(error)}`
1041
1090
  );
1042
1091
  }
1043
1092
  }
1093
+ async function verifyJudgment(brain, verify, snapshot, maskText, maxRetries = 3) {
1094
+ let lastError = "";
1095
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
1096
+ try {
1097
+ return await brain.judge(verify, verify, await snapshot());
1098
+ } catch (error) {
1099
+ if (error instanceof RunStoppedError) throw error;
1100
+ lastError = error instanceof Error ? error.message : String(error);
1101
+ }
1102
+ }
1103
+ throw new AuthError(`Authentication could not be verified: ${maskText(lastError)}`);
1104
+ }
1044
1105
  async function fromSteps(options) {
1045
1106
  const { auth, baseUrl, browser, brain, maxRetries, snapshot, timeoutMs, maxSnapshotLines, onEvent, mask } = options;
1046
1107
  const steps = auth.steps;
@@ -1078,7 +1139,13 @@ async function fromSteps(options) {
1078
1139
  );
1079
1140
  }
1080
1141
  if (auth.verify) {
1081
- const judgment = await brain.judge(auth.verify, auth.verify, mask.mask(await takeSnapshot(page)));
1142
+ const judgment = await verifyJudgment(
1143
+ brain,
1144
+ auth.verify,
1145
+ async () => mask.mask(await takeSnapshot(page)),
1146
+ (text) => mask.mask(text),
1147
+ maxRetries
1148
+ );
1082
1149
  if (!judgment.pass) {
1083
1150
  throw new AuthError(`Authentication could not be verified: ${mask.mask(judgment.reason)}`);
1084
1151
  }
@@ -1450,7 +1517,7 @@ warning: ${findings.length} file(s) changed in the working tree are not in the d
1450
1517
  }
1451
1518
 
1452
1519
  // src/llm/brain.ts
1453
- import { generateObject } from "ai";
1520
+ import { APICallError, generateObject, RetryError } from "ai";
1454
1521
 
1455
1522
  // src/llm/prompts.ts
1456
1523
  function agentSystemPrompt() {
@@ -1502,6 +1569,8 @@ A value sitting in a control that was just typed into \u2014 an open dialog's te
1502
1569
 
1503
1570
  A step that names an ACTION (submit, click, create, add, ...) is satisfied by evidence the action took effect, not by the action's own control still being on the page. A successful action ordinarily replaces or moves past exactly the form, button or field the step names, so that control's absence is normal evidence of success, not evidence the step is unverifiable \u2014 do not fail such a step only because you can no longer see the thing it names. Fail it instead when the snapshot shows the action did NOT take effect: an error message, a validation warning, or the very same pre-action page still in front of you with nothing changed. A different page, a new state, or the result the action was meant to produce counts as evidence it worked.
1504
1571
 
1572
+ An outcome may already hold before this step acts: an earlier step, or the application itself, got there first. A step asking for something to be gone, closed or dismissed is satisfied by that thing being absent from the snapshot. It does not also require the thing to have been there, or this step to have removed it, and a different element that is present (another dialog, another banner) is not the one the step names. Whether the step's action ran is not what you decide: judge the state the step asks for.
1573
+
1505
1574
  You may also be shown the actions already performed in this step, with their results. That record tells you what was ATTEMPTED and what it produced \u2014 for instance that a navigation was performed and which URL the server ultimately served, or that a form was submitted. Use it to avoid concluding that something never happened when the page simply cannot show it any more: a navigation the server redirected does not leave the browser at the path that was requested, and that is what success looks like, not failure.
1506
1575
 
1507
1576
  The record is not evidence that the step's outcome holds. An action reported as \`ok\` establishes that it ran and what it returned; whether the thing the step describes is now TRUE is still decided by the snapshot alone. Never pass a step because the record shows an action succeeded while the snapshot does not show the outcome.
@@ -1607,8 +1676,13 @@ function parseAgentAction(value) {
1607
1676
  return parsedAgentActionSchema.safeParse(value);
1608
1677
  }
1609
1678
  var assertJudgmentSchema = z2.object({
1610
- pass: z2.boolean().describe("Whether the snapshot satisfies the expectation."),
1611
- reason: z2.string().describe("One sentence explaining the judgment.")
1679
+ outcome: z2.string().describe(
1680
+ 'The state the STEP asks for, rewritten as a sentence about the page with its action removed: "click Save and verify the note is listed" becomes "the note is listed"; "open the Account menu and verify it shows the email" becomes "the Account menu shows the email".'
1681
+ ),
1682
+ reason: z2.string().describe("One sentence: whether the snapshot shows that outcome."),
1683
+ pass: z2.boolean().describe(
1684
+ "Whether the snapshot shows that outcome. The expectation is only a claim offered in support; it never replaces the step. False if any part of the outcome the step asks for is not shown, or cannot be assessed from this snapshot. An action the step names (click, dismiss, submit) is how its outcome is reached, not part of it: an outcome that holds passes whether or not that action was needed. An outcome that is an absence (gone, closed, dismissed, removed) is shown by the thing being absent."
1685
+ )
1612
1686
  });
1613
1687
  var generatedTestSchema = z2.object({
1614
1688
  summary: z2.string().min(1).describe('One line naming the user journey this test covers, e.g. "Applying a discount updates the cart total".'),
@@ -1636,13 +1710,20 @@ function withProviderDetail(error) {
1636
1710
  error.message = `${error.message} \u2014 provider said: ${detail}`;
1637
1711
  return error;
1638
1712
  }
1713
+ function asProviderRefusal(error) {
1714
+ const cause = RetryError.isInstance(error) ? error.lastError : error;
1715
+ if (!APICallError.isInstance(cause)) return error;
1716
+ if (cause.statusCode !== void 0 && cause.statusCode < 400) return error;
1717
+ withProviderDetail(cause);
1718
+ return new ProviderRefusedError(cause.statusCode, cause.message);
1719
+ }
1639
1720
  async function countedGenerate(generate, budget, options) {
1640
1721
  budget.check();
1641
1722
  let result;
1642
1723
  try {
1643
1724
  result = await generate(options);
1644
1725
  } catch (error) {
1645
- throw withProviderDetail(error);
1726
+ throw asProviderRefusal(withProviderDetail(error));
1646
1727
  }
1647
1728
  budget.record(result.usage);
1648
1729
  return result;
@@ -1665,11 +1746,20 @@ function createBrain(model, generate = generateObject, budget) {
1665
1746
  return parsed.data;
1666
1747
  },
1667
1748
  async judge(step, expectation, snapshot, stepHistory) {
1749
+ const asJudged = stepHistory?.map((entry) => ({
1750
+ action: placeholdersAsLabels(entry.action),
1751
+ result: placeholdersAsLabels(entry.result)
1752
+ }));
1668
1753
  const result = await countedGenerate(generate, budget, {
1669
1754
  model,
1670
1755
  schema: assertJudgmentSchema,
1671
1756
  system: assertSystemPrompt(),
1672
- prompt: assertUserPrompt(step, expectation, snapshot, stepHistory),
1757
+ prompt: assertUserPrompt(
1758
+ placeholdersAsLabels(step),
1759
+ placeholdersAsLabels(expectation),
1760
+ snapshot,
1761
+ asJudged
1762
+ ),
1673
1763
  temperature: 0
1674
1764
  });
1675
1765
  const parsed = assertJudgmentSchema.safeParse(result.object);
@@ -1678,10 +1768,27 @@ function createBrain(model, generate = generateObject, budget) {
1678
1768
  `Model returned an invalid assert judgment: ${parsed.error.issues[0]?.message ?? "unknown"}`
1679
1769
  );
1680
1770
  }
1681
- return parsed.data;
1771
+ return secretMismatch(placeholdersAsLabels(step), snapshot, asJudged, parsed.data);
1682
1772
  }
1683
1773
  };
1684
1774
  }
1775
+ function secretMismatch(judgedStep, snapshot, judgedRecord, judgment) {
1776
+ if (!judgment.pass) return judgment;
1777
+ const onPage = labelledVariables(snapshot);
1778
+ const inRecord = labelledVariables((judgedRecord ?? []).map((e) => `${e.action}
1779
+ ${e.result}`).join("\n"));
1780
+ for (const name of labelledVariables(judgedStep)) {
1781
+ const others = onPage.filter((other) => other !== name);
1782
+ if (onPage.includes(name) || inRecord.includes(name) || others.length === 0) continue;
1783
+ const shown = others.map((other) => redactionLabel(other)).join(", ");
1784
+ return {
1785
+ ...judgment,
1786
+ pass: false,
1787
+ reason: `The step names ${redactionLabel(name)}, which appears neither on the page nor in this step's actions, while the page shows ${shown}: a different secret cannot satisfy it. (The judge had said: ${judgment.reason})`
1788
+ };
1789
+ }
1790
+ return judgment;
1791
+ }
1685
1792
  function createPlanner(model, generate = generateObject, budget) {
1686
1793
  return {
1687
1794
  async planTest(input) {
@@ -2541,7 +2648,7 @@ X ${r.summary} (${r.file})`);
2541
2648
  }
2542
2649
  if (notRun.length > 0) {
2543
2650
  console.log(`
2544
- ${notRun.length} test(s) not run (run stopped by its budget or deadline):`);
2651
+ ${notRun.length} test(s) not run (run stopped: ${notRun[0].reason}):`);
2545
2652
  for (const r of notRun) console.log(` - ${r.summary} (${r.file})`);
2546
2653
  }
2547
2654
  }
@@ -2866,7 +2973,7 @@ ${results.length} test file(s) could not be parsed:`);
2866
2973
  onEvent: printEvent
2867
2974
  });
2868
2975
  } catch (error) {
2869
- if (error instanceof BudgetExhaustedError) {
2976
+ if (error instanceof RunStoppedError) {
2870
2977
  incomplete = error;
2871
2978
  } else if (error instanceof AuthError) {
2872
2979
  console.error(`error: ${error.message}`);
@@ -2896,7 +3003,7 @@ ${results.length} test file(s) could not be parsed:`);
2896
3003
  if (!streaming) for (const line of lines) console.log(line);
2897
3004
  return { index, result };
2898
3005
  } catch (error) {
2899
- if (error instanceof BudgetExhaustedError) {
3006
+ if (error instanceof RunStoppedError) {
2900
3007
  stoppedBy ??= error;
2901
3008
  if (!streaming) for (const line of lines) console.log(line);
2902
3009
  return { index, stopped: error };
@@ -3080,7 +3187,7 @@ async function planCommand(options) {
3080
3187
  onEvent: printAuthEvent
3081
3188
  });
3082
3189
  } catch (error) {
3083
- if (error instanceof BudgetExhaustedError) {
3190
+ if (error instanceof RunStoppedError) {
3084
3191
  incomplete = error;
3085
3192
  } else if (error instanceof AuthError) {
3086
3193
  console.error(`error: ${error.message}`);
@@ -3108,7 +3215,7 @@ async function planCommand(options) {
3108
3215
  timeoutMs: config.browser.timeout_ms
3109
3216
  });
3110
3217
  } catch (error) {
3111
- if (error instanceof BudgetExhaustedError) {
3218
+ if (error instanceof RunStoppedError) {
3112
3219
  incomplete = error;
3113
3220
  break;
3114
3221
  }
@@ -3162,7 +3269,7 @@ ${renderTestYaml(draft, { route, base })}`);
3162
3269
  if (incomplete) {
3163
3270
  console.log(`Stopped: ${incomplete.message}`);
3164
3271
  if (notAttempted.length > 0) {
3165
- console.log("Not attempted (run out of budget):");
3272
+ console.log("Not attempted (run stopped):");
3166
3273
  for (const route of notAttempted) console.log(` ${route}`);
3167
3274
  }
3168
3275
  }
@@ -3256,7 +3363,7 @@ function parsePositiveNumber(flag) {
3256
3363
  };
3257
3364
  }
3258
3365
  var program = new Command();
3259
- program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.21.0");
3366
+ program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.23.0");
3260
3367
  program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
3261
3368
  try {
3262
3369
  const result = await initProject(process.cwd());