blastproof 0.19.0 → 0.21.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 +6 -2
- package/dist/cli.js +165 -54
- 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?
|
|
@@ -273,9 +275,11 @@ The application under test is not trusted input: its page content reaches the mo
|
|
|
273
275
|
|
|
274
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.
|
|
275
277
|
|
|
276
|
-
**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, and replaced with a label naming its variable: `[redacted TEST_PASSWORD]`. The label carries the name and nothing derived from the value, so it tells a reader, and the model, *which* secret was there without telling anyone what it was. 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)).
|
|
277
281
|
|
|
278
|
-
**A redacted value
|
|
282
|
+
**A redacted value is asserted on by identity.** A step that verifies text equal to an `{{env.*}}` value reaches the judge through the mask, like the page does, so both sides read `[redacted NAME]`. The same label on both sides is a match; different labels are not. Until 0.21.0 every secret was redacted to the same `***`, and a step verifying one secret passed against a page showing another — measured, 3 times out of 3 ([#87](https://github.com/hamc/blastproof/issues/87)). What the judge still cannot check is anything *about* the value beyond which variable it is: its length, its format, a prefix. Assert on those with a value that is not in `{{env.*}}`.
|
|
279
283
|
|
|
280
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.
|
|
281
285
|
|
package/dist/cli.js
CHANGED
|
@@ -118,7 +118,7 @@ var SAMPLE_LOGIN_TEMPLATE = `# TEMPLATE \u2014 rename to login.yaml once these s
|
|
|
118
118
|
#
|
|
119
119
|
# Credentials come from the environment. Export them before running:
|
|
120
120
|
# export TEST_EMAIL=... TEST_PASSWORD=...
|
|
121
|
-
# Substituted values are masked as
|
|
121
|
+
# Substituted values are masked as [redacted NAME] everywhere and never reach the model.
|
|
122
122
|
summary: Login with valid credentials succeeds
|
|
123
123
|
priority: P0
|
|
124
124
|
tags: [smoke, auth]
|
|
@@ -336,16 +336,34 @@ 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
|
+
var REDACTION_PREFIX = "[redacted";
|
|
343
|
+
function redactionLabel(name) {
|
|
344
|
+
return name === void 0 ? `${REDACTION_PREFIX}]` : `${REDACTION_PREFIX} ${name}]`;
|
|
345
|
+
}
|
|
346
|
+
function compile(secrets, names) {
|
|
347
|
+
return [...secrets].filter(Boolean).sort((a, b) => b.length - a.length).map((value) => ({ value, pattern: secretPattern(value), label: redactionLabel(names?.get(value)) }));
|
|
348
|
+
}
|
|
349
|
+
function maskCompiled(text, compiled, onNearMiss) {
|
|
341
350
|
let masked = text;
|
|
342
|
-
for (const
|
|
343
|
-
|
|
351
|
+
for (const { value, pattern, label } of compiled) {
|
|
352
|
+
pattern.lastIndex = 0;
|
|
353
|
+
masked = masked.replace(pattern, (found) => {
|
|
354
|
+
if (found !== value) onNearMiss?.(value, found);
|
|
355
|
+
return label;
|
|
356
|
+
});
|
|
344
357
|
}
|
|
345
358
|
return masked;
|
|
346
359
|
}
|
|
347
360
|
var SecretsMask = class {
|
|
348
361
|
secrets = /* @__PURE__ */ new Set();
|
|
362
|
+
/** Value -> the `{{env.*}}` variable it came from, so a warning can name it. */
|
|
363
|
+
names = /* @__PURE__ */ new Map();
|
|
364
|
+
nearMissed = /* @__PURE__ */ new Set();
|
|
365
|
+
/** Built on first use after a registration, not on every masked string. */
|
|
366
|
+
compiled;
|
|
349
367
|
/** Registers the current values of the env vars referenced in `text`. Throws MissingEnvError on unset vars. */
|
|
350
368
|
registerFrom(text, env = process.env) {
|
|
351
369
|
for (const name of referencedEnvVars(text)) {
|
|
@@ -353,7 +371,7 @@ var SecretsMask = class {
|
|
|
353
371
|
if (value === void 0) {
|
|
354
372
|
throw new MissingEnvError(name);
|
|
355
373
|
}
|
|
356
|
-
this.add(value);
|
|
374
|
+
this.add(value, name);
|
|
357
375
|
}
|
|
358
376
|
}
|
|
359
377
|
/**
|
|
@@ -362,14 +380,39 @@ var SecretsMask = class {
|
|
|
362
380
|
* space stopped matching a literal search and passed through unmasked.
|
|
363
381
|
* This cannot cover every transform a page might apply; see the README.
|
|
364
382
|
*/
|
|
365
|
-
add(value) {
|
|
383
|
+
add(value, name) {
|
|
366
384
|
if (!value) return;
|
|
367
385
|
this.secrets.add(value);
|
|
386
|
+
this.compiled = void 0;
|
|
387
|
+
if (name !== void 0 && !this.names.has(value)) this.names.set(value, name);
|
|
368
388
|
const encoded = encodeURIComponent(value);
|
|
369
|
-
if (encoded !== value)
|
|
389
|
+
if (encoded !== value) {
|
|
390
|
+
this.secrets.add(encoded);
|
|
391
|
+
if (name !== void 0 && !this.names.has(encoded)) this.names.set(encoded, name);
|
|
392
|
+
}
|
|
370
393
|
}
|
|
371
394
|
mask(text) {
|
|
372
|
-
|
|
395
|
+
this.compiled ??= compile(this.secrets, this.names);
|
|
396
|
+
return maskCompiled(text, this.compiled, (secret) => {
|
|
397
|
+
const name = this.names.get(secret);
|
|
398
|
+
if (name !== void 0) this.nearMissed.add(name);
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* The `{{env.*}}` variables whose value was redacted in a form differing from
|
|
403
|
+
* the one supplied — the case a literal comparison would have missed
|
|
404
|
+
* (design D2). Names only: a caller reporting this must never hold the value.
|
|
405
|
+
*/
|
|
406
|
+
nearMissedVariables() {
|
|
407
|
+
return [...this.nearMissed];
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Whether no value is registered — i.e. nothing in the run referenced
|
|
411
|
+
* `{{env.*}}`. Says nothing about which values, so a caller deciding what a
|
|
412
|
+
* report may carry never holds a secret to decide it.
|
|
413
|
+
*/
|
|
414
|
+
isEmpty() {
|
|
415
|
+
return this.secrets.size === 0;
|
|
373
416
|
}
|
|
374
417
|
};
|
|
375
418
|
|
|
@@ -638,7 +681,26 @@ var StepRecovery = class {
|
|
|
638
681
|
* and #28 has now produced one on three applications.
|
|
639
682
|
*/
|
|
640
683
|
refusalFor(action) {
|
|
641
|
-
return this.repeatedCommitRefusal(action) ?? this.unsourcedValueRefusal(action);
|
|
684
|
+
return this.repeatedCommitRefusal(action) ?? this.redactionLabelRefusal(action) ?? this.unsourcedValueRefusal(action);
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Refuses a typed value containing a redaction label (design
|
|
688
|
+
* label-a-redaction-with-its-variable, D3).
|
|
689
|
+
*
|
|
690
|
+
* Checked before the source check because the source check would admit it:
|
|
691
|
+
* `observe` credits the model with the masked snapshot, and the label is in
|
|
692
|
+
* the masked snapshot. It is the mask's own writing, not the application's, so
|
|
693
|
+
* typing it would put the words "[redacted TEST_EMAIL]" into a real field.
|
|
694
|
+
* A prefix match rather than the exact label, because a model that copies
|
|
695
|
+
* part of one, or rebuilds one around another name, is equally wrong.
|
|
696
|
+
*
|
|
697
|
+
* Translating it back into its placeholder was the alternative, and it is the
|
|
698
|
+
* exemption #66 closed: the model would be choosing a secret to type.
|
|
699
|
+
*/
|
|
700
|
+
redactionLabelRefusal(action) {
|
|
701
|
+
if (!SOURCED_VALUE_ACTIONS.has(action.action)) return void 0;
|
|
702
|
+
if (!action.value?.includes(REDACTION_PREFIX)) return void 0;
|
|
703
|
+
return `refused: the value was NOT typed, because it contains a redaction label. ${REDACTION_PREFIX} NAME] stands for the value of {{env.NAME}}, withheld from you; it is never a value the application should receive. To enter an environment value, use the {{env.*}} placeholder this step names. If the step names none, use a value it supplies, or fail the step.`;
|
|
642
704
|
}
|
|
643
705
|
repeatedCommitRefusal(action) {
|
|
644
706
|
if (!COMMIT_ACTIONS.has(action.action)) return void 0;
|
|
@@ -1410,7 +1472,7 @@ Rules:
|
|
|
1410
1472
|
- An action reported as "blocked" is the exception to that rule: it means another element is on top of your target, not that you picked the wrong target. Re-targeting cannot fix it. Whatever is covering the page is in the snapshot \u2014 a dialog, a cookie banner, an onboarding overlay \u2014 so dismiss that first, with its own close or accept control, or by pressing Escape with no target, and then act on your original target again. Overlays can be stacked: clearing one may reveal another, and that is progress, not failure.
|
|
1411
1473
|
- Never invent a value. A value you type must come from the step, from the page, or from an {{env.*}} placeholder. This one is enforced, not merely asked: a fill or select whose value is in none of those is refused and not performed. If a step needs a value it does not give you, that is a failing step, not a gap for you to fill in.
|
|
1412
1474
|
- A record of the actions you already performed in this step may be shown to you. It is the ground truth about what happened, even when the page no longer shows it: a form that submitted successfully and came back empty looks exactly like one you never submitted. Do not redo work that record says you already did.
|
|
1413
|
-
-
|
|
1475
|
+
- \`[redacted NAME]\` in a snapshot is the value of {{env.NAME}}, deliberately withheld from you. Seeing one is expected and is not a problem. The same label is the same value; two different labels are two different values. A field showing \`[redacted NAME]\` after you filled it from {{env.NAME}} means the fill worked; treat that as success and move on. Never retry a fill because its value is redacted, never report failure because a value was withheld, and never type a label as a value: to enter an environment value, use the {{env.*}} placeholder the step names.
|
|
1414
1476
|
- Keep reasoning to one short sentence.`;
|
|
1415
1477
|
}
|
|
1416
1478
|
function agentUserPrompt(input) {
|
|
@@ -1446,7 +1508,7 @@ The record is not evidence that the step's outcome holds. An action reported as
|
|
|
1446
1508
|
|
|
1447
1509
|
Be strict about what the step asks, not about withholding a pass you can plainly see is earned. Answer with pass=true/false and a one-sentence reason.
|
|
1448
1510
|
|
|
1449
|
-
|
|
1511
|
+
\`[redacted NAME]\` marks the value of {{env.NAME}}, deliberately withheld from you. Seeing one is expected. A field holding a label is filled, not empty, so do not fail a step on the grounds that a value was redacted. The same label is the same value, and two different labels are two different values: a step expecting \`[redacted A]\` is not satisfied by a page showing \`[redacted B]\`. This applies only to the redaction itself: everything else the step asks for must still be visibly satisfied by the snapshot, and a step you genuinely cannot check against what you were shown still fails.`;
|
|
1450
1512
|
}
|
|
1451
1513
|
function assertUserPrompt(step, expectation, snapshot, stepHistory) {
|
|
1452
1514
|
const parts = [`Step under test: ${step}`];
|
|
@@ -1481,7 +1543,7 @@ Rules:
|
|
|
1481
1543
|
- **The test starts at the application's base URL, not at this route.** Begin with a step that navigates to the route and says what should be visible once it loads \u2014 "navigate to /support and verify the heading "Contact support" is shown". Without it the run opens the home page and every later step looks for controls that are not there.
|
|
1482
1544
|
- **Every step says what it should produce.** Name what must be true once the step has been carried out, not the action alone: "submit the support form and verify the confirmation page shows the ticket number", never "submit the support form". A step that names an action without an outcome asks the runner to judge whether something happened while looking at the page that succeeding produces \u2014 a submitted form comes back empty, a redirect moves the URL \u2014 and that is the shape behind several real failures.
|
|
1483
1545
|
- **A step that enters a value writes the value.** "fill the subject field with Order not received", never "enter a subject". The runner is forbidden from inventing values, and enforces it: a fill whose value is in neither the step nor the page is refused, so a step that supplies none cannot be relied on to run.
|
|
1484
|
-
- If a step needs a credential or any
|
|
1546
|
+
- If a step needs a credential, or any value that identifies an account or a person \u2014 an email address, a username, an account or customer number \u2014 write it as a placeholder like {{env.TEST_EMAIL}} or {{env.TEST_PASSWORD}}. Never write a real or invented one.
|
|
1485
1547
|
- Keep the whole test to a handful of steps: one journey, not an exhaustive suite.`;
|
|
1486
1548
|
}
|
|
1487
1549
|
function plannerUserPrompt(input) {
|
|
@@ -1697,6 +1759,41 @@ import { mkdir as mkdir4, writeFile as writeFile3 } from "fs/promises";
|
|
|
1697
1759
|
import path6 from "path";
|
|
1698
1760
|
import { stringify } from "yaml";
|
|
1699
1761
|
|
|
1762
|
+
// src/runner/authoring.ts
|
|
1763
|
+
var VALUE_VERBS = ["fill", "enter", "type", "input", "set"];
|
|
1764
|
+
var LEADING_VALUE_VERB = new RegExp(`^\\s*(?:${VALUE_VERBS.join("|")})\\b`, "i");
|
|
1765
|
+
var CONNECTOR_WORDS = ["with", "to", "as", "using", "into", "from", "in"];
|
|
1766
|
+
var CONNECTOR_WORD = new RegExp(`\\b(?:${CONNECTOR_WORDS.join("|")})\\b`, "i");
|
|
1767
|
+
var CONNECTOR_SYMBOL = /["'`:=]|\{\{env\./i;
|
|
1768
|
+
var PHRASAL_IN = new RegExp(`^(\\s*(?:${VALUE_VERBS.join("|")}))\\s+in\\b`, "i");
|
|
1769
|
+
function namesAValue(step) {
|
|
1770
|
+
const withoutPhrasal = step.replace(PHRASAL_IN, "$1");
|
|
1771
|
+
return CONNECTOR_WORD.test(withoutPhrasal) || CONNECTOR_SYMBOL.test(withoutPhrasal);
|
|
1772
|
+
}
|
|
1773
|
+
function namesNoValue(step) {
|
|
1774
|
+
return LEADING_VALUE_VERB.test(step) && !namesAValue(step);
|
|
1775
|
+
}
|
|
1776
|
+
function entersNamedValue(step) {
|
|
1777
|
+
return LEADING_VALUE_VERB.test(step) && namesAValue(step);
|
|
1778
|
+
}
|
|
1779
|
+
function detectMissingValues(tests) {
|
|
1780
|
+
const findings = [];
|
|
1781
|
+
for (const test of tests) {
|
|
1782
|
+
for (const origin of ["setup", "steps"]) {
|
|
1783
|
+
const steps = origin === "setup" ? test.setup ?? [] : test.steps;
|
|
1784
|
+
steps.forEach((step, position) => {
|
|
1785
|
+
if (namesNoValue(step)) {
|
|
1786
|
+
findings.push({ test, origin, index: position + 1, step });
|
|
1787
|
+
}
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
return { findings };
|
|
1792
|
+
}
|
|
1793
|
+
function suggestValueClause(step) {
|
|
1794
|
+
return `${step.trimEnd()} with <value>`;
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1700
1797
|
// src/runner/testfile.ts
|
|
1701
1798
|
import { readFile as readFile3, readdir } from "fs/promises";
|
|
1702
1799
|
import path5 from "path";
|
|
@@ -1772,9 +1869,20 @@ var CREDENTIAL_WORD = /\b(password|passwd|api[ _-]?key|token|secret|credential)\
|
|
|
1772
1869
|
var QUOTED_LITERAL = /["'][^"']+["']/;
|
|
1773
1870
|
function findSecretLiterals(steps) {
|
|
1774
1871
|
return steps.filter(
|
|
1775
|
-
(step) => CREDENTIAL_WORD.test(step) &&
|
|
1872
|
+
(step) => CREDENTIAL_WORD.test(step) && !step.includes("{{env.") && (QUOTED_LITERAL.test(step) || entersNamedValue(step))
|
|
1776
1873
|
);
|
|
1777
1874
|
}
|
|
1875
|
+
var EMAIL_ADDRESS = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g;
|
|
1876
|
+
function findUnsourcedEmails(steps, snapshot) {
|
|
1877
|
+
const page = snapshot.toLowerCase();
|
|
1878
|
+
const found = [];
|
|
1879
|
+
steps.forEach((text, step) => {
|
|
1880
|
+
for (const [address] of text.matchAll(EMAIL_ADDRESS)) {
|
|
1881
|
+
if (!page.includes(address.toLowerCase())) found.push({ step, address });
|
|
1882
|
+
}
|
|
1883
|
+
});
|
|
1884
|
+
return found;
|
|
1885
|
+
}
|
|
1778
1886
|
function routeToSlug(route) {
|
|
1779
1887
|
const slug = route.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
1780
1888
|
return slug || "home";
|
|
@@ -1806,9 +1914,10 @@ async function generateForRoute(page, options) {
|
|
|
1806
1914
|
`Cannot load ${url}: ${error instanceof Error ? error.message : String(error)}`
|
|
1807
1915
|
);
|
|
1808
1916
|
}
|
|
1917
|
+
const pageSnapshot = mask(await takeSnapshot(page));
|
|
1809
1918
|
const generated = await brain.planTest({
|
|
1810
1919
|
route,
|
|
1811
|
-
snapshot:
|
|
1920
|
+
snapshot: pageSnapshot,
|
|
1812
1921
|
changedFiles
|
|
1813
1922
|
});
|
|
1814
1923
|
const leaked = findSecretLiterals(generated.steps);
|
|
@@ -1817,7 +1926,11 @@ async function generateForRoute(page, options) {
|
|
|
1817
1926
|
`Generated steps contain literal secrets instead of {{env.VAR}} placeholders: ${leaked.join(" | ")}`
|
|
1818
1927
|
);
|
|
1819
1928
|
}
|
|
1820
|
-
return {
|
|
1929
|
+
return {
|
|
1930
|
+
...generated,
|
|
1931
|
+
routes: [route],
|
|
1932
|
+
unsourcedEmails: findUnsourcedEmails(generated.steps, pageSnapshot)
|
|
1933
|
+
};
|
|
1821
1934
|
}
|
|
1822
1935
|
async function writeDraft(cwd, draft, meta) {
|
|
1823
1936
|
const dir = path6.join(cwd, TESTS_RELATIVE_DIR);
|
|
@@ -2071,7 +2184,11 @@ function renderSteps(steps) {
|
|
|
2071
2184
|
${items}
|
|
2072
2185
|
</ol>`;
|
|
2073
2186
|
}
|
|
2074
|
-
|
|
2187
|
+
function withheldScreenshot(file, reportDir) {
|
|
2188
|
+
const href = path7.relative(reportDir, file).split(path7.sep).join("/");
|
|
2189
|
+
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>`;
|
|
2190
|
+
}
|
|
2191
|
+
async function renderTest(result, screenshots, cwd) {
|
|
2075
2192
|
const failed = result.status === "failed";
|
|
2076
2193
|
const notRun = result.status === "not-run";
|
|
2077
2194
|
const tagClass = failed ? "fail" : notRun ? "skip" : "pass";
|
|
@@ -2093,7 +2210,9 @@ async function renderTest(result, cwd) {
|
|
|
2093
2210
|
parts.push(` <p class="note">${escapeHtml(result.reason)}</p>`);
|
|
2094
2211
|
}
|
|
2095
2212
|
parts.push(` ${renderSteps(result.steps)}`);
|
|
2096
|
-
if (failed && result.screenshot) {
|
|
2213
|
+
if (failed && result.screenshot && screenshots !== "embed") {
|
|
2214
|
+
parts.push(withheldScreenshot(result.screenshot, screenshots.withheldRelativeTo));
|
|
2215
|
+
} else if (failed && result.screenshot) {
|
|
2097
2216
|
const embedded = await embedScreenshot(result.screenshot);
|
|
2098
2217
|
parts.push(
|
|
2099
2218
|
embedded ? ` <img class="shot" alt="Screenshot at failure" src="${embedded}">` : ' <p class="note">Screenshot unavailable.</p>'
|
|
@@ -2108,7 +2227,7 @@ async function renderHtml(results, skipped, meta) {
|
|
|
2108
2227
|
const notRun = results.filter((r) => r.status === "not-run");
|
|
2109
2228
|
const generatedAt = (meta.generatedAt ?? /* @__PURE__ */ new Date()).toISOString();
|
|
2110
2229
|
const ordered = [...failures, ...notRun, ...results.filter((r) => r.status === "passed")];
|
|
2111
|
-
const detail = (await Promise.all(ordered.map((r) => renderTest(r, meta.cwd)))).join("\n");
|
|
2230
|
+
const detail = (await Promise.all(ordered.map((r) => renderTest(r, meta.screenshots, meta.cwd)))).join("\n");
|
|
2112
2231
|
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>`;
|
|
2113
2232
|
const banner = meta.incomplete === void 0 ? "" : ` <section class="incomplete">Run stopped: ${escapeHtml(meta.incomplete)}</section>
|
|
2114
2233
|
|
|
@@ -2152,7 +2271,7 @@ ${rows}${rows && skippedRows ? "\n" : ""}${skippedRows}
|
|
|
2152
2271
|
|
|
2153
2272
|
${detail}
|
|
2154
2273
|
|
|
2155
|
-
<footer>Generated by blastproof. Screenshots are embedded, so this file works offline
|
|
2274
|
+
<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>
|
|
2156
2275
|
</main>
|
|
2157
2276
|
</body>
|
|
2158
2277
|
</html>
|
|
@@ -2249,36 +2368,6 @@ async function writeJUnit(file, xml) {
|
|
|
2249
2368
|
}
|
|
2250
2369
|
}
|
|
2251
2370
|
|
|
2252
|
-
// src/runner/authoring.ts
|
|
2253
|
-
var VALUE_VERBS = ["fill", "enter", "type", "input", "set"];
|
|
2254
|
-
var LEADING_VALUE_VERB = new RegExp(`^\\s*(?:${VALUE_VERBS.join("|")})\\b`, "i");
|
|
2255
|
-
var CONNECTOR_WORDS = ["with", "to", "as", "using", "into", "from", "in"];
|
|
2256
|
-
var CONNECTOR_WORD = new RegExp(`\\b(?:${CONNECTOR_WORDS.join("|")})\\b`, "i");
|
|
2257
|
-
var CONNECTOR_SYMBOL = /["'`:=]|\{\{env\./i;
|
|
2258
|
-
var PHRASAL_IN = new RegExp(`^(\\s*(?:${VALUE_VERBS.join("|")}))\\s+in\\b`, "i");
|
|
2259
|
-
function namesNoValue(step) {
|
|
2260
|
-
if (!LEADING_VALUE_VERB.test(step)) return false;
|
|
2261
|
-
const withoutPhrasal = step.replace(PHRASAL_IN, "$1");
|
|
2262
|
-
return !CONNECTOR_WORD.test(withoutPhrasal) && !CONNECTOR_SYMBOL.test(withoutPhrasal);
|
|
2263
|
-
}
|
|
2264
|
-
function detectMissingValues(tests) {
|
|
2265
|
-
const findings = [];
|
|
2266
|
-
for (const test of tests) {
|
|
2267
|
-
for (const origin of ["setup", "steps"]) {
|
|
2268
|
-
const steps = origin === "setup" ? test.setup ?? [] : test.steps;
|
|
2269
|
-
steps.forEach((step, position) => {
|
|
2270
|
-
if (namesNoValue(step)) {
|
|
2271
|
-
findings.push({ test, origin, index: position + 1, step });
|
|
2272
|
-
}
|
|
2273
|
-
});
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
|
-
return { findings };
|
|
2277
|
-
}
|
|
2278
|
-
function suggestValueClause(step) {
|
|
2279
|
-
return `${step.trimEnd()} with <value>`;
|
|
2280
|
-
}
|
|
2281
|
-
|
|
2282
2371
|
// src/runner/pool.ts
|
|
2283
2372
|
async function runWithConcurrency(items, concurrency, run) {
|
|
2284
2373
|
if (concurrency < 1) throw new RangeError(`concurrency must be at least 1, got ${concurrency}`);
|
|
@@ -2522,8 +2611,16 @@ function printImpactReport(impact, selection, cwd) {
|
|
|
2522
2611
|
}
|
|
2523
2612
|
console.log("---------------------------------------------------------------");
|
|
2524
2613
|
}
|
|
2525
|
-
|
|
2614
|
+
function reportNearMissedSecrets(variables) {
|
|
2615
|
+
for (const variable of variables) {
|
|
2616
|
+
console.error(
|
|
2617
|
+
`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.`
|
|
2618
|
+
);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
async function finalize(results, skipped, options, sessionDir, secretsHeld, nearMissedSecrets, durationMs, impact, incomplete, spend) {
|
|
2526
2622
|
if (results.length > 0) printSummary(results);
|
|
2623
|
+
reportNearMissedSecrets(nearMissedSecrets);
|
|
2527
2624
|
if (spend) console.log(formatSpendLine(spend));
|
|
2528
2625
|
const score = computeScore(results);
|
|
2529
2626
|
console.log(
|
|
@@ -2549,7 +2646,13 @@ async function finalize(results, skipped, options, sessionDir, durationMs, impac
|
|
|
2549
2646
|
minScore: options.minScore,
|
|
2550
2647
|
cwd: options.cwd,
|
|
2551
2648
|
incomplete: incomplete?.message,
|
|
2552
|
-
spend
|
|
2649
|
+
spend,
|
|
2650
|
+
// A screenshot cannot be masked, so a run that held any `{{env.*}}` value
|
|
2651
|
+
// links to it instead of carrying it (design
|
|
2652
|
+
// withhold-a-screenshot-that-saw-a-secret, D1): the run's mask, not the
|
|
2653
|
+
// failed test's own placeholders, because a login credential is echoed by
|
|
2654
|
+
// the pages of every test that comes after it.
|
|
2655
|
+
screenshots: secretsHeld ? { withheldRelativeTo: path9.dirname(target) } : "embed"
|
|
2553
2656
|
});
|
|
2554
2657
|
await writeHtml(target, html);
|
|
2555
2658
|
console.log(`HTML report: ${path9.relative(options.cwd, target)}`);
|
|
@@ -2720,7 +2823,7 @@ ${results.length} test file(s) could not be parsed:`);
|
|
|
2720
2823
|
console.log(
|
|
2721
2824
|
impact ? "No impacted tests to run." : "No tests matched the given filters."
|
|
2722
2825
|
);
|
|
2723
|
-
return finalize(results, selection.unroutedSkipped, options, sessionDir, Date.now() - startedAt, impact);
|
|
2826
|
+
return finalize(results, selection.unroutedSkipped, options, sessionDir, false, [], Date.now() - startedAt, impact);
|
|
2724
2827
|
}
|
|
2725
2828
|
try {
|
|
2726
2829
|
createModel(config.llm);
|
|
@@ -2818,6 +2921,8 @@ ${results.length} test file(s) could not be parsed:`);
|
|
|
2818
2921
|
selection.unroutedSkipped,
|
|
2819
2922
|
options,
|
|
2820
2923
|
sessionDir,
|
|
2924
|
+
!runMask.isEmpty(),
|
|
2925
|
+
runMask.nearMissedVariables(),
|
|
2821
2926
|
Date.now() - startedAt,
|
|
2822
2927
|
impact,
|
|
2823
2928
|
incomplete,
|
|
@@ -3013,6 +3118,12 @@ async function planCommand(options) {
|
|
|
3013
3118
|
} finally {
|
|
3014
3119
|
await context.close();
|
|
3015
3120
|
}
|
|
3121
|
+
for (const { step, address } of draft.unsourcedEmails) {
|
|
3122
|
+
console.error(
|
|
3123
|
+
` warning: step ${step + 1} writes an email address the page does not show (${address}).
|
|
3124
|
+
If it is the account the test signs in as, use a placeholder like {{env.TEST_EMAIL}}.`
|
|
3125
|
+
);
|
|
3126
|
+
}
|
|
3016
3127
|
generated.push(route);
|
|
3017
3128
|
if (options.write) {
|
|
3018
3129
|
try {
|
|
@@ -3145,7 +3256,7 @@ function parsePositiveNumber(flag) {
|
|
|
3145
3256
|
};
|
|
3146
3257
|
}
|
|
3147
3258
|
var program = new Command();
|
|
3148
|
-
program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.
|
|
3259
|
+
program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.21.0");
|
|
3149
3260
|
program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
|
|
3150
3261
|
try {
|
|
3151
3262
|
const result = await initProject(process.cwd());
|