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 +11 -3
- package/dist/cli.js +115 -18
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
340
|
-
|
|
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
|
|
343
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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());
|