blastproof 0.18.0 → 0.20.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
@@ -86,6 +86,8 @@ The name is matched **exactly first**, then by substring if nothing matches exac
86
86
  | `<button aria-label="Delete note">` | an icon-only button with no name |
87
87
  | `<select>` with `<option>`s | an ARIA-less custom dropdown |
88
88
 
89
+ **An editable data grid is the shape most likely to defeat this, and every row above understates it.** Two adversarial runs against a personal-finance application failed on the journey such an application exists for — entering a transaction. The row's cells carry no accessible name, but the Payee *column header* does, so the tree does not merely lack the target: it offers a control with exactly the right name that is the wrong control. The agent clicks the header, the row editor never opens, and the next `type` lands in whatever input does exist — there, the page's global search box. The step fails describing a textbox nobody mentioned, roughly 70 model calls in. Nobody has yet tried driving such a grid by keyboard (`Tab` into the row, type, `Tab` on), so the honest claim is that **the obvious authoring fails, and fails confusingly** — not that it is impossible. If your core journey is a row editor, spend one test on it before adopting ([#67](https://github.com/hamc/blastproof/issues/67)).
90
+
89
91
  **Run an accessibility checker on your app before installing anything.** The result predicts how well this will work better than anything else you could measure — and the fixes it suggests are worth making regardless of whether you adopt this tool.
90
92
 
91
93
  ### 2. Does your journey need anything on this list?
@@ -133,7 +135,7 @@ steps:
133
135
  - complete checkout
134
136
  ```
135
137
 
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.
138
+ `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
139
 
138
140
  ### Say what each step should produce
139
141
 
@@ -227,6 +229,10 @@ The key is the file glob and the value is the routes it can affect — the oppos
227
229
 
228
230
  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
231
 
232
+ **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.
233
+
234
+ 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.
235
+
230
236
  **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
237
 
232
238
  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.
@@ -269,9 +275,11 @@ The application under test is not trusted input: its page content reaches the mo
269
275
 
270
276
  If your application legitimately spans hosts (an identity provider, a hosted payment step), declare them. A suite that was quietly walking onto a foreign page will now fail and name the origin to add.
271
277
 
272
- **Your secrets stay out of prompts.** `{{env.*}}` placeholders survive intact and are substituted at the moment of typing. Every value your tests or auth recipe reference is redacted from everything else crossing into a prompt — page snapshots included — in literal and percent-encoded form. Redaction matches known values, so treat it as a strong default rather than a guarantee against a hostile app.
278
+ **Your secrets stay out of prompts.** `{{env.*}}` placeholders survive intact and are substituted at the moment of typing. Every value your tests or auth recipe reference is redacted from everything else crossing into a prompt — page snapshots included — in literal and percent-encoded form. Redaction matches a known value case-insensitively and tolerantly of whitespace, so a page that uppercases what you typed is still covered. It does not chase a value your application re-encodes, hashes or truncates — no list of transforms can be complete, and one that pretends to be would make you careless. When a redaction is found only by that wider comparison, the run prints one warning naming the variable, because the same page could just as easily have returned a form nothing here can recognise. Treat all of it as a strong default rather than a guarantee against a hostile app.
279
+
280
+ **Redaction covers text, not pixels.** A failure screenshot shows whatever was on screen, including a value typed from `{{env.*}}` or echoed back by the page. So when anything in a run references `{{env.*}}`, the HTML report does not embed screenshots: each failure links to its PNG under `.blastproof/reports/` instead. That keeps `report.html` safe to attach to a pull request. The PNGs themselves are not masked, so do not upload `.blastproof/reports/` anywhere you would not put the secret ([#110](https://github.com/hamc/blastproof/issues/110)).
273
281
 
274
- **A redacted value cannot be asserted on.** The masking is thorough by design, and page snapshots are not exempt — so a step that verifies text which happens to equal an `{{env.*}}` value can never pass, because the judge is shown `***` where the page shows the thing. The failure is the most misleading shape available: the test is right, the application is right, and the report blames the application. Put a value in `{{env.*}}` because it is a secret or because it varies by environment, but do not then write a step that asserts on it ([#87](https://github.com/hamc/blastproof/issues/87)).
282
+ **A redacted value cannot be asserted on.** The masking is thorough by design, and page snapshots are not exempt — so a step that verifies text which happens to equal an `{{env.*}}` value can never pass, because the judge is shown `***` where the page shows the thing. The failure is the most misleading shape available: the test is right, the application is right, and the report blames the application. Put a value in `{{env.*}}` because it is a secret or because it varies by environment, but do not then write a step that asserts on it ([#87](https://github.com/hamc/blastproof/issues/87)). Matching is case-insensitive, so this covers a little more of the page than the value's exact spelling — one more reason to keep assertions off it.
275
283
 
276
284
  The system prompt also tells the model that page content is data, never instruction. That raises the cost of casual injection and is **not** a boundary — the origin constraint is. Do not point blastproof at an application you would not run locally.
277
285
 
package/dist/cli.js CHANGED
@@ -336,16 +336,30 @@ function referencedEnvVars(text) {
336
336
  function escapeRegExp(value) {
337
337
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
338
338
  }
339
- function maskSecrets(text, secrets) {
340
- const ordered = [...secrets].filter(Boolean).sort((a, b) => b.length - a.length);
339
+ function secretPattern(secret) {
340
+ return new RegExp(escapeRegExp(secret).replace(/\s+/g, "\\s+"), "gi");
341
+ }
342
+ function compile(secrets) {
343
+ return [...secrets].filter(Boolean).sort((a, b) => b.length - a.length).map((value) => ({ value, pattern: secretPattern(value) }));
344
+ }
345
+ function maskCompiled(text, compiled, onNearMiss) {
341
346
  let masked = text;
342
- for (const secret of ordered) {
343
- masked = masked.replace(new RegExp(escapeRegExp(secret), "g"), "***");
347
+ for (const { value, pattern } of compiled) {
348
+ pattern.lastIndex = 0;
349
+ masked = masked.replace(pattern, (found) => {
350
+ if (found !== value) onNearMiss?.(value, found);
351
+ return "***";
352
+ });
344
353
  }
345
354
  return masked;
346
355
  }
347
356
  var SecretsMask = class {
348
357
  secrets = /* @__PURE__ */ new Set();
358
+ /** Value -> the `{{env.*}}` variable it came from, so a warning can name it. */
359
+ names = /* @__PURE__ */ new Map();
360
+ nearMissed = /* @__PURE__ */ new Set();
361
+ /** Built on first use after a registration, not on every masked string. */
362
+ compiled;
349
363
  /** Registers the current values of the env vars referenced in `text`. Throws MissingEnvError on unset vars. */
350
364
  registerFrom(text, env = process.env) {
351
365
  for (const name of referencedEnvVars(text)) {
@@ -353,7 +367,7 @@ var SecretsMask = class {
353
367
  if (value === void 0) {
354
368
  throw new MissingEnvError(name);
355
369
  }
356
- this.add(value);
370
+ this.add(value, name);
357
371
  }
358
372
  }
359
373
  /**
@@ -362,14 +376,39 @@ var SecretsMask = class {
362
376
  * space stopped matching a literal search and passed through unmasked.
363
377
  * This cannot cover every transform a page might apply; see the README.
364
378
  */
365
- add(value) {
379
+ add(value, name) {
366
380
  if (!value) return;
367
381
  this.secrets.add(value);
382
+ this.compiled = void 0;
383
+ if (name !== void 0 && !this.names.has(value)) this.names.set(value, name);
368
384
  const encoded = encodeURIComponent(value);
369
- if (encoded !== value) this.secrets.add(encoded);
385
+ if (encoded !== value) {
386
+ this.secrets.add(encoded);
387
+ if (name !== void 0 && !this.names.has(encoded)) this.names.set(encoded, name);
388
+ }
370
389
  }
371
390
  mask(text) {
372
- return maskSecrets(text, this.secrets);
391
+ this.compiled ??= compile(this.secrets);
392
+ return maskCompiled(text, this.compiled, (secret) => {
393
+ const name = this.names.get(secret);
394
+ if (name !== void 0) this.nearMissed.add(name);
395
+ });
396
+ }
397
+ /**
398
+ * The `{{env.*}}` variables whose value was redacted in a form differing from
399
+ * the one supplied — the case a literal comparison would have missed
400
+ * (design D2). Names only: a caller reporting this must never hold the value.
401
+ */
402
+ nearMissedVariables() {
403
+ return [...this.nearMissed];
404
+ }
405
+ /**
406
+ * Whether no value is registered — i.e. nothing in the run referenced
407
+ * `{{env.*}}`. Says nothing about which values, so a caller deciding what a
408
+ * report may carry never holds a secret to decide it.
409
+ */
410
+ isEmpty() {
411
+ return this.secrets.size === 0;
373
412
  }
374
413
  };
375
414
 
@@ -1316,6 +1355,20 @@ async function getChangedFiles(baseRef, cwd = process.cwd()) {
1316
1355
  throw toDiffError(error, baseRef);
1317
1356
  }
1318
1357
  }
1358
+ async function getUncommittedFiles(cwd = process.cwd()) {
1359
+ try {
1360
+ const status = await simpleGit(cwd).status();
1361
+ const paths = /* @__PURE__ */ new Set();
1362
+ for (const file of status.files) {
1363
+ paths.add(file.path.replace(/\\/g, "/"));
1364
+ const from = file.from;
1365
+ if (from) paths.add(from.replace(/\\/g, "/"));
1366
+ }
1367
+ return [...paths].sort();
1368
+ } catch {
1369
+ return [];
1370
+ }
1371
+ }
1319
1372
 
1320
1373
  // src/impact.ts
1321
1374
  import picomatch from "picomatch";
@@ -1354,6 +1407,25 @@ function mapImpact(changedFiles, routes, ignore = []) {
1354
1407
  };
1355
1408
  }
1356
1409
 
1410
+ // src/report/uncommitted.ts
1411
+ function printUncommitted(uncommitted, changedFiles, config, baseRef) {
1412
+ const inDiff = new Set(changedFiles.map((file) => file.replace(/\\/g, "/")));
1413
+ const routes = config.routes ?? {};
1414
+ const ignore = config.ignore ?? [];
1415
+ const findings = uncommitted.filter((file) => !inDiff.has(file)).map((file) => ({ file, impact: mapImpact([file], routes, ignore) })).filter(({ impact }) => impact.ignoredFiles.length === 0);
1416
+ if (findings.length === 0) return;
1417
+ console.error(
1418
+ `
1419
+ warning: ${findings.length} file(s) changed in the working tree are not in the diff against '${baseRef}':`
1420
+ );
1421
+ for (const { file, impact } of findings) {
1422
+ console.error(
1423
+ impact.affectedRoutes.length > 0 ? ` ${file} -> ${impact.affectedRoutes.join(", ")}` : ` ${file} (matched by no routes: or ignore: glob)`
1424
+ );
1425
+ }
1426
+ console.error("Nothing above was considered. Commit or stash them to include them.");
1427
+ }
1428
+
1357
1429
  // src/llm/brain.ts
1358
1430
  import { generateObject } from "ai";
1359
1431
 
@@ -2038,7 +2110,11 @@ function renderSteps(steps) {
2038
2110
  ${items}
2039
2111
  </ol>`;
2040
2112
  }
2041
- async function renderTest(result, cwd) {
2113
+ function withheldScreenshot(file, reportDir) {
2114
+ const href = path7.relative(reportDir, file).split(path7.sep).join("/");
2115
+ return ` <p class="note">Screenshot withheld: this run handled secrets ({{env.*}}), and a screenshot cannot be masked. <a href="${escapeHtml(href)}">${escapeHtml(href)}</a></p>`;
2116
+ }
2117
+ async function renderTest(result, screenshots, cwd) {
2042
2118
  const failed = result.status === "failed";
2043
2119
  const notRun = result.status === "not-run";
2044
2120
  const tagClass = failed ? "fail" : notRun ? "skip" : "pass";
@@ -2060,7 +2136,9 @@ async function renderTest(result, cwd) {
2060
2136
  parts.push(` <p class="note">${escapeHtml(result.reason)}</p>`);
2061
2137
  }
2062
2138
  parts.push(` ${renderSteps(result.steps)}`);
2063
- if (failed && result.screenshot) {
2139
+ if (failed && result.screenshot && screenshots !== "embed") {
2140
+ parts.push(withheldScreenshot(result.screenshot, screenshots.withheldRelativeTo));
2141
+ } else if (failed && result.screenshot) {
2064
2142
  const embedded = await embedScreenshot(result.screenshot);
2065
2143
  parts.push(
2066
2144
  embedded ? ` <img class="shot" alt="Screenshot at failure" src="${embedded}">` : ' <p class="note">Screenshot unavailable.</p>'
@@ -2075,7 +2153,7 @@ async function renderHtml(results, skipped, meta) {
2075
2153
  const notRun = results.filter((r) => r.status === "not-run");
2076
2154
  const generatedAt = (meta.generatedAt ?? /* @__PURE__ */ new Date()).toISOString();
2077
2155
  const ordered = [...failures, ...notRun, ...results.filter((r) => r.status === "passed")];
2078
- const detail = (await Promise.all(ordered.map((r) => renderTest(r, meta.cwd)))).join("\n");
2156
+ const detail = (await Promise.all(ordered.map((r) => renderTest(r, meta.screenshots, meta.cwd)))).join("\n");
2079
2157
  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>`;
2080
2158
  const banner = meta.incomplete === void 0 ? "" : ` <section class="incomplete">Run stopped: ${escapeHtml(meta.incomplete)}</section>
2081
2159
 
@@ -2119,7 +2197,7 @@ ${rows}${rows && skippedRows ? "\n" : ""}${skippedRows}
2119
2197
 
2120
2198
  ${detail}
2121
2199
 
2122
- <footer>Generated by blastproof. Screenshots are embedded, so this file works offline.</footer>
2200
+ <footer>Generated by blastproof. ${meta.screenshots === "embed" ? "Screenshots are embedded, so this file works offline." : "Screenshots were withheld because this run handled secrets ({{env.*}}); failures link to the file instead."}</footer>
2123
2201
  </main>
2124
2202
  </body>
2125
2203
  </html>
@@ -2489,8 +2567,16 @@ function printImpactReport(impact, selection, cwd) {
2489
2567
  }
2490
2568
  console.log("---------------------------------------------------------------");
2491
2569
  }
2492
- async function finalize(results, skipped, options, sessionDir, durationMs, impact, incomplete, spend) {
2570
+ function reportNearMissedSecrets(variables) {
2571
+ for (const variable of variables) {
2572
+ console.error(
2573
+ `warning: the application returned ${variable} in a different form than the value supplied (case or spacing). It was redacted, but a form this tool cannot recognise \u2014 an encoding, a hash \u2014 would not have been. Check what that page does with the value.`
2574
+ );
2575
+ }
2576
+ }
2577
+ async function finalize(results, skipped, options, sessionDir, secretsHeld, nearMissedSecrets, durationMs, impact, incomplete, spend) {
2493
2578
  if (results.length > 0) printSummary(results);
2579
+ reportNearMissedSecrets(nearMissedSecrets);
2494
2580
  if (spend) console.log(formatSpendLine(spend));
2495
2581
  const score = computeScore(results);
2496
2582
  console.log(
@@ -2516,7 +2602,13 @@ async function finalize(results, skipped, options, sessionDir, durationMs, impac
2516
2602
  minScore: options.minScore,
2517
2603
  cwd: options.cwd,
2518
2604
  incomplete: incomplete?.message,
2519
- spend
2605
+ spend,
2606
+ // A screenshot cannot be masked, so a run that held any `{{env.*}}` value
2607
+ // links to it instead of carrying it (design
2608
+ // withhold-a-screenshot-that-saw-a-secret, D1): the run's mask, not the
2609
+ // failed test's own placeholders, because a login credential is echoed by
2610
+ // the pages of every test that comes after it.
2611
+ screenshots: secretsHeld ? { withheldRelativeTo: path9.dirname(target) } : "embed"
2520
2612
  });
2521
2613
  await writeHtml(target, html);
2522
2614
  console.log(`HTML report: ${path9.relative(options.cwd, target)}`);
@@ -2574,7 +2666,8 @@ Dry run: ${selected.length} test(s) selected, base_url=${config.base_url}`);
2574
2666
  for (const test of selected) {
2575
2667
  console.log(` ${test.summary} [${test.priority}] (${path9.relative(cwd, test.path)})`);
2576
2668
  }
2577
- const authSteps = config.auth?.steps ?? [];
2669
+ const needsLogin = selected.some((test) => test.auth);
2670
+ const authSteps = needsLogin ? config.auth?.steps ?? [] : [];
2578
2671
  const withAuth = authSteps.length > 0 ? [...selected, { steps: authSteps }] : selected;
2579
2672
  const ceiling = estimateMaxModelCalls(
2580
2673
  withAuth,
@@ -2619,6 +2712,7 @@ async function runCommand(options) {
2619
2712
  throw error;
2620
2713
  }
2621
2714
  impact = mapImpact(changedFiles, config.routes ?? {}, config.ignore ?? []);
2715
+ printUncommitted(await getUncommittedFiles(options.cwd), changedFiles, config, base);
2622
2716
  }
2623
2717
  const testsDir = path9.join(options.cwd, TESTS_RELATIVE_DIR);
2624
2718
  let files;
@@ -2685,7 +2779,7 @@ ${results.length} test file(s) could not be parsed:`);
2685
2779
  console.log(
2686
2780
  impact ? "No impacted tests to run." : "No tests matched the given filters."
2687
2781
  );
2688
- return finalize(results, selection.unroutedSkipped, options, sessionDir, Date.now() - startedAt, impact);
2782
+ return finalize(results, selection.unroutedSkipped, options, sessionDir, false, [], Date.now() - startedAt, impact);
2689
2783
  }
2690
2784
  try {
2691
2785
  createModel(config.llm);
@@ -2711,7 +2805,7 @@ ${results.length} test file(s) could not be parsed:`);
2711
2805
  let incomplete;
2712
2806
  try {
2713
2807
  let session;
2714
- if (config.auth) {
2808
+ if (config.auth && selected.some((test) => test.auth)) {
2715
2809
  try {
2716
2810
  console.log("Authenticating...");
2717
2811
  session = await authenticate({
@@ -2783,6 +2877,8 @@ ${results.length} test file(s) could not be parsed:`);
2783
2877
  selection.unroutedSkipped,
2784
2878
  options,
2785
2879
  sessionDir,
2880
+ !runMask.isEmpty(),
2881
+ runMask.nearMissedVariables(),
2786
2882
  Date.now() - startedAt,
2787
2883
  impact,
2788
2884
  incomplete,
@@ -2832,6 +2928,7 @@ async function resolveTargets(options, config, suite) {
2832
2928
  const base = options.base ?? "main";
2833
2929
  const changedFiles = await getChangedFiles(base, options.cwd);
2834
2930
  const impact = mapImpact(changedFiles, config.routes ?? {}, config.ignore ?? []);
2931
+ printUncommitted(await getUncommittedFiles(options.cwd), changedFiles, config, base);
2835
2932
  const filesForRoute = (route) => changedFiles.filter(
2836
2933
  (file) => mapImpact([file], config.routes ?? {}, config.ignore ?? []).affectedRoutes.includes(route)
2837
2934
  );
@@ -3109,7 +3206,7 @@ function parsePositiveNumber(flag) {
3109
3206
  };
3110
3207
  }
3111
3208
  var program = new Command();
3112
- program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.18.0");
3209
+ program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.20.0");
3113
3210
  program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
3114
3211
  try {
3115
3212
  const result = await initProject(process.cwd());