blastproof 0.17.0 → 0.19.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
@@ -133,7 +133,7 @@ steps:
133
133
  - complete checkout
134
134
  ```
135
135
 
136
- `priority` is P0–P2 (default P1). `tags`, `setup` steps and `auth` are optional — `auth: false` runs the test signed out, which a login test needs. `routes` declares the URLs a test covers, which is what `--impacted` selects on; write route strings consistently, since they compare by exact equality (`/cart` ≠ `/cart/`). `run` warns to stderr — non-fatal — when a test declares a route no `routes:` mapping declares, since that route contributes nothing to `--impacted` selection.
136
+ `priority` is P0–P2 (default P1). `tags`, `setup` steps and `auth` are optional — `auth: false` runs the test signed out, which a login test needs, and a selection in which *every* test declares it skips the login altogether. `routes` declares the URLs a test covers, which is what `--impacted` selects on; write route strings consistently, since they compare by exact equality (`/cart` ≠ `/cart/`). `run` warns to stderr — non-fatal — when a test declares a route no `routes:` mapping declares, since that route contributes nothing to `--impacted` selection.
137
137
 
138
138
  ### Say what each step should produce
139
139
 
@@ -227,6 +227,10 @@ The key is the file glob and the value is the routes it can affect — the oppos
227
227
 
228
228
  Every changed file lands in one of three buckets: it matches `routes:` and contributes them, matches `ignore:` and is knowingly irrelevant, or **matches neither — nobody has said what it affects**. `--fail-on-unmapped` blocks on that third case, naming the files and both ways to resolve them.
229
229
 
230
+ **The diff is `git diff <base>...HEAD`, so it compares commits.** Work you have not committed — edited, staged or untracked — is not in it. That is the right comparison in CI, where everything is committed by the time the workflow runs, and the wrong one on the machine you are writing the change on: a run against a dirty working tree selects from your last commit, not from your editor.
231
+
232
+ It says so rather than leaving you to notice. Any working-tree change the diff excluded is named on stderr, with the routes it maps to, on `run --impacted` and `plan --base` alike — non-fatal, and it changes no exit code and nothing about what is selected. Files your `ignore:` globs already cover stay silent, and so do files already in the diff, whose routes are selected either way.
233
+
230
234
  **Nothing is ignored by default**, on purpose: a default that guesses on your behalf would hide the first files worth thinking about. The flag is additive — a run can meet `--min-score` and still be blocked here, because "the tests I ran passed" and "something changed that nobody classified" are different claims.
231
235
 
232
236
  Its limit is worth knowing: it catches files that are *unclassified*, not *misclassified*. A shared module mapped to one route when it can break five still slips through. Impact by import graph is the fix, and blastproof does not do it yet.
package/dist/cli.js CHANGED
@@ -598,6 +598,23 @@ var StepRecovery = class {
598
598
  observe(maskedSnapshot) {
599
599
  this.readable.push(normalise(maskedSnapshot));
600
600
  }
601
+ /**
602
+ * Whether anything succeeded during this step (design close-a-step D1).
603
+ *
604
+ * Reads the history `record` already keeps rather than a flag threaded
605
+ * through the loop: `record` is reached at one place, after `performAction`
606
+ * returns without throwing, so this is "what worked" and not "what was
607
+ * attempted" — a click that raised is correctly absent. A separate counter
608
+ * would be one more thing a future branch can forget to increment, which is
609
+ * the shape that let `timeoutMs` and the budget go unset at a single call
610
+ * site while every other one set them.
611
+ *
612
+ * `observe` deliberately does not move it. Being shown a page is not doing
613
+ * something to it.
614
+ */
615
+ get acted() {
616
+ return this.history.length > 0;
617
+ }
601
618
  /** Records an action that was actually performed and succeeded. */
602
619
  record(action, description, result) {
603
620
  this.performed.add(identity(action));
@@ -691,6 +708,7 @@ function slugify(text) {
691
708
  const slug = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
692
709
  return slug || "test";
693
710
  }
711
+ var NOTHING_DONE_REFUSAL = "refused: nothing succeeded during this step, so it was NOT closed. A step closes on what was done in it, not on being declared finished. If the outcome already holds \u2014 there was nothing left to do \u2014 assert that it holds and let the judgment close the step; an assertion is evidence, a declaration is not. If the step cannot be carried out at all, fail it and say why.";
694
712
  var StepFailure = class extends Error {
695
713
  };
696
714
  async function executeTest(page, test, options) {
@@ -775,6 +793,15 @@ async function executeTest(page, test, options) {
775
793
  }
776
794
  iterations++;
777
795
  if (action.action === "done") {
796
+ if (!recovery.acted) {
797
+ failedAttempts++;
798
+ lastResult = NOTHING_DONE_REFUSAL;
799
+ emitAction(index, action, lastResult);
800
+ if (failedAttempts >= maxRetries) {
801
+ throw new StepFailure(lastResult);
802
+ }
803
+ continue;
804
+ }
778
805
  emitAction(index, action, action.reasoning);
779
806
  break;
780
807
  }
@@ -1289,6 +1316,20 @@ async function getChangedFiles(baseRef, cwd = process.cwd()) {
1289
1316
  throw toDiffError(error, baseRef);
1290
1317
  }
1291
1318
  }
1319
+ async function getUncommittedFiles(cwd = process.cwd()) {
1320
+ try {
1321
+ const status = await simpleGit(cwd).status();
1322
+ const paths = /* @__PURE__ */ new Set();
1323
+ for (const file of status.files) {
1324
+ paths.add(file.path.replace(/\\/g, "/"));
1325
+ const from = file.from;
1326
+ if (from) paths.add(from.replace(/\\/g, "/"));
1327
+ }
1328
+ return [...paths].sort();
1329
+ } catch {
1330
+ return [];
1331
+ }
1332
+ }
1292
1333
 
1293
1334
  // src/impact.ts
1294
1335
  import picomatch from "picomatch";
@@ -1327,6 +1368,25 @@ function mapImpact(changedFiles, routes, ignore = []) {
1327
1368
  };
1328
1369
  }
1329
1370
 
1371
+ // src/report/uncommitted.ts
1372
+ function printUncommitted(uncommitted, changedFiles, config, baseRef) {
1373
+ const inDiff = new Set(changedFiles.map((file) => file.replace(/\\/g, "/")));
1374
+ const routes = config.routes ?? {};
1375
+ const ignore = config.ignore ?? [];
1376
+ const findings = uncommitted.filter((file) => !inDiff.has(file)).map((file) => ({ file, impact: mapImpact([file], routes, ignore) })).filter(({ impact }) => impact.ignoredFiles.length === 0);
1377
+ if (findings.length === 0) return;
1378
+ console.error(
1379
+ `
1380
+ warning: ${findings.length} file(s) changed in the working tree are not in the diff against '${baseRef}':`
1381
+ );
1382
+ for (const { file, impact } of findings) {
1383
+ console.error(
1384
+ impact.affectedRoutes.length > 0 ? ` ${file} -> ${impact.affectedRoutes.join(", ")}` : ` ${file} (matched by no routes: or ignore: glob)`
1385
+ );
1386
+ }
1387
+ console.error("Nothing above was considered. Commit or stash them to include them.");
1388
+ }
1389
+
1330
1390
  // src/llm/brain.ts
1331
1391
  import { generateObject } from "ai";
1332
1392
 
@@ -2547,7 +2607,8 @@ Dry run: ${selected.length} test(s) selected, base_url=${config.base_url}`);
2547
2607
  for (const test of selected) {
2548
2608
  console.log(` ${test.summary} [${test.priority}] (${path9.relative(cwd, test.path)})`);
2549
2609
  }
2550
- const authSteps = config.auth?.steps ?? [];
2610
+ const needsLogin = selected.some((test) => test.auth);
2611
+ const authSteps = needsLogin ? config.auth?.steps ?? [] : [];
2551
2612
  const withAuth = authSteps.length > 0 ? [...selected, { steps: authSteps }] : selected;
2552
2613
  const ceiling = estimateMaxModelCalls(
2553
2614
  withAuth,
@@ -2592,6 +2653,7 @@ async function runCommand(options) {
2592
2653
  throw error;
2593
2654
  }
2594
2655
  impact = mapImpact(changedFiles, config.routes ?? {}, config.ignore ?? []);
2656
+ printUncommitted(await getUncommittedFiles(options.cwd), changedFiles, config, base);
2595
2657
  }
2596
2658
  const testsDir = path9.join(options.cwd, TESTS_RELATIVE_DIR);
2597
2659
  let files;
@@ -2684,7 +2746,7 @@ ${results.length} test file(s) could not be parsed:`);
2684
2746
  let incomplete;
2685
2747
  try {
2686
2748
  let session;
2687
- if (config.auth) {
2749
+ if (config.auth && selected.some((test) => test.auth)) {
2688
2750
  try {
2689
2751
  console.log("Authenticating...");
2690
2752
  session = await authenticate({
@@ -2805,6 +2867,7 @@ async function resolveTargets(options, config, suite) {
2805
2867
  const base = options.base ?? "main";
2806
2868
  const changedFiles = await getChangedFiles(base, options.cwd);
2807
2869
  const impact = mapImpact(changedFiles, config.routes ?? {}, config.ignore ?? []);
2870
+ printUncommitted(await getUncommittedFiles(options.cwd), changedFiles, config, base);
2808
2871
  const filesForRoute = (route) => changedFiles.filter(
2809
2872
  (file) => mapImpact([file], config.routes ?? {}, config.ignore ?? []).affectedRoutes.includes(route)
2810
2873
  );
@@ -3082,7 +3145,7 @@ function parsePositiveNumber(flag) {
3082
3145
  };
3083
3146
  }
3084
3147
  var program = new Command();
3085
- program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.17.0");
3148
+ program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.19.0");
3086
3149
  program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
3087
3150
  try {
3088
3151
  const result = await initProject(process.cwd());